Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-object-mutation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@perstack/runtime": patch
---

Avoid object mutation in getSkillManagers and resolveExpertToRun
6 changes: 2 additions & 4 deletions packages/runtime/src/resolve-expert-to-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ describe("@perstack/runtime: resolveExpertToRun", () => {
})
expect(result.key).toBe("remote-expert")
expect(result.name).toBe("Remote Expert")
expect(experts["remote-expert"]).toBeDefined()
})

it("adds name to skills when converting from API", async () => {
Expand All @@ -89,13 +88,12 @@ describe("@perstack/runtime: resolveExpertToRun", () => {
expect(result.skills["@perstack/base"].name).toBe("@perstack/base")
})

it("caches fetched expert in experts record", async () => {
it("does not mutate experts record", async () => {
const experts: Record<string, Expert> = {}
await resolveExpertToRun("remote-expert", experts, {
perstackApiBaseUrl: "https://api.test.com",
})
expect(experts["remote-expert"]).toBeDefined()
expect(experts["remote-expert"].key).toBe("remote-expert")
expect(experts["remote-expert"]).toBeUndefined()
})
})

3 changes: 1 addition & 2 deletions packages/runtime/src/resolve-expert-to-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ export async function resolveExpertToRun(
apiKey: clientOptions.perstackApiKey,
})
const { expert } = await client.registry.experts.get({ expertKey })
experts[expertKey] = toRuntimeExpert(expert)
return experts[expertKey]
return toRuntimeExpert(expert)
}

function toRuntimeExpert(expert: ApiRegistryExpert): Expert {
Expand Down
26 changes: 14 additions & 12 deletions packages/runtime/src/setup-experts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,44 +33,46 @@ describe("@perstack/runtime: setupExperts", () => {
const mockResolve = vi.fn().mockResolvedValue(baseExpert)
const result = await setupExperts(baseSetting, mockResolve)
expect(result.expertToRun).toEqual(baseExpert)
expect(result.experts).toEqual({})
expect(result.experts).toEqual({ "test-expert": baseExpert })
expect(mockResolve).toHaveBeenCalledWith(
"test-expert",
{},
expect.any(Object),
{
perstackApiBaseUrl: "https://api.perstack.dev",
perstackApiKey: undefined,
},
)
})

it("copies experts from setting", async () => {
it("copies experts from setting and does not mutate original", async () => {
const existingExpert: Expert = { ...baseExpert, key: "existing", name: "existing" }
const settingWithExperts = {
...baseSetting,
experts: { existing: existingExpert },
}
const mockResolve = vi.fn().mockResolvedValue(baseExpert)
const result = await setupExperts(settingWithExperts, mockResolve)
expect(result.experts).toEqual({ existing: existingExpert })
expect(result.experts).toEqual({ existing: existingExpert, "test-expert": baseExpert })
expect(result.experts).not.toBe(settingWithExperts.experts)
expect(settingWithExperts.experts).toEqual({ existing: existingExpert })
})

it("resolves all delegates", async () => {
it("resolves all delegates and adds them to experts map", async () => {
const expertWithDelegates: Expert = {
...baseExpert,
delegates: ["delegate-1", "delegate-2"],
}
const delegateExpert: Expert = { ...baseExpert, key: "delegate", name: "delegate" }
const delegateExpert1: Expert = { ...baseExpert, key: "delegate-1", name: "delegate-1" }
const delegateExpert2: Expert = { ...baseExpert, key: "delegate-2", name: "delegate-2" }
const mockResolve = vi
.fn()
.mockResolvedValueOnce(expertWithDelegates)
.mockResolvedValueOnce(delegateExpert)
.mockResolvedValueOnce(delegateExpert)
await setupExperts(baseSetting, mockResolve)
.mockResolvedValueOnce(delegateExpert1)
.mockResolvedValueOnce(delegateExpert2)
const result = await setupExperts(baseSetting, mockResolve)
expect(mockResolve).toHaveBeenCalledTimes(3)
expect(mockResolve).toHaveBeenNthCalledWith(2, "delegate-1", {}, expect.any(Object))
expect(mockResolve).toHaveBeenNthCalledWith(3, "delegate-2", {}, expect.any(Object))
expect(result.experts["delegate-1"]).toEqual(delegateExpert1)
expect(result.experts["delegate-2"]).toEqual(delegateExpert2)
})

it("throws when delegate resolution returns falsy", async () => {
Expand All @@ -96,7 +98,7 @@ describe("@perstack/runtime: setupExperts", () => {
await setupExperts(settingWithKey, mockResolve)
expect(mockResolve).toHaveBeenCalledWith(
"test-expert",
{},
expect.any(Object),
{
perstackApiBaseUrl: "https://api.perstack.dev",
perstackApiKey: "my-api-key",
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime/src/setup-experts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ export async function setupExperts(
perstackApiKey: setting.perstackApiKey,
}
const expertToRun = await resolveExpertToRun(expertKey, experts, clientOptions)
experts[expertKey] = expertToRun
for (const delegateName of expertToRun.delegates) {
const delegate = await resolveExpertToRun(delegateName, experts, clientOptions)
if (!delegate) {
throw new Error(`Delegate ${delegateName} not found`)
}
experts[delegateName] = delegate
}
return { expertToRun, experts }
}
44 changes: 24 additions & 20 deletions packages/runtime/src/skill-manager/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,28 +29,32 @@ export async function getSkillManagers(
throw new Error("Base skill is not defined")
}
const allManagers: BaseSkillManager[] = []
const mcpSkills = Object.values(skills).filter(
(skill) => skill.type === "mcpStdioSkill" || skill.type === "mcpSseSkill",
)
for (const skill of mcpSkills) {
if (perstackBaseSkillCommand && skill.type === "mcpStdioSkill") {
const matchesBaseByPackage = skill.command === "npx" && skill.packageName === "@perstack/base"
const matchesBaseByArgs =
skill.command === "npx" &&
Array.isArray(skill.args) &&
skill.args.includes("@perstack/base")
if (matchesBaseByPackage || matchesBaseByArgs) {
const [overrideCommand, ...overrideArgs] = perstackBaseSkillCommand
if (!overrideCommand) {
throw new Error("perstackBaseSkillCommand must have at least one element")
const mcpSkills = Object.values(skills)
.filter((skill) => skill.type === "mcpStdioSkill" || skill.type === "mcpSseSkill")
.map((skill) => {
if (perstackBaseSkillCommand && skill.type === "mcpStdioSkill") {
const matchesBaseByPackage =
skill.command === "npx" && skill.packageName === "@perstack/base"
const matchesBaseByArgs =
skill.command === "npx" &&
Array.isArray(skill.args) &&
skill.args.includes("@perstack/base")
if (matchesBaseByPackage || matchesBaseByArgs) {
const [overrideCommand, ...overrideArgs] = perstackBaseSkillCommand
if (!overrideCommand) {
throw new Error("perstackBaseSkillCommand must have at least one element")
}
return {
...skill,
command: overrideCommand,
packageName: undefined,
args: overrideArgs,
lazyInit: false,
}
}
skill.command = overrideCommand
skill.packageName = undefined
skill.args = overrideArgs
skill.lazyInit = false
}
}
}
return skill
})
const mcpSkillManagers = mcpSkills.map((skill) => {
const manager = new McpSkillManager(skill, env, runId, eventListener)
allManagers.push(manager)
Expand Down