diff --git a/.changeset/skill-manager-package.md b/.changeset/skill-manager-package.md new file mode 100644 index 00000000..15b2a5cf --- /dev/null +++ b/.changeset/skill-manager-package.md @@ -0,0 +1,6 @@ +--- +"@perstack/runtime": patch +"@perstack/installer": patch +--- + +Introduce @perstack/skill-manager package and migrate runtime to use SkillManager diff --git a/apps/base/src/index.ts b/apps/base/src/index.ts index 72975ff8..bb81a935 100644 --- a/apps/base/src/index.ts +++ b/apps/base/src/index.ts @@ -3,6 +3,7 @@ export { errorToolResult, successToolResult } from "./lib/tool-result.js" export { BASE_SKILL_NAME, BASE_SKILL_VERSION, + type CreateBaseServerOptions, createBaseServer, registerAllTools, } from "./server.js" @@ -12,5 +13,6 @@ export * from "./tools/exec.js" export * from "./tools/read-image-file.js" export * from "./tools/read-pdf-file.js" export * from "./tools/read-text-file.js" +export * from "./tools/skill-management.js" export * from "./tools/todo.js" export * from "./tools/write-text-file.js" diff --git a/apps/base/src/server.ts b/apps/base/src/server.ts index ffb9b05d..ac640707 100644 --- a/apps/base/src/server.ts +++ b/apps/base/src/server.ts @@ -6,6 +6,8 @@ import { registerExec } from "./tools/exec.js" import { registerReadImageFile } from "./tools/read-image-file.js" import { registerReadPdfFile } from "./tools/read-pdf-file.js" import { registerReadTextFile } from "./tools/read-text-file.js" +import type { SkillManagementCallbacks } from "./tools/skill-management.js" +import { registerSkillManagementTools } from "./tools/skill-management.js" import { registerClearTodo, registerTodo } from "./tools/todo.js" import { registerWriteTextFile } from "./tools/write-text-file.js" @@ -31,11 +33,15 @@ export function registerAllTools(server: McpServer): void { registerEditTextFile(server) } +export interface CreateBaseServerOptions { + skillManagement?: SkillManagementCallbacks +} + /** * Create a base skill MCP server with all tools registered. * Used by the runtime for in-process execution via InMemoryTransport. */ -export function createBaseServer(): McpServer { +export function createBaseServer(options?: CreateBaseServerOptions): McpServer { const server = new McpServer( { name: BASE_SKILL_NAME, @@ -48,5 +54,8 @@ export function createBaseServer(): McpServer { }, ) registerAllTools(server) + if (options?.skillManagement) { + registerSkillManagementTools(server, options.skillManagement) + } return server } diff --git a/apps/base/src/tools/skill-management.test.ts b/apps/base/src/tools/skill-management.test.ts new file mode 100644 index 00000000..79baf14f --- /dev/null +++ b/apps/base/src/tools/skill-management.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from "vitest" +import type { SkillManagementCallbacks } from "./skill-management.js" +import { + registerAddDelegate, + registerAddSkill, + registerRemoveDelegate, + registerRemoveSkill, +} from "./skill-management.js" + +function createMockCallbacks(): SkillManagementCallbacks { + return { + addSkill: vi.fn().mockResolvedValue({ tools: ["toolA", "toolB"] }), + removeSkill: vi.fn().mockResolvedValue(undefined), + addDelegate: vi.fn().mockResolvedValue({ delegateToolName: "delegate-tool" }), + removeDelegate: vi.fn().mockResolvedValue(undefined), + } +} + +function createMockServer() { + return { registerTool: vi.fn() } +} + +function getHandler(mockServer: { registerTool: ReturnType }) { + return mockServer.registerTool.mock.calls[0][2] +} + +describe("skill-management tools", () => { + describe("addSkill", () => { + it("registers tool with correct metadata", () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + registerAddSkill(server as never, callbacks) + expect(server.registerTool).toHaveBeenCalledWith( + "addSkill", + expect.objectContaining({ title: "Add skill" }), + expect.any(Function), + ) + }) + + it("calls callback with correct input and returns tool list", async () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + registerAddSkill(server as never, callbacks) + const handler = getHandler(server) + const input = { + name: "my-skill", + type: "mcpStdioSkill" as const, + command: "npx", + packageName: "@my/pkg", + } + const result = await handler(input) + expect(callbacks.addSkill).toHaveBeenCalledWith(input) + expect(result).toStrictEqual({ + content: [{ type: "text", text: JSON.stringify({ tools: ["toolA", "toolB"] }) }], + }) + }) + + it("returns errorToolResult when callback throws", async () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + ;(callbacks.addSkill as ReturnType).mockRejectedValue( + new Error("skill already exists"), + ) + registerAddSkill(server as never, callbacks) + const handler = getHandler(server) + const result = await handler({ name: "dup", type: "mcpStdioSkill" }) + expect(result).toStrictEqual({ + content: [ + { + type: "text", + text: JSON.stringify({ error: "Error", message: "skill already exists" }), + }, + ], + }) + }) + }) + + describe("removeSkill", () => { + it("registers tool with correct metadata", () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + registerRemoveSkill(server as never, callbacks) + expect(server.registerTool).toHaveBeenCalledWith( + "removeSkill", + expect.objectContaining({ title: "Remove skill" }), + expect.any(Function), + ) + }) + + it("calls callback with skill name", async () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + registerRemoveSkill(server as never, callbacks) + const handler = getHandler(server) + const result = await handler({ skillName: "my-skill" }) + expect(callbacks.removeSkill).toHaveBeenCalledWith("my-skill") + expect(result).toStrictEqual({ + content: [{ type: "text", text: JSON.stringify({ removed: "my-skill" }) }], + }) + }) + + it("returns errorToolResult when callback throws", async () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + ;(callbacks.removeSkill as ReturnType).mockRejectedValue(new Error("not found")) + registerRemoveSkill(server as never, callbacks) + const handler = getHandler(server) + const result = await handler({ skillName: "missing" }) + expect(result).toStrictEqual({ + content: [{ type: "text", text: JSON.stringify({ error: "Error", message: "not found" }) }], + }) + }) + }) + + describe("addDelegate", () => { + it("registers tool with correct metadata", () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + registerAddDelegate(server as never, callbacks) + expect(server.registerTool).toHaveBeenCalledWith( + "addDelegate", + expect.objectContaining({ title: "Add delegate" }), + expect.any(Function), + ) + }) + + it("calls callback with expert key and returns delegate tool name", async () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + registerAddDelegate(server as never, callbacks) + const handler = getHandler(server) + const result = await handler({ expertKey: "my-expert" }) + expect(callbacks.addDelegate).toHaveBeenCalledWith("my-expert") + expect(result).toStrictEqual({ + content: [{ type: "text", text: JSON.stringify({ delegateToolName: "delegate-tool" }) }], + }) + }) + + it("returns errorToolResult when callback throws", async () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + ;(callbacks.addDelegate as ReturnType).mockRejectedValue( + new Error("expert not found"), + ) + registerAddDelegate(server as never, callbacks) + const handler = getHandler(server) + const result = await handler({ expertKey: "missing" }) + expect(result).toStrictEqual({ + content: [ + { + type: "text", + text: JSON.stringify({ error: "Error", message: "expert not found" }), + }, + ], + }) + }) + }) + + describe("removeDelegate", () => { + it("registers tool with correct metadata", () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + registerRemoveDelegate(server as never, callbacks) + expect(server.registerTool).toHaveBeenCalledWith( + "removeDelegate", + expect.objectContaining({ title: "Remove delegate" }), + expect.any(Function), + ) + }) + + it("calls callback with expert name", async () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + registerRemoveDelegate(server as never, callbacks) + const handler = getHandler(server) + const result = await handler({ expertName: "my-expert" }) + expect(callbacks.removeDelegate).toHaveBeenCalledWith("my-expert") + expect(result).toStrictEqual({ + content: [{ type: "text", text: JSON.stringify({ removed: "my-expert" }) }], + }) + }) + + it("returns errorToolResult when callback throws", async () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + ;(callbacks.removeDelegate as ReturnType).mockRejectedValue( + new Error("delegate not found"), + ) + registerRemoveDelegate(server as never, callbacks) + const handler = getHandler(server) + const result = await handler({ expertName: "missing" }) + expect(result).toStrictEqual({ + content: [ + { + type: "text", + text: JSON.stringify({ error: "Error", message: "delegate not found" }), + }, + ], + }) + }) + }) +}) diff --git a/apps/base/src/tools/skill-management.ts b/apps/base/src/tools/skill-management.ts new file mode 100644 index 00000000..e74b2840 --- /dev/null +++ b/apps/base/src/tools/skill-management.ts @@ -0,0 +1,142 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { z } from "zod/v4" +import { errorToolResult, successToolResult } from "../lib/tool-result.js" + +export interface SkillManagementCallbacks { + addSkill(input: { + name: string + type: "mcpStdioSkill" | "mcpSseSkill" + command?: string + packageName?: string + args?: string[] + requiredEnv?: string[] + endpoint?: string + description?: string + rule?: string + pick?: string[] + omit?: string[] + }): Promise<{ tools: string[] }> + removeSkill(skillName: string): Promise + addDelegate(expertKey: string): Promise<{ delegateToolName: string }> + removeDelegate(expertName: string): Promise +} + +export function registerAddSkill(server: McpServer, callbacks: SkillManagementCallbacks) { + server.registerTool( + "addSkill", + { + title: "Add skill", + description: + "Dynamically add an MCP skill. Returns the list of tool names provided by the new skill.", + inputSchema: { + name: z.string().describe("Unique skill name"), + type: z.enum(["mcpStdioSkill", "mcpSseSkill"]).describe("Skill transport type"), + command: z.string().optional().describe("Command to execute (for stdio skills)"), + packageName: z.string().optional().describe("Package name for npx/uvx (for stdio skills)"), + args: z.array(z.string()).optional().describe("Additional command arguments"), + requiredEnv: z.array(z.string()).optional().describe("Required environment variable names"), + endpoint: z.string().optional().describe("SSE endpoint URL (for SSE skills)"), + description: z.string().optional().describe("Human-readable description"), + rule: z.string().optional().describe("Usage rules for the LLM"), + pick: z.array(z.string()).optional().describe("Tool names to include (whitelist)"), + omit: z.array(z.string()).optional().describe("Tool names to exclude (blacklist)"), + }, + }, + async (input: { + name: string + type: "mcpStdioSkill" | "mcpSseSkill" + command?: string + packageName?: string + args?: string[] + requiredEnv?: string[] + endpoint?: string + description?: string + rule?: string + pick?: string[] + omit?: string[] + }) => { + try { + return successToolResult(await callbacks.addSkill(input)) + } catch (e) { + if (e instanceof Error) return errorToolResult(e) + throw e + } + }, + ) +} + +export function registerRemoveSkill(server: McpServer, callbacks: SkillManagementCallbacks) { + server.registerTool( + "removeSkill", + { + title: "Remove skill", + description: "Dynamically remove an MCP skill by name. Disconnects and removes the skill.", + inputSchema: { + skillName: z.string().describe("Name of the skill to remove"), + }, + }, + async (input: { skillName: string }) => { + try { + await callbacks.removeSkill(input.skillName) + return successToolResult({ removed: input.skillName }) + } catch (e) { + if (e instanceof Error) return errorToolResult(e) + throw e + } + }, + ) +} + +export function registerAddDelegate(server: McpServer, callbacks: SkillManagementCallbacks) { + server.registerTool( + "addDelegate", + { + title: "Add delegate", + description: + "Dynamically add a delegate expert. Returns the delegate tool name so you know what to call.", + inputSchema: { + expertKey: z.string().describe("Key of the expert to add as a delegate"), + }, + }, + async (input: { expertKey: string }) => { + try { + return successToolResult(await callbacks.addDelegate(input.expertKey)) + } catch (e) { + if (e instanceof Error) return errorToolResult(e) + throw e + } + }, + ) +} + +export function registerRemoveDelegate(server: McpServer, callbacks: SkillManagementCallbacks) { + server.registerTool( + "removeDelegate", + { + title: "Remove delegate", + description: "Dynamically remove a delegate expert by name.", + inputSchema: { + expertName: z.string().describe("Name of the delegate expert to remove"), + }, + }, + async (input: { expertName: string }) => { + try { + await callbacks.removeDelegate(input.expertName) + return successToolResult({ removed: input.expertName }) + } catch (e) { + if (e instanceof Error) return errorToolResult(e) + throw e + } + }, + ) +} + +export function registerSkillManagementTools( + server: McpServer, + callbacks: SkillManagementCallbacks, +): void { + registerAddSkill(server, callbacks) + registerRemoveSkill(server, callbacks) + registerAddDelegate(server, callbacks) + registerRemoveDelegate(server, callbacks) +} diff --git a/e2e/experts/skills.toml b/e2e/experts/skills.toml index 0d93c255..e5f692ae 100644 --- a/e2e/experts/skills.toml +++ b/e2e/experts/skills.toml @@ -9,8 +9,11 @@ envPath = [".env", ".env.local"] version = "1.0.0" description = "E2E test expert with picked tools only" instruction = """ -If asked to read a file, report that readTextFile is not available. -Call attemptCompletion with the result. +Follow these steps exactly: +1. Use the todo tool to add the user's request as a task +2. Mark the task as completed using the todo tool +3. Call attemptCompletion +If asked to read a file, report that readTextFile is not available via attemptCompletion. """ [experts."e2e-pick-tools".skills."@perstack/base"] @@ -53,3 +56,51 @@ type = "mcpStdioSkill" command = "npx" packageName = "@perstack/base" pick = ["attemptCompletion", "todo"] + +# Expert for testing dynamic skill add/remove +[experts."e2e-dynamic-skills"] +version = "1.0.0" +description = "E2E test expert for dynamic skill management" +instruction = """ +Follow these steps exactly: +1. Call addSkill with: name "file-tools", type "mcpStdioSkill", command "npx", packageName "@perstack/base", pick ["readTextFile"] +2. Call readTextFile with filePath "./e2e/experts/skills.toml" +3. Call removeSkill with skillName "file-tools" +4. Call attemptCompletion +""" + +[experts."e2e-dynamic-skills".skills."@perstack/base"] +type = "mcpStdioSkill" +command = "npx" +packageName = "@perstack/base" +pick = ["attemptCompletion", "addSkill", "removeSkill"] + +# Target expert for delegate testing +[experts."e2e-delegate-target"] +version = "1.0.0" +description = "Simple target expert for delegate testing" +instruction = "Call attemptCompletion with result 'delegated OK'." + +[experts."e2e-delegate-target".skills."@perstack/base"] +type = "mcpStdioSkill" +command = "npx" +packageName = "@perstack/base" +pick = ["attemptCompletion"] + +# Expert for testing dynamic delegate add/remove +[experts."e2e-dynamic-delegates"] +version = "1.0.0" +description = "E2E test expert for dynamic delegate management" +instruction = """ +Follow these steps exactly: +1. Call addDelegate with expertKey "e2e-delegate-target" +2. Call the delegate tool (the tool name is returned by addDelegate) with query "say hello" +3. Call removeDelegate with expertName "e2e-delegate-target" +4. Call attemptCompletion +""" + +[experts."e2e-dynamic-delegates".skills."@perstack/base"] +type = "mcpStdioSkill" +command = "npx" +packageName = "@perstack/base" +pick = ["attemptCompletion", "addDelegate", "removeDelegate"] diff --git a/e2e/perstack-cli/skills.test.ts b/e2e/perstack-cli/skills.test.ts index ef9343dc..5f0917dd 100644 --- a/e2e/perstack-cli/skills.test.ts +++ b/e2e/perstack-cli/skills.test.ts @@ -107,4 +107,71 @@ describe.concurrent("Skills", () => { }, LLM_TIMEOUT, ) + + /** Dynamic skill add/remove via addSkill and removeSkill tools */ + it( + "should dynamically add and remove skills", + async () => { + const cmdResult = await runCli( + ["run", "--config", SKILLS_CONFIG, "e2e-dynamic-skills", "Go"], + { timeout: LLM_TIMEOUT }, + ) + const result = withEventParsing(cmdResult) + expect(result.exitCode).toBe(0) + + const callToolsEvents = filterEventsByType(result.events, "callTools") + + // Verify addSkill was called + const hasAddSkill = callToolsEvents.some((e) => { + const calls = (e as { toolCalls?: { toolName: string }[] }).toolCalls ?? [] + return calls.some((c) => c.toolName === "addSkill") + }) + expect(hasAddSkill).toBe(true) + + // Verify readTextFile was called (from the dynamically added skill) + const hasReadTextFile = callToolsEvents.some((e) => { + const calls = (e as { toolCalls?: { toolName: string }[] }).toolCalls ?? [] + return calls.some((c) => c.toolName === "readTextFile") + }) + expect(hasReadTextFile).toBe(true) + + // Verify removeSkill was called + const hasRemoveSkill = callToolsEvents.some((e) => { + const calls = (e as { toolCalls?: { toolName: string }[] }).toolCalls ?? [] + return calls.some((c) => c.toolName === "removeSkill") + }) + expect(hasRemoveSkill).toBe(true) + }, + LLM_TIMEOUT, + ) + + /** Dynamic delegate add/remove via addDelegate and removeDelegate tools */ + it( + "should dynamically add and remove delegates", + async () => { + const cmdResult = await runCli( + ["run", "--config", SKILLS_CONFIG, "e2e-dynamic-delegates", "Go"], + { timeout: LLM_TIMEOUT }, + ) + const result = withEventParsing(cmdResult) + expect(result.exitCode).toBe(0) + + const callToolsEvents = filterEventsByType(result.events, "callTools") + + // Verify addDelegate was called + const hasAddDelegate = callToolsEvents.some((e) => { + const calls = (e as { toolCalls?: { toolName: string }[] }).toolCalls ?? [] + return calls.some((c) => c.toolName === "addDelegate") + }) + expect(hasAddDelegate).toBe(true) + + // Verify removeDelegate was called + const hasRemoveDelegate = callToolsEvents.some((e) => { + const calls = (e as { toolCalls?: { toolName: string }[] }).toolCalls ?? [] + return calls.some((c) => c.toolName === "removeDelegate") + }) + expect(hasRemoveDelegate).toBe(true) + }, + LLM_TIMEOUT, + ) }) diff --git a/packages/installer/package.json b/packages/installer/package.json index 29ddc3b6..052d1525 100644 --- a/packages/installer/package.json +++ b/packages/installer/package.json @@ -30,7 +30,7 @@ "@perstack/api-client": "^0.0.55", "@perstack/core": "workspace:*", "@perstack/perstack-toml": "workspace:*", - "@perstack/runtime": "workspace:*" + "@perstack/skill-manager": "workspace:*" }, "devDependencies": { "@perstack/tui": "workspace:*", diff --git a/packages/installer/src/handler.ts b/packages/installer/src/handler.ts index 771db6ab..b2a9e8c1 100644 --- a/packages/installer/src/handler.ts +++ b/packages/installer/src/handler.ts @@ -2,7 +2,7 @@ import { writeFile } from "node:fs/promises" import path from "node:path" import type { Lockfile, LockfileExpert, PerstackConfig } from "@perstack/core" import { generateLockfileToml } from "@perstack/perstack-toml" -import { collectToolDefinitionsForExpert } from "@perstack/runtime" +import { SkillManager } from "@perstack/skill-manager" import { getEnv } from "@perstack/tui/get-env" import { resolveAllExperts } from "./expert-resolver.js" import { expertToLockfileExpert } from "./lockfile-generator.js" @@ -25,7 +25,7 @@ export async function installHandler(options: { const lockfileExperts: Record = {} for (const [key, expert] of Object.entries(experts)) { console.log(`Collecting tool definitions for ${key}...`) - const toolDefinitions = await collectToolDefinitionsForExpert(expert, { + const toolDefinitions = await SkillManager.collectToolDefinitions(expert, { env, perstackBaseSkillCommand: config.perstackBaseSkillCommand, }) diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 7dca15fa..e8ee04a8 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -33,11 +33,10 @@ "@ai-sdk/google": "^3.0.29", "@ai-sdk/google-vertex": "^4.0.58", "@ai-sdk/openai": "^3.0.29", - "@modelcontextprotocol/sdk": "^1.26.0", "@paralleldrive/cuid2": "^3.3.0", "@perstack/api-client": "^0.0.55", - "@perstack/base": "workspace:*", "@perstack/core": "workspace:*", + "@perstack/skill-manager": "workspace:*", "ai": "^6.0.86", "ollama-ai-provider-v2": "^3.3.0", "ts-dedent": "^2.2.0", diff --git a/packages/runtime/src/helpers/tool-set.ts b/packages/runtime/src/helpers/tool-set.ts new file mode 100644 index 00000000..db27d368 --- /dev/null +++ b/packages/runtime/src/helpers/tool-set.ts @@ -0,0 +1,13 @@ +import type { SkillManager } from "@perstack/skill-manager" +import { jsonSchema, type ToolSet, tool } from "ai" + +export function getToolSet(skillManager: SkillManager): ToolSet { + const tools: ToolSet = {} + for (const def of skillManager.getToolDefinitions()) { + tools[def.name] = tool({ + description: def.description, + inputSchema: jsonSchema(def.inputSchema), + }) + } + return tools +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index d35c4861..ee096f90 100755 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,11 +1,21 @@ +import type { Expert } from "@perstack/core" +import { type CollectedToolDefinition, SkillManager } from "@perstack/skill-manager" import pkg from "../package.json" with { type: "json" } +export type { CollectedToolDefinition } from "@perstack/skill-manager" export { getLockfileExpertToolDefinitions } from "./helpers/index.js" export { type RunOptions, run } from "./run.js" -export { - type CollectedToolDefinition, - type CollectToolDefinitionsOptions, - collectToolDefinitionsForExpert, -} from "./skill-manager/index.js" export { type RunActor, type RunSnapshot, runtimeStateMachine } from "./state-machine/index.js" export const runtimeVersion = pkg.version + +export type CollectToolDefinitionsOptions = { + env: Record + perstackBaseSkillCommand?: string[] +} + +export async function collectToolDefinitionsForExpert( + expert: Expert, + options: CollectToolDefinitionsOptions, +): Promise { + return SkillManager.collectToolDefinitions(expert, options) +} diff --git a/packages/runtime/src/orchestration/coordinator-executor.test.ts b/packages/runtime/src/orchestration/coordinator-executor.test.ts index 3eead3bd..03958bb3 100644 --- a/packages/runtime/src/orchestration/coordinator-executor.test.ts +++ b/packages/runtime/src/orchestration/coordinator-executor.test.ts @@ -47,8 +47,22 @@ vi.mock("../helpers/index.js", () => ({ })), })) -vi.mock("../skill-manager/index.js", () => ({ - getSkillManagers: vi.fn().mockResolvedValue({}), +const { mockSkillManagerInstance } = vi.hoisted(() => ({ + mockSkillManagerInstance: { + getToolDefinitions: vi.fn().mockReturnValue([]), + getAdapterByToolName: vi.fn(), + callTool: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + isClosed: false, + getAdapters: vi.fn().mockReturnValue(new Map()), + }, +})) + +vi.mock("@perstack/skill-manager", () => ({ + SkillManager: { + fromExpert: vi.fn().mockResolvedValue(mockSkillManagerInstance), + fromLockfile: vi.fn().mockResolvedValue(mockSkillManagerInstance), + }, })) vi.mock("../state-machine/index.js", () => ({ @@ -178,7 +192,7 @@ describe("@perstack/runtime: coordinator-executor", () => { }) it("passes isDelegatedRun flag to getSkillManagers", async () => { - const { getSkillManagers } = await import("../skill-manager/index.js") + const { SkillManager } = await import("@perstack/skill-manager") const executor = new CoordinatorExecutor() const setting = createMockSetting() const checkpoint = createMockCheckpoint({ @@ -193,12 +207,10 @@ describe("@perstack/runtime: coordinator-executor", () => { await executor.execute(setting, checkpoint) - expect(getSkillManagers).toHaveBeenCalledWith( - expect.anything(), + expect(SkillManager.fromExpert).toHaveBeenCalledWith( expect.anything(), expect.anything(), - undefined, - { isDelegatedRun: true }, + expect.objectContaining({ isDelegatedRun: true }), ) }) diff --git a/packages/runtime/src/orchestration/coordinator-executor.ts b/packages/runtime/src/orchestration/coordinator-executor.ts index e433fde3..77e65c7b 100644 --- a/packages/runtime/src/orchestration/coordinator-executor.ts +++ b/packages/runtime/src/orchestration/coordinator-executor.ts @@ -9,6 +9,7 @@ import { type RuntimeEvent, type Step, } from "@perstack/core" +import { SkillManager } from "@perstack/skill-manager" import pkg from "../../package.json" with { type: "json" } import { RunEventEmitter } from "../events/event-emitter.js" import { @@ -23,7 +24,6 @@ import { import { createProviderAdapter } from "../helpers/provider-adapter-factory.js" import "../helpers/register-providers.js" import { LLMExecutor } from "../llm/index.js" -import { getSkillManagers, getSkillManagersFromLockfile } from "../skill-manager/index.js" import { executeStateMachine } from "../state-machine/index.js" export type CoordinatorExecutorOptions = { @@ -65,19 +65,21 @@ export class CoordinatorExecutor { this.emitInitEvent(setting, expertToRun, experts) const lockfileExpert = this.options.lockfile?.experts[setting.expertKey] - const skillManagers = lockfileExpert - ? await getSkillManagersFromLockfile( - expertToRun, - experts, - setting, - getLockfileExpertToolDefinitions(lockfileExpert), - this.options.eventListener, - { isDelegatedRun: !!checkpoint?.delegatedBy }, - ) - : await getSkillManagers(expertToRun, experts, setting, this.options.eventListener, { + const skillManager = lockfileExpert + ? await SkillManager.fromLockfile(expertToRun, experts, { + env: setting.env, + perstackBaseSkillCommand: setting.perstackBaseSkillCommand, + isDelegatedRun: !!checkpoint?.delegatedBy, + lockfileToolDefinitions: getLockfileExpertToolDefinitions(lockfileExpert), + }) + : await SkillManager.fromExpert(expertToRun, experts, { + env: setting.env, + perstackBaseSkillCommand: setting.perstackBaseSkillCommand, isDelegatedRun: !!checkpoint?.delegatedBy, }) + this.emitSkillConnectedEvents(setting, skillManager) + const initialCheckpoint = checkpoint ? createNextStepCheckpoint(createId(), checkpoint, setting.runId) : createInitialCheckpoint(createId(), { @@ -96,7 +98,7 @@ export class CoordinatorExecutor { setting: { ...setting, experts }, initialCheckpoint, eventListener, - skillManagers, + skillManager, llmExecutor, eventEmitter, storeCheckpoint: this.options.storeCheckpoint ?? (async () => {}), @@ -118,6 +120,20 @@ export class CoordinatorExecutor { } } + private emitSkillConnectedEvents(setting: RunSetting, skillManager: SkillManager): void { + if (!this.options.eventListener) return + + for (const adapter of skillManager.getAdapters().values()) { + if (adapter.type === "delegate" || adapter.type === "interactive") continue + const event = createRuntimeEvent("skillConnected", setting.jobId, setting.runId, { + skillName: adapter.name, + spawnDurationMs: adapter.spawnDurationMs, + totalDurationMs: adapter.connectDurationMs, + }) + this.options.eventListener(event) + } + } + private emitInitEvent( setting: RunSetting, expertToRun: Expert, diff --git a/packages/runtime/src/skill-manager/base.ts b/packages/runtime/src/skill-manager/base.ts deleted file mode 100644 index 4839dbe1..00000000 --- a/packages/runtime/src/skill-manager/base.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { - Expert, - FileInlinePart, - ImageInlinePart, - InteractiveSkill, - McpSseSkill, - McpStdioSkill, - RunEvent, - RuntimeEvent, - SkillType, - TextPart, - ToolDefinition, -} from "@perstack/core" - -export abstract class BaseSkillManager { - protected _toolDefinitions: ToolDefinition[] = [] - protected _initialized = false - protected _initializing?: Promise - abstract readonly name: string - abstract readonly type: SkillType - abstract readonly lazyInit: boolean - readonly skill?: McpStdioSkill | McpSseSkill - readonly interactiveSkill?: InteractiveSkill - readonly expert?: Expert - protected _jobId: string - protected _runId: string - protected _eventListener?: (event: RunEvent | RuntimeEvent) => void - - constructor( - jobId: string, - runId: string, - eventListener?: (event: RunEvent | RuntimeEvent) => void, - ) { - this._jobId = jobId - this._runId = runId - this._eventListener = eventListener - } - - async init(): Promise { - if (this._initialized) { - throw new Error(`Skill ${this.name} is already initialized`) - } - if (this._initializing) { - throw new Error(`Skill ${this.name} is already initializing`) - } - const initPromise = this._performInit() - this._initializing = initPromise - if (!this.lazyInit) { - try { - await initPromise - } catch (error) { - this._initialized = false - this._initializing = undefined - throw error - } - } else { - // Prevent unhandled promise rejection for lazy-initialized skills. - // The error will be surfaced when getToolDefinitions() or callTool() awaits _initializing. - initPromise.catch(() => {}) - } - } - - isInitialized(): boolean { - return this._initialized - } - - protected async _performInit(): Promise { - await this._doInit() - this._initialized = true - this._initializing = undefined - } - - protected abstract _doInit(): Promise - - abstract close(): Promise - - async getToolDefinitions(): Promise { - // If initialization is in progress, wait for it to complete - if (!this.isInitialized() && this._initializing) { - try { - await this._initializing - } catch { - // Lazy-initialized skill failed to init; treat as unavailable - if (this.lazyInit) { - return [] - } - throw new Error(`Skill ${this.name} failed to initialize`) - } - } - if (!this.isInitialized()) { - throw new Error(`Skill ${this.name} is not initialized`) - } - return this._filterTools(this._toolDefinitions) - } - - protected _filterTools(tools: ToolDefinition[]): ToolDefinition[] { - return tools - } - - abstract callTool( - toolName: string, - input: Record, - ): Promise> -} diff --git a/packages/runtime/src/skill-manager/delegate.test.ts b/packages/runtime/src/skill-manager/delegate.test.ts deleted file mode 100644 index 024bdc9d..00000000 --- a/packages/runtime/src/skill-manager/delegate.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { Expert } from "@perstack/core" -import { describe, expect, it } from "vitest" -import { DelegateSkillManager } from "./delegate.js" - -function createDelegateExpert(overrides: Partial = {}): Expert { - return { - key: "delegate-expert", - name: "@test/delegate-expert", - version: "1.0.0", - description: "A delegate expert", - instruction: "Delegate expert instruction", - skills: {}, - delegates: [], - tags: [], - minRuntimeVersion: "v1.0", - ...overrides, - } -} - -const testJobId = "test-job-id" -const testRunId = "test-run-id" - -describe("@perstack/runtime: DelegateSkillManager", () => { - it("initializes delegate skill correctly", async () => { - const expert = createDelegateExpert() - const skillManager = new DelegateSkillManager(expert, testJobId, testRunId) - expect(skillManager.type).toBe("delegate") - expect(skillManager.name).toBe("@test/delegate-expert") - expect(skillManager.lazyInit).toBe(false) - await skillManager.init() - expect(skillManager.isInitialized()).toBe(true) - }) - - it("returns tool definitions for delegate skill", async () => { - const expert = createDelegateExpert() - const skillManager = new DelegateSkillManager(expert, testJobId, testRunId) - await skillManager.init() - const tools = await skillManager.getToolDefinitions() - expect(tools).toHaveLength(1) - expect(tools[0].name).toBe("delegate-expert") - expect(tools[0].skillName).toBe("@test/delegate-expert") - expect(tools[0].inputSchema).toEqual({ - type: "object", - properties: { - query: { type: "string" }, - }, - required: ["query"], - }) - }) - - it("callTool returns empty array for delegate skill", async () => { - const expert = createDelegateExpert() - const skillManager = new DelegateSkillManager(expert, testJobId, testRunId) - await skillManager.init() - const result = await skillManager.callTool("delegate-expert", { query: "test" }) - expect(result).toEqual([]) - }) - - it("extracts tool name from expert name", async () => { - const expert = createDelegateExpert({ name: "@perstack/code-reviewer" }) - const skillManager = new DelegateSkillManager(expert, testJobId, testRunId) - await skillManager.init() - const tools = await skillManager.getToolDefinitions() - expect(tools[0].name).toBe("code-reviewer") - }) - - it("includes expert description in tool definition", async () => { - const expert = createDelegateExpert({ description: "Expert for code review" }) - const skillManager = new DelegateSkillManager(expert, testJobId, testRunId) - await skillManager.init() - const tools = await skillManager.getToolDefinitions() - expect(tools[0].description).toBe("Expert for code review") - }) - - it("close resolves without error", async () => { - const expert = createDelegateExpert() - const skillManager = new DelegateSkillManager(expert, testJobId, testRunId) - await skillManager.init() - await expect(skillManager.close()).resolves.toBeUndefined() - }) -}) diff --git a/packages/runtime/src/skill-manager/helpers.test.ts b/packages/runtime/src/skill-manager/helpers.test.ts deleted file mode 100644 index 26a57af0..00000000 --- a/packages/runtime/src/skill-manager/helpers.test.ts +++ /dev/null @@ -1,625 +0,0 @@ -import type { Expert, McpSseSkill, McpStdioSkill, RunSetting, ToolDefinition } from "@perstack/core" -import { describe, expect, it, vi } from "vitest" -import type { BaseSkillManager } from "./base.js" -import { - closeSkillManagers, - collectToolDefinitionsForExpert, - getSkillManagerByToolName, - getSkillManagersFromLockfile, - getToolSet, - hasExplicitBaseVersion, - initSkillManagersWithCleanup, - isBaseSkill, - shouldUseBundledBase, -} from "./helpers.js" -import type { SkillManagerFactory } from "./skill-manager-factory.js" - -const createMcpStdioSkill = (overrides: Partial = {}): McpStdioSkill => ({ - name: "@perstack/base", - type: "mcpStdioSkill", - command: "npx", - packageName: "@perstack/base", - args: [], - pick: [], - omit: [], - requiredEnv: [], - lazyInit: false, - ...overrides, -}) - -const createMcpSseSkill = (overrides: Partial = {}): McpSseSkill => ({ - name: "other-skill", - type: "mcpSseSkill", - endpoint: "https://example.com/sse", - pick: [], - omit: [], - ...overrides, -}) - -describe("skill-manager helpers", () => { - describe("hasExplicitBaseVersion", () => { - it("returns true for packageName with version", () => { - const skill = createMcpStdioSkill({ packageName: "@perstack/base@0.0.34" }) - expect(hasExplicitBaseVersion(skill)).toBe(true) - }) - - it("returns true for args with version", () => { - const skill = createMcpStdioSkill({ - packageName: undefined, - args: ["@perstack/base@1.2.3"], - }) - expect(hasExplicitBaseVersion(skill)).toBe(true) - }) - - it("returns false for packageName without version", () => { - const skill = createMcpStdioSkill({ packageName: "@perstack/base" }) - expect(hasExplicitBaseVersion(skill)).toBe(false) - }) - - it("returns false for args without version", () => { - const skill = createMcpStdioSkill({ - packageName: undefined, - args: ["@perstack/base"], - }) - expect(hasExplicitBaseVersion(skill)).toBe(false) - }) - - it("returns false when no packageName or args", () => { - const skill = createMcpStdioSkill({ - command: "node", - packageName: undefined, - args: [], - }) - expect(hasExplicitBaseVersion(skill)).toBe(false) - }) - }) - - describe("isBaseSkill", () => { - it("returns true for skill named @perstack/base", () => { - const skill = createMcpStdioSkill({ name: "@perstack/base" }) - expect(isBaseSkill(skill)).toBe(true) - }) - - it("returns true for skill with packageName starting with @perstack/base", () => { - const skill = createMcpStdioSkill({ - name: "some-skill", - packageName: "@perstack/base@1.0.0", - }) - expect(isBaseSkill(skill)).toBe(true) - }) - - it("returns true for skill with args containing @perstack/base", () => { - const skill = createMcpStdioSkill({ - name: "some-skill", - packageName: undefined, - args: ["-y", "@perstack/base"], - }) - expect(isBaseSkill(skill)).toBe(true) - }) - - it("returns false for non-base skill", () => { - const skill = createMcpStdioSkill({ - name: "other-skill", - packageName: "@perstack/other", - }) - expect(isBaseSkill(skill)).toBe(false) - }) - - it("returns false for SSE skill without base name", () => { - const skill = createMcpSseSkill({ name: "other-skill" }) - expect(isBaseSkill(skill)).toBe(false) - }) - }) - - describe("shouldUseBundledBase", () => { - it("returns true for base skill without explicit version and no custom command", () => { - const skill = createMcpStdioSkill({ packageName: "@perstack/base" }) - expect(shouldUseBundledBase(skill)).toBe(true) - }) - - it("returns false when custom command is provided", () => { - const skill = createMcpStdioSkill({ packageName: "@perstack/base" }) - expect(shouldUseBundledBase(skill, ["node", "custom.js"])).toBe(false) - }) - - it("returns false for SSE skills", () => { - const skill = createMcpSseSkill({ name: "@perstack/base" }) - expect(shouldUseBundledBase(skill)).toBe(false) - }) - - it("returns false for skill with explicit version", () => { - const skill = createMcpStdioSkill({ packageName: "@perstack/base@0.0.34" }) - expect(shouldUseBundledBase(skill)).toBe(false) - }) - - it("returns false when perstackBaseSkillCommand is empty array", () => { - const skill = createMcpStdioSkill({ packageName: "@perstack/base" }) - // Empty array is still falsy for the check - expect(shouldUseBundledBase(skill, [])).toBe(true) - }) - }) - - describe("initSkillManagersWithCleanup", () => { - const createMockManager = (initResult: "success" | Error = "success") => { - const manager = { - name: "mock-skill", - type: "mcp" as const, - lazyInit: false, - skill: createMcpStdioSkill(), - init: vi - .fn() - .mockImplementation(() => - initResult === "success" ? Promise.resolve() : Promise.reject(initResult), - ), - close: vi.fn().mockResolvedValue(undefined), - getToolDefinitions: vi.fn().mockResolvedValue([]), - callTool: vi.fn().mockResolvedValue([]), - } - return manager as unknown as BaseSkillManager & { - init: ReturnType - close: ReturnType - } - } - - it("initializes all managers successfully", async () => { - const manager1 = createMockManager() - const manager2 = createMockManager() - const allManagers: BaseSkillManager[] = [manager1, manager2] - - await initSkillManagersWithCleanup([manager1, manager2], allManagers) - - expect(manager1.init).toHaveBeenCalled() - expect(manager2.init).toHaveBeenCalled() - }) - - it("closes all managers and throws on failure", async () => { - const error = new Error("Init failed") - const manager1 = createMockManager("success") - const manager2 = createMockManager(error) - const allManagers: BaseSkillManager[] = [manager1, manager2] - - await expect(initSkillManagersWithCleanup([manager1, manager2], allManagers)).rejects.toThrow( - "Init failed", - ) - - expect(manager1.close).toHaveBeenCalled() - expect(manager2.close).toHaveBeenCalled() - }) - - it("handles close errors gracefully", async () => { - const initError = new Error("Init failed") - const manager1 = createMockManager("success") - manager1.close.mockRejectedValue(new Error("Close failed")) - const manager2 = createMockManager(initError) - const allManagers: BaseSkillManager[] = [manager1, manager2] - - // Should not throw from close error, only from init error - await expect(initSkillManagersWithCleanup([manager1, manager2], allManagers)).rejects.toThrow( - "Init failed", - ) - }) - }) - - describe("closeSkillManagers", () => { - const createMockManager = () => { - const manager = { - name: "mock-skill", - type: "mcp" as const, - lazyInit: false, - skill: createMcpStdioSkill(), - init: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - getToolDefinitions: vi.fn().mockResolvedValue([]), - callTool: vi.fn().mockResolvedValue([]), - } - return manager as unknown as BaseSkillManager & { close: ReturnType } - } - - it("closes all skill managers", async () => { - const manager1 = createMockManager() - const manager2 = createMockManager() - const managers = { skill1: manager1, skill2: manager2 } - - await closeSkillManagers(managers) - - expect(manager1.close).toHaveBeenCalled() - expect(manager2.close).toHaveBeenCalled() - }) - - it("handles close errors gracefully", async () => { - const manager1 = createMockManager() - manager1.close.mockRejectedValue(new Error("Close failed")) - const manager2 = createMockManager() - const managers = { skill1: manager1, skill2: manager2 } - - // Should not throw - await expect(closeSkillManagers(managers)).resolves.not.toThrow() - }) - }) - - describe("getSkillManagerByToolName", () => { - const createMockManager = (name: string, tools: ToolDefinition[]): BaseSkillManager => { - const manager = { - name, - type: "mcp" as const, - lazyInit: false, - skill: createMcpStdioSkill({ name }), - init: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - getToolDefinitions: vi.fn().mockResolvedValue(tools), - callTool: vi.fn().mockResolvedValue([]), - } - return manager as unknown as BaseSkillManager - } - - it("returns manager that contains the tool", async () => { - const manager1 = createMockManager("skill1", [ - { - skillName: "skill1", - name: "tool1", - description: "Test", - inputSchema: {}, - interactive: false, - }, - ]) - const manager2 = createMockManager("skill2", [ - { - skillName: "skill2", - name: "tool2", - description: "Test", - inputSchema: {}, - interactive: false, - }, - ]) - const managers = { skill1: manager1, skill2: manager2 } - - const result = await getSkillManagerByToolName(managers, "tool2") - - expect(result).toBe(manager2) - }) - - it("throws when tool is not found", async () => { - const manager = createMockManager("skill1", [ - { - skillName: "skill1", - name: "tool1", - description: "Test", - inputSchema: {}, - interactive: false, - }, - ]) - const managers = { skill1: manager } - - await expect(getSkillManagerByToolName(managers, "nonexistent")).rejects.toThrow( - "Tool nonexistent not found", - ) - }) - }) - - describe("getToolSet", () => { - const createMockManager = (tools: ToolDefinition[]): BaseSkillManager => { - const manager = { - name: "mock-skill", - type: "mcp" as const, - lazyInit: false, - skill: createMcpStdioSkill(), - init: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - getToolDefinitions: vi.fn().mockResolvedValue(tools), - callTool: vi.fn().mockResolvedValue([]), - } - return manager as unknown as BaseSkillManager - } - - it("returns toolset from all managers", async () => { - const manager1 = createMockManager([ - { - skillName: "skill1", - name: "readFile", - description: "Read a file", - inputSchema: { type: "object", properties: { path: { type: "string" } } }, - interactive: false, - }, - ]) - const manager2 = createMockManager([ - { - skillName: "skill2", - name: "writeFile", - description: "Write a file", - inputSchema: { type: "object", properties: { path: { type: "string" } } }, - interactive: false, - }, - ]) - const managers = { skill1: manager1, skill2: manager2 } - - const toolset = await getToolSet(managers) - - expect(toolset).toHaveProperty("readFile") - expect(toolset).toHaveProperty("writeFile") - }) - - it("returns empty object for empty managers", async () => { - const managers = {} - - const toolset = await getToolSet(managers) - - expect(Object.keys(toolset)).toHaveLength(0) - }) - }) - - describe("collectToolDefinitionsForExpert", () => { - const createMockExpert = (overrides: Partial = {}): Expert => ({ - key: "test-expert", - name: "Test Expert", - version: "1.0.0", - instruction: "Test instruction", - skills: { - "@perstack/base": createMcpStdioSkill({ name: "@perstack/base" }), - }, - delegates: [], - tags: [], - minRuntimeVersion: "v1.0", - ...overrides, - }) - - const createMockFactory = (): SkillManagerFactory => { - const mockManager = { - name: "mock-skill", - type: "mcp" as const, - lazyInit: false, - skill: createMcpStdioSkill(), - init: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - getToolDefinitions: vi.fn().mockResolvedValue([ - { - skillName: "@perstack/base", - name: "readFile", - description: "Read a file", - inputSchema: { type: "object" }, - interactive: false, - }, - ]), - callTool: vi.fn().mockResolvedValue([]), - } - return { - createMcp: vi.fn().mockReturnValue(mockManager), - createInMemoryBase: vi.fn().mockReturnValue(mockManager), - createInteractive: vi.fn().mockReturnValue(mockManager), - createDelegate: vi.fn().mockReturnValue(mockManager), - } as unknown as SkillManagerFactory - } - - it("throws when base skill is not defined", async () => { - const expert = createMockExpert({ skills: {} }) - - await expect( - collectToolDefinitionsForExpert(expert, { env: {}, factory: createMockFactory() }), - ).rejects.toThrow("Base skill is not defined") - }) - - it("collects tool definitions from managers", async () => { - const expert = createMockExpert() - const factory = createMockFactory() - - const result = await collectToolDefinitionsForExpert(expert, { env: {}, factory }) - - expect(result).toHaveLength(1) - expect(result[0].name).toBe("readFile") - }) - - it("uses bundled base for base skill without version", async () => { - const expert = createMockExpert() - const factory = createMockFactory() - - await collectToolDefinitionsForExpert(expert, { env: {}, factory }) - - expect(factory.createInMemoryBase).toHaveBeenCalled() - }) - - it("handles perstackBaseSkillCommand override", async () => { - const expert = createMockExpert({ - skills: { - "@perstack/base": createMcpStdioSkill({ - name: "@perstack/base", - command: "npx", - packageName: "@perstack/base", - }), - "other-skill": createMcpStdioSkill({ name: "other-skill", packageName: "other" }), - }, - }) - const factory = createMockFactory() - - await collectToolDefinitionsForExpert(expert, { - env: {}, - factory, - perstackBaseSkillCommand: ["node", "custom.js"], - }) - - // When perstackBaseSkillCommand is set, bundled base is not used - expect(factory.createInMemoryBase).not.toHaveBeenCalled() - }) - - it("closes all managers in finally block", async () => { - const expert = createMockExpert() - const mockManager = { - name: "mock-skill", - type: "mcp" as const, - lazyInit: false, - skill: createMcpStdioSkill(), - init: vi.fn().mockRejectedValue(new Error("Init failed")), - close: vi.fn().mockResolvedValue(undefined), - getToolDefinitions: vi.fn().mockResolvedValue([]), - callTool: vi.fn().mockResolvedValue([]), - } - const factory = { - createMcp: vi.fn().mockReturnValue(mockManager), - createInMemoryBase: vi.fn().mockReturnValue(mockManager), - createInteractive: vi.fn().mockReturnValue(mockManager), - createDelegate: vi.fn().mockReturnValue(mockManager), - } as unknown as SkillManagerFactory - - await expect(collectToolDefinitionsForExpert(expert, { env: {}, factory })).rejects.toThrow() - - // close should still be called due to finally block - expect(mockManager.close).toHaveBeenCalled() - }) - }) - - describe("getSkillManagersFromLockfile", () => { - const createMockExpert = (overrides: Partial = {}): Expert => ({ - key: "test-expert", - name: "Test Expert", - version: "1.0.0", - instruction: "Test instruction", - skills: { - "@perstack/base": createMcpStdioSkill({ name: "@perstack/base" }), - }, - delegates: [], - tags: [], - minRuntimeVersion: "v1.0", - ...overrides, - }) - - const createMockRunSetting = (): RunSetting => ({ - expertKey: "test-expert", - input: { text: "test" }, - maxSteps: 10, - maxRetries: 3, - timeout: 60000, - env: {}, - jobId: "test-job", - runId: "test-run", - providerConfig: { providerName: "anthropic", apiKey: "test-key" }, - model: "claude-sonnet-4-5", - reasoningBudget: "low", - experts: {}, - startedAt: Date.now(), - updatedAt: Date.now(), - perstackApiBaseUrl: "https://api.perstack.ai", - }) - - const createMockFactory = (): SkillManagerFactory => { - const mockManager = { - name: "mock-skill", - type: "mcp" as const, - lazyInit: false, - skill: createMcpStdioSkill(), - init: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - getToolDefinitions: vi.fn().mockResolvedValue([]), - callTool: vi.fn().mockResolvedValue([]), - } - return { - createMcp: vi.fn().mockReturnValue(mockManager), - createInMemoryBase: vi.fn().mockReturnValue(mockManager), - createInteractive: vi.fn().mockReturnValue(mockManager), - createDelegate: vi.fn().mockReturnValue(mockManager), - } as unknown as SkillManagerFactory - } - - it("throws when base skill is not defined", async () => { - const expert = createMockExpert({ skills: {} }) - const setting = createMockRunSetting() - - await expect( - getSkillManagersFromLockfile(expert, {}, setting, {}, undefined, { - factory: createMockFactory(), - }), - ).rejects.toThrow("Base skill is not defined") - }) - - it("creates LockfileSkillManager for MCP skills", async () => { - const expert = createMockExpert() - const setting = createMockRunSetting() - const lockfileToolDefinitions = { - "@perstack/base": [ - { skillName: "@perstack/base", name: "readFile", inputSchema: { type: "object" } }, - ], - } - - const managers = await getSkillManagersFromLockfile( - expert, - {}, - setting, - lockfileToolDefinitions, - undefined, - { factory: createMockFactory() }, - ) - - expect(Object.keys(managers)).toContain("@perstack/base") - }) - - it("throws when delegate expert is not found", async () => { - const expert = createMockExpert({ delegates: ["nonexistent-expert"] }) - const setting = createMockRunSetting() - - await expect( - getSkillManagersFromLockfile(expert, {}, setting, {}, undefined, { - factory: createMockFactory(), - }), - ).rejects.toThrow('Delegate expert "nonexistent-expert" not found in experts') - }) - - it("creates delegate managers for delegate experts", async () => { - const expert = createMockExpert({ delegates: ["delegate-expert"] }) - const delegateExpert = createMockExpert({ key: "delegate-expert", name: "Delegate" }) - const setting = createMockRunSetting() - const factory = createMockFactory() - - await getSkillManagersFromLockfile( - expert, - { "delegate-expert": delegateExpert }, - setting, - {}, - undefined, - { factory }, - ) - - expect(factory.createDelegate).toHaveBeenCalled() - }) - - it("skips interactive skills for delegated runs", async () => { - const expert = createMockExpert({ - skills: { - "@perstack/base": createMcpStdioSkill({ name: "@perstack/base" }), - interactive: { - type: "interactiveSkill", - name: "interactive", - tools: {}, - }, - }, - }) - const setting = createMockRunSetting() - const factory = createMockFactory() - - await getSkillManagersFromLockfile(expert, {}, setting, {}, undefined, { - factory, - isDelegatedRun: true, - }) - - expect(factory.createInteractive).not.toHaveBeenCalled() - }) - - it("creates interactive skill managers for non-delegated runs", async () => { - const expert = createMockExpert({ - skills: { - "@perstack/base": createMcpStdioSkill({ name: "@perstack/base" }), - interactive: { - type: "interactiveSkill", - name: "interactive", - tools: {}, - }, - }, - }) - const setting = createMockRunSetting() - const factory = createMockFactory() - - await getSkillManagersFromLockfile(expert, {}, setting, {}, undefined, { - factory, - isDelegatedRun: false, - }) - - expect(factory.createInteractive).toHaveBeenCalled() - }) - }) -}) diff --git a/packages/runtime/src/skill-manager/helpers.ts b/packages/runtime/src/skill-manager/helpers.ts deleted file mode 100644 index fdc87aca..00000000 --- a/packages/runtime/src/skill-manager/helpers.ts +++ /dev/null @@ -1,458 +0,0 @@ -import type { - Expert, - InteractiveSkill, - McpSseSkill, - McpStdioSkill, - RunEvent, - RunSetting, - RuntimeEvent, -} from "@perstack/core" -import { jsonSchema, type ToolSet, tool } from "ai" -import type { BaseSkillManager } from "./base.js" -import { - defaultSkillManagerFactory, - type SkillManagerFactory, - type SkillManagerFactoryContext, -} from "./skill-manager-factory.js" - -/** - * Initialize skill managers and cleanup on failure. - * Exported for testing purposes. - */ -export async function initSkillManagersWithCleanup( - managers: BaseSkillManager[], - allManagers: BaseSkillManager[], -): Promise { - const results = await Promise.allSettled(managers.map((m) => m.init())) - const firstRejected = results.find((r) => r.status === "rejected") - if (firstRejected) { - await Promise.all(allManagers.map((m) => m.close().catch(() => {}))) - throw (firstRejected as PromiseRejectedResult).reason - } -} - -export interface GetSkillManagersOptions { - isDelegatedRun?: boolean - factory?: SkillManagerFactory -} - -/** - * Check if a base skill has an explicit version specified. - * Examples of versioned packages: - * - packageName: "@perstack/base@0.0.34" - * - args: ["@perstack/base@0.0.34"] - */ -export function hasExplicitBaseVersion(skill: McpStdioSkill): boolean { - // Check packageName for version: @perstack/base@1.2.3 - // The @ symbol appears twice: once at the start of the scope, once before the version - if (skill.packageName) { - const atSignIndex = skill.packageName.indexOf("@", 1) // Skip the leading @ in @perstack - if (atSignIndex > 0) { - return true - } - } - - // Check args for versioned package - if (skill.args) { - for (const arg of skill.args) { - if (arg.startsWith("@perstack/base@")) { - // Check if there's a version after @perstack/base@ - const versionStart = "@perstack/base@".length - if (arg.length > versionStart) { - return true - } - } - } - } - - return false -} - -/** - * Check if a skill is the @perstack/base skill (by name or package). - */ -export function isBaseSkill(skill: McpStdioSkill | McpSseSkill): boolean { - if (skill.name === "@perstack/base") { - return true - } - if (skill.type === "mcpStdioSkill") { - const stdioSkill = skill as McpStdioSkill - if (stdioSkill.packageName?.startsWith("@perstack/base")) { - return true - } - if (stdioSkill.args?.some((arg) => arg.startsWith("@perstack/base"))) { - return true - } - } - return false -} - -/** - * Determine if the bundled in-memory base should be used. - * Returns true if: - * - No perstackBaseSkillCommand override is set - * - The base skill doesn't have an explicit version pinned - */ -export function shouldUseBundledBase( - baseSkill: McpStdioSkill | McpSseSkill, - perstackBaseSkillCommand?: string[], -): boolean { - // If a custom command is specified, don't use bundled base - if (perstackBaseSkillCommand && perstackBaseSkillCommand.length > 0) { - return false - } - - // SSE skills can't use bundled base - if (baseSkill.type === "mcpSseSkill") { - return false - } - - // If explicit version is specified, use npx instead of bundled - if (hasExplicitBaseVersion(baseSkill)) { - return false - } - - return true -} - -export async function getSkillManagers( - expert: Expert, - experts: Record, - setting: RunSetting, - eventListener?: (event: RunEvent | RuntimeEvent) => void, - options?: GetSkillManagersOptions, -): Promise> { - const { perstackBaseSkillCommand, env, jobId, runId } = setting - const { skills } = expert - const factory = options?.factory ?? defaultSkillManagerFactory - - if (!skills["@perstack/base"]) { - throw new Error("Base skill is not defined") - } - - const factoryContext: SkillManagerFactoryContext = { - env, - jobId, - runId, - eventListener, - } - - const allManagers: BaseSkillManager[] = [] - const baseSkill = skills["@perstack/base"] - - // Determine if we should use bundled in-memory base - const useBundledBase = - (baseSkill.type === "mcpStdioSkill" || baseSkill.type === "mcpSseSkill") && - shouldUseBundledBase(baseSkill, perstackBaseSkillCommand) - - // Process base skill first - if (useBundledBase && baseSkill.type === "mcpStdioSkill") { - // Warn if requiredEnv is set (has no effect with bundled base) - if (baseSkill.requiredEnv.length > 0) { - console.warn( - `[perstack] requiredEnv is ignored for bundled @perstack/base. Pin a version to enable it.`, - ) - } - // Use InMemoryTransport for bundled base (near-zero latency) - const baseManager = factory.createInMemoryBase(baseSkill, factoryContext) - allManagers.push(baseManager) - await initSkillManagersWithCleanup([baseManager], allManagers) - } - - // Process MCP skills (excluding base if using bundled) - const mcpSkills = Object.values(skills) - .filter( - (skill): skill is McpStdioSkill | McpSseSkill => - skill.type === "mcpStdioSkill" || skill.type === "mcpSseSkill", - ) - .filter((skill) => { - // Skip base skill if we're using bundled base - if (useBundledBase && isBaseSkill(skill)) { - return false - } - return true - }) - .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, - } as McpStdioSkill - } - } - return skill - }) - - const mcpSkillManagers = mcpSkills.map((skill) => { - const manager = factory.createMcp(skill, factoryContext) - allManagers.push(manager) - return manager - }) - await initSkillManagersWithCleanup(mcpSkillManagers, allManagers) - - // Process interactive skills (not for delegated runs) - if (!options?.isDelegatedRun) { - const interactiveSkills = Object.values(skills).filter( - (skill): skill is InteractiveSkill => skill.type === "interactiveSkill", - ) - const interactiveSkillManagers = interactiveSkills.map((interactiveSkill) => { - const manager = factory.createInteractive(interactiveSkill, factoryContext) - allManagers.push(manager) - return manager - }) - await initSkillManagersWithCleanup(interactiveSkillManagers, allManagers) - } - - // Process delegate experts - const delegateSkillManagers: BaseSkillManager[] = [] - for (const delegateExpertName of expert.delegates) { - const delegate = experts[delegateExpertName] - if (!delegate) { - await Promise.all(allManagers.map((m) => m.close().catch(() => {}))) - throw new Error(`Delegate expert "${delegateExpertName}" not found in experts`) - } - const manager = factory.createDelegate(delegate, factoryContext) - allManagers.push(manager) - delegateSkillManagers.push(manager) - } - await initSkillManagersWithCleanup(delegateSkillManagers, allManagers) - - const skillManagers: Record = {} - for (const manager of allManagers) { - skillManagers[manager.name] = manager - } - return skillManagers -} - -export async function closeSkillManagers( - skillManagers: Record, -): Promise { - await Promise.all(Object.values(skillManagers).map((m) => m.close().catch(() => {}))) -} - -export async function getSkillManagerByToolName( - skillManagers: Record, - toolName: string, -): Promise { - for (const skillManager of Object.values(skillManagers)) { - const toolDefinitions = await skillManager.getToolDefinitions() - for (const toolDefinition of toolDefinitions) { - if (toolDefinition.name === toolName) { - return skillManager - } - } - } - throw new Error(`Tool ${toolName} not found`) -} - -export async function getToolSet( - skillManagers: Record, -): Promise { - const tools: ToolSet = {} - for (const skillManager of Object.values(skillManagers)) { - const toolDefinitions = await skillManager.getToolDefinitions() - for (const toolDefinition of toolDefinitions) { - tools[toolDefinition.name] = tool({ - description: toolDefinition.description, - inputSchema: jsonSchema(toolDefinition.inputSchema), - }) - } - } - return tools -} - -export interface CollectToolDefinitionsOptions { - env: Record - perstackBaseSkillCommand?: string[] - factory?: SkillManagerFactory -} - -export interface CollectedToolDefinition { - skillName: string - name: string - description?: string - inputSchema: Record -} - -export async function collectToolDefinitionsForExpert( - expert: Expert, - options: CollectToolDefinitionsOptions, -): Promise { - const { env, perstackBaseSkillCommand, factory = defaultSkillManagerFactory } = options - const { skills } = expert - if (!skills["@perstack/base"]) { - throw new Error("Base skill is not defined") - } - const factoryContext: SkillManagerFactoryContext = { - env, - jobId: "lockfile-generation", - runId: "lockfile-generation", - eventListener: undefined, - } - const allManagers: BaseSkillManager[] = [] - const baseSkill = skills["@perstack/base"] - const useBundledBase = - (baseSkill.type === "mcpStdioSkill" || baseSkill.type === "mcpSseSkill") && - shouldUseBundledBase(baseSkill, perstackBaseSkillCommand) - try { - if (useBundledBase && baseSkill.type === "mcpStdioSkill") { - const baseManager = factory.createInMemoryBase(baseSkill, factoryContext) - allManagers.push(baseManager) - await initSkillManagersWithCleanup([baseManager], allManagers) - } - const mcpSkills = Object.values(skills) - .filter( - (skill): skill is McpStdioSkill | McpSseSkill => - skill.type === "mcpStdioSkill" || skill.type === "mcpSseSkill", - ) - .filter((skill) => { - if (useBundledBase && isBaseSkill(skill)) { - return false - } - return true - }) - .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, - } as McpStdioSkill - } - } - return skill - }) - const mcpSkillManagers = mcpSkills.map((skill) => { - const manager = factory.createMcp(skill, factoryContext) - allManagers.push(manager) - return manager - }) - await initSkillManagersWithCleanup(mcpSkillManagers, allManagers) - const interactiveSkills = Object.values(skills).filter( - (skill): skill is InteractiveSkill => skill.type === "interactiveSkill", - ) - const interactiveSkillManagers = interactiveSkills.map((interactiveSkill) => { - const manager = factory.createInteractive(interactiveSkill, factoryContext) - allManagers.push(manager) - return manager - }) - await initSkillManagersWithCleanup(interactiveSkillManagers, allManagers) - const toolDefinitions: CollectedToolDefinition[] = [] - for (const manager of allManagers) { - const definitions = await manager.getToolDefinitions() - for (const def of definitions) { - toolDefinitions.push({ - skillName: def.skillName, - name: def.name, - description: def.description, - inputSchema: def.inputSchema, - }) - } - } - return toolDefinitions - } finally { - await Promise.all(allManagers.map((m) => m.close().catch(() => {}))) - } -} - -export interface GetSkillManagersFromLockfileOptions { - isDelegatedRun?: boolean - factory?: SkillManagerFactory -} - -export async function getSkillManagersFromLockfile( - expert: Expert, - experts: Record, - setting: RunSetting, - lockfileToolDefinitions: Record, - eventListener?: (event: RunEvent | RuntimeEvent) => void, - options?: GetSkillManagersFromLockfileOptions, -): Promise> { - const { LockfileSkillManager } = await import("./lockfile-skill-manager.js") - const { perstackBaseSkillCommand, env, jobId, runId } = setting - const { skills } = expert - const factory = options?.factory ?? defaultSkillManagerFactory - if (!skills["@perstack/base"]) { - throw new Error("Base skill is not defined") - } - const factoryContext: SkillManagerFactoryContext = { - env, - jobId, - runId, - eventListener, - } - const allManagers: BaseSkillManager[] = [] - const mcpSkills = Object.values(skills).filter( - (skill): skill is McpStdioSkill | McpSseSkill => - skill.type === "mcpStdioSkill" || skill.type === "mcpSseSkill", - ) - for (const skill of mcpSkills) { - const skillToolDefs = lockfileToolDefinitions[skill.name] ?? [] - const manager = new LockfileSkillManager({ - skill, - toolDefinitions: skillToolDefs, - env, - jobId, - runId, - eventListener, - perstackBaseSkillCommand, - }) - await manager.init() - allManagers.push(manager) - } - if (!options?.isDelegatedRun) { - const interactiveSkills = Object.values(skills).filter( - (skill): skill is InteractiveSkill => skill.type === "interactiveSkill", - ) - const interactiveSkillManagers = interactiveSkills.map((interactiveSkill) => { - const manager = factory.createInteractive(interactiveSkill, factoryContext) - allManagers.push(manager) - return manager - }) - await initSkillManagersWithCleanup(interactiveSkillManagers, allManagers) - } - const delegateSkillManagers: BaseSkillManager[] = [] - for (const delegateExpertName of expert.delegates) { - const delegate = experts[delegateExpertName] - if (!delegate) { - await Promise.all(allManagers.map((m) => m.close().catch(() => {}))) - throw new Error(`Delegate expert "${delegateExpertName}" not found in experts`) - } - const manager = factory.createDelegate(delegate, factoryContext) - allManagers.push(manager) - delegateSkillManagers.push(manager) - } - await initSkillManagersWithCleanup(delegateSkillManagers, allManagers) - const skillManagers: Record = {} - for (const manager of allManagers) { - skillManagers[manager.name] = manager - } - return skillManagers -} diff --git a/packages/runtime/src/skill-manager/in-memory-base.test.ts b/packages/runtime/src/skill-manager/in-memory-base.test.ts deleted file mode 100644 index b19f3559..00000000 --- a/packages/runtime/src/skill-manager/in-memory-base.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { BASE_SKILL_NAME } from "@perstack/base" -import type { McpStdioSkill } from "@perstack/core" -import { describe, expect, it, vi } from "vitest" -import { InMemoryBaseSkillManager } from "./in-memory-base.js" - -function createBaseSkill(overrides: Partial = {}): McpStdioSkill { - return { - type: "mcpStdioSkill", - name: "@perstack/base", - command: "npx", - packageName: "@perstack/base", - args: [], - requiredEnv: [], - lazyInit: false, - pick: [], - omit: [], - ...overrides, - } -} - -describe("@perstack/runtime: InMemoryBaseSkillManager", () => { - describe("constructor", () => { - it("creates manager with correct name", () => { - const manager = new InMemoryBaseSkillManager(createBaseSkill(), "job-1", "run-1") - expect(manager.name).toBe(BASE_SKILL_NAME) - }) - - it("creates manager with mcp type", () => { - const manager = new InMemoryBaseSkillManager(createBaseSkill(), "job-1", "run-1") - expect(manager.type).toBe("mcp") - }) - - it("creates manager with lazyInit false", () => { - const manager = new InMemoryBaseSkillManager(createBaseSkill(), "job-1", "run-1") - expect(manager.lazyInit).toBe(false) - }) - }) - - describe("init", () => { - it("emits skillConnected event on successful init", async () => { - const eventListener = vi.fn() - const manager = new InMemoryBaseSkillManager( - createBaseSkill(), - "job-1", - "run-1", - eventListener, - ) - - await manager.init() - - expect(eventListener).toHaveBeenCalled() - const event = eventListener.mock.calls[0][0] - expect(event.type).toBe("skillConnected") - expect(event.skillName).toBe(BASE_SKILL_NAME) - expect(event.spawnDurationMs).toBe(0) // No process spawn for in-memory - }) - - it("sets initialized state after init", async () => { - const manager = new InMemoryBaseSkillManager(createBaseSkill(), "job-1", "run-1") - - expect(manager.isInitialized()).toBe(false) - await manager.init() - expect(manager.isInitialized()).toBe(true) - }) - }) - - describe("getToolDefinitions", () => { - it("returns tool definitions after init", async () => { - const manager = new InMemoryBaseSkillManager(createBaseSkill(), "job-1", "run-1") - await manager.init() - - const tools = await manager.getToolDefinitions() - - expect(Array.isArray(tools)).toBe(true) - expect(tools.length).toBeGreaterThan(0) - // Check for expected base skill tools - const toolNames = tools.map((t) => t.name) - expect(toolNames).toContain("todo") - expect(toolNames).toContain("exec") - expect(toolNames).toContain("readTextFile") - }) - - it("all tools have skillName set to @perstack/base", async () => { - const manager = new InMemoryBaseSkillManager(createBaseSkill(), "job-1", "run-1") - await manager.init() - - const tools = await manager.getToolDefinitions() - - for (const tool of tools) { - expect(tool.skillName).toBe(BASE_SKILL_NAME) - } - }) - - it("applies pick filter to tool definitions", async () => { - const manager = new InMemoryBaseSkillManager( - createBaseSkill({ pick: ["todo", "exec"] }), - "job-1", - "run-1", - ) - await manager.init() - - const tools = await manager.getToolDefinitions() - const toolNames = tools.map((t) => t.name) - - expect(toolNames).toContain("todo") - expect(toolNames).toContain("exec") - expect(toolNames).not.toContain("readTextFile") - expect(tools.length).toBe(2) - }) - - it("applies omit filter to tool definitions", async () => { - const manager = new InMemoryBaseSkillManager( - createBaseSkill({ omit: ["todo", "exec"] }), - "job-1", - "run-1", - ) - await manager.init() - - const tools = await manager.getToolDefinitions() - const toolNames = tools.map((t) => t.name) - - expect(toolNames).not.toContain("todo") - expect(toolNames).not.toContain("exec") - expect(toolNames).toContain("readTextFile") - }) - }) - - describe("callTool", () => { - it("throws error if not initialized", async () => { - const manager = new InMemoryBaseSkillManager(createBaseSkill(), "job-1", "run-1") - - await expect(manager.callTool("todo", {})).rejects.toThrow( - "@perstack/base is not initialized", - ) - }) - - it("can call todo tool after init", async () => { - const manager = new InMemoryBaseSkillManager(createBaseSkill(), "job-1", "run-1") - await manager.init() - - const result = await manager.callTool("todo", {}) - - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBeGreaterThan(0) - }) - }) - - describe("close", () => { - it("emits skillDisconnected event on close", async () => { - const eventListener = vi.fn() - const manager = new InMemoryBaseSkillManager( - createBaseSkill(), - "job-1", - "run-1", - eventListener, - ) - await manager.init() - eventListener.mockClear() - - await manager.close() - - expect(eventListener).toHaveBeenCalled() - const event = eventListener.mock.calls[0][0] - expect(event.type).toBe("skillDisconnected") - expect(event.skillName).toBe(BASE_SKILL_NAME) - }) - - it("does not throw when closing without init", async () => { - const manager = new InMemoryBaseSkillManager(createBaseSkill(), "job-1", "run-1") - - await expect(manager.close()).resolves.toBeUndefined() - }) - }) -}) diff --git a/packages/runtime/src/skill-manager/index.ts b/packages/runtime/src/skill-manager/index.ts deleted file mode 100644 index 832b0639..00000000 --- a/packages/runtime/src/skill-manager/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -export { BaseSkillManager } from "./base.js" -export { type CommandArgs, getCommandArgs } from "./command-args.js" -export { DelegateSkillManager } from "./delegate.js" -export { - type CollectedToolDefinition, - type CollectToolDefinitionsOptions, - closeSkillManagers, - collectToolDefinitionsForExpert, - type GetSkillManagersFromLockfileOptions, - type GetSkillManagersOptions, - getSkillManagerByToolName, - getSkillManagers, - getSkillManagersFromLockfile, - getToolSet, - hasExplicitBaseVersion, - initSkillManagersWithCleanup, - isBaseSkill, - shouldUseBundledBase, -} from "./helpers.js" -export { - InMemoryBaseSkillManager, - type InMemoryBaseSkillManagerOptions, -} from "./in-memory-base.js" -export { InteractiveSkillManager } from "./interactive.js" -export { isPrivateOrLocalIP } from "./ip-validator.js" -export { - LockfileSkillManager, - type LockfileSkillManagerOptions, -} from "./lockfile-skill-manager.js" -export { McpSkillManager, type McpSkillManagerOptions } from "./mcp.js" -export { - convertPart, - convertResource, - convertToolResult, - handleToolError, -} from "./mcp-converters.js" -export { - DefaultSkillManagerFactory, - defaultSkillManagerFactory, - type SkillManagerFactory, - type SkillManagerFactoryContext, -} from "./skill-manager-factory.js" -export { - DefaultTransportFactory, - defaultTransportFactory, - type SseTransportOptions, - type StdioTransportOptions, - type TransportFactory, -} from "./transport-factory.js" diff --git a/packages/runtime/src/skill-manager/interactive.test.ts b/packages/runtime/src/skill-manager/interactive.test.ts deleted file mode 100644 index 0956e434..00000000 --- a/packages/runtime/src/skill-manager/interactive.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { InteractiveSkill } from "@perstack/core" -import { describe, expect, it } from "vitest" -import { InteractiveSkillManager } from "./interactive.js" - -function createInteractiveSkill(overrides: Partial = {}): InteractiveSkill { - return { - type: "interactiveSkill", - name: "interactive-skill", - description: "An interactive skill", - tools: { - "interactive-tool": { - name: "interactive-tool", - description: "An interactive tool", - inputJsonSchema: JSON.stringify({ - type: "object", - properties: { input: { type: "string" } }, - required: ["input"], - }), - }, - }, - ...overrides, - } -} - -const testJobId = "test-job-id" -const testRunId = "test-run-id" - -describe("@perstack/runtime: InteractiveSkillManager", () => { - it("initializes interactive skill correctly", async () => { - const interactiveSkill = createInteractiveSkill() - const skillManager = new InteractiveSkillManager(interactiveSkill, testJobId, testRunId) - expect(skillManager.type).toBe("interactive") - expect(skillManager.name).toBe("interactive-skill") - expect(skillManager.lazyInit).toBe(false) - await skillManager.init() - expect(skillManager.isInitialized()).toBe(true) - }) - - it("returns tool definitions for interactive skill", async () => { - const interactiveSkill = createInteractiveSkill() - const skillManager = new InteractiveSkillManager(interactiveSkill, testJobId, testRunId) - await skillManager.init() - const tools = await skillManager.getToolDefinitions() - expect(tools).toHaveLength(1) - expect(tools[0].name).toBe("interactive-tool") - expect(tools[0].interactive).toBe(true) - }) - - it("callTool returns empty array for interactive skill", async () => { - const interactiveSkill = createInteractiveSkill() - const skillManager = new InteractiveSkillManager(interactiveSkill, testJobId, testRunId) - await skillManager.init() - const result = await skillManager.callTool("interactive-tool", { input: "test" }) - expect(result).toEqual([]) - }) - - it("handles multiple tools", async () => { - const interactiveSkill = createInteractiveSkill({ - tools: { - tool1: { - name: "tool1", - description: "First tool", - inputJsonSchema: JSON.stringify({ type: "object" }), - }, - tool2: { - name: "tool2", - description: "Second tool", - inputJsonSchema: JSON.stringify({ type: "object" }), - }, - }, - }) - const skillManager = new InteractiveSkillManager(interactiveSkill, testJobId, testRunId) - await skillManager.init() - const tools = await skillManager.getToolDefinitions() - expect(tools).toHaveLength(2) - expect(tools.map((t) => t.name).sort()).toEqual(["tool1", "tool2"]) - }) - - it("close resolves without error", async () => { - const interactiveSkill = createInteractiveSkill() - const skillManager = new InteractiveSkillManager(interactiveSkill, testJobId, testRunId) - await skillManager.init() - await expect(skillManager.close()).resolves.toBeUndefined() - }) -}) diff --git a/packages/runtime/src/skill-manager/lockfile-skill-manager.test.ts b/packages/runtime/src/skill-manager/lockfile-skill-manager.test.ts deleted file mode 100644 index b8deba4d..00000000 --- a/packages/runtime/src/skill-manager/lockfile-skill-manager.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import type { LockfileToolDefinition, McpSseSkill, McpStdioSkill } from "@perstack/core" -import { describe, expect, it } from "vitest" -import { LockfileSkillManager } from "./lockfile-skill-manager.js" - -describe("LockfileSkillManager", () => { - const createMockSkill = (overrides: Partial = {}): McpStdioSkill => ({ - name: "@perstack/base", - type: "mcpStdioSkill", - command: "npx", - packageName: "@perstack/base", - args: [], - pick: [], - omit: [], - requiredEnv: [], - lazyInit: false, - ...overrides, - }) - - const createMockToolDefinitions = (): LockfileToolDefinition[] => [ - { - skillName: "@perstack/base", - name: "readFile", - description: "Read a file", - inputSchema: { type: "object", properties: { path: { type: "string" } } }, - }, - { - skillName: "@perstack/base", - name: "writeFile", - description: "Write a file", - inputSchema: { type: "object", properties: { path: { type: "string" } } }, - }, - ] - - it("should return cached tool definitions without initializing real manager", async () => { - const manager = new LockfileSkillManager({ - skill: createMockSkill(), - toolDefinitions: createMockToolDefinitions(), - env: {}, - jobId: "test-job", - runId: "test-run", - }) - - await manager.init() - const tools = await manager.getToolDefinitions() - - expect(tools).toHaveLength(2) - expect(tools[0].name).toBe("readFile") - expect(tools[1].name).toBe("writeFile") - }) - - it("should have lazyInit set to true", () => { - const manager = new LockfileSkillManager({ - skill: createMockSkill(), - toolDefinitions: createMockToolDefinitions(), - env: {}, - jobId: "test-job", - runId: "test-run", - }) - - expect(manager.lazyInit).toBe(true) - }) - - it("should filter tools based on pick option", async () => { - const skill = createMockSkill({ pick: ["readFile"] }) - - const manager = new LockfileSkillManager({ - skill, - toolDefinitions: createMockToolDefinitions(), - env: {}, - jobId: "test-job", - runId: "test-run", - }) - - await manager.init() - const tools = await manager.getToolDefinitions() - - expect(tools).toHaveLength(1) - expect(tools[0].name).toBe("readFile") - }) - - it("should filter tools based on omit option", async () => { - const skill = createMockSkill({ omit: ["writeFile"] }) - - const manager = new LockfileSkillManager({ - skill, - toolDefinitions: createMockToolDefinitions(), - env: {}, - jobId: "test-job", - runId: "test-run", - }) - - await manager.init() - const tools = await manager.getToolDefinitions() - - expect(tools).toHaveLength(1) - expect(tools[0].name).toBe("readFile") - }) - - it("should close without error when real manager was never initialized", async () => { - const manager = new LockfileSkillManager({ - skill: createMockSkill(), - toolDefinitions: createMockToolDefinitions(), - env: {}, - jobId: "test-job", - runId: "test-run", - }) - - await manager.init() - await expect(manager.close()).resolves.not.toThrow() - }) - - it("should expose skill property correctly", () => { - const skill = createMockSkill({ name: "custom-skill" }) - const manager = new LockfileSkillManager({ - skill, - toolDefinitions: createMockToolDefinitions(), - env: {}, - jobId: "test-job", - runId: "test-run", - }) - - expect(manager.skill).toBe(skill) - expect(manager.name).toBe("custom-skill") - expect(manager.type).toBe("mcp") - }) - - it("should set interactive to false for all tool definitions", async () => { - const manager = new LockfileSkillManager({ - skill: createMockSkill(), - toolDefinitions: createMockToolDefinitions(), - env: {}, - jobId: "test-job", - runId: "test-run", - }) - - await manager.init() - const tools = await manager.getToolDefinitions() - - for (const tool of tools) { - expect(tool.interactive).toBe(false) - } - }) - - it("should handle empty tool definitions", async () => { - const manager = new LockfileSkillManager({ - skill: createMockSkill(), - toolDefinitions: [], - env: {}, - jobId: "test-job", - runId: "test-run", - }) - - await manager.init() - const tools = await manager.getToolDefinitions() - - expect(tools).toHaveLength(0) - }) - - describe("_ensureRealManager", () => { - it("creates InMemoryBaseSkillManager for base skill without version", async () => { - const skill = createMockSkill({ - name: "@perstack/base", - packageName: "@perstack/base", - }) - const manager = new LockfileSkillManager({ - skill, - toolDefinitions: createMockToolDefinitions(), - env: {}, - jobId: "test-job", - runId: "test-run", - }) - - await manager.init() - - // Access private method via type assertion for testing - const managerAny = manager as unknown as { - _ensureRealManager: () => Promise - _realManager?: { close: () => Promise } - } - - // This will trigger real manager creation - try { - await managerAny._ensureRealManager() - // Real manager should be created - expect(managerAny._realManager).toBeDefined() - } catch { - // Expected if InMemoryBaseSkillManager init fails in test env - } finally { - // Cleanup - if (managerAny._realManager) { - await managerAny._realManager.close().catch(() => {}) - } - } - }) - }) - - describe("with SSE skill", () => { - const createMockSseSkill = (overrides: Partial = {}): McpSseSkill => ({ - name: "sse-skill", - type: "mcpSseSkill", - endpoint: "https://example.com/sse", - pick: [], - omit: [], - ...overrides, - }) - - it("should handle SSE skill type", async () => { - const manager = new LockfileSkillManager({ - skill: createMockSseSkill(), - toolDefinitions: [ - { - skillName: "sse-skill", - name: "sseTool", - description: "An SSE tool", - inputSchema: { type: "object" }, - }, - ], - env: {}, - jobId: "test-job", - runId: "test-run", - }) - - await manager.init() - const tools = await manager.getToolDefinitions() - - expect(tools).toHaveLength(1) - expect(tools[0].name).toBe("sseTool") - }) - }) -}) diff --git a/packages/runtime/src/skill-manager/mcp.test.ts b/packages/runtime/src/skill-manager/mcp.test.ts deleted file mode 100644 index 31dd6077..00000000 --- a/packages/runtime/src/skill-manager/mcp.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import type { RunEvent, RuntimeEvent, ToolDefinition } from "@perstack/core" -import { describe, expect, it, vi } from "vitest" - -// Mock the MCP SDK -vi.mock("@modelcontextprotocol/sdk/client/index.js", () => { - const mockClient = vi.fn() - mockClient.prototype.listTools = vi.fn().mockResolvedValue({ tools: [] }) - mockClient.prototype.connect = vi.fn().mockResolvedValue(undefined) - mockClient.prototype.close = vi.fn().mockResolvedValue(undefined) - return { Client: mockClient } -}) - -import { McpSkillManager } from "./mcp.js" - -type McpSkillManagerInternal = { - _doInit: () => Promise - _toolDefinitions: ToolDefinition[] -} - -function createMcpSkill(overrides: Record = {}) { - return { - type: "mcpStdioSkill" as const, - name: "test-skill", - description: "", - command: "npx", - args: ["@example/pkg"], - requiredEnv: [], - pick: [], - omit: [], - lazyInit: true, - ...overrides, - } -} - -const testJobId = "test-job-id" -const testRunId = "test-run-id" - -describe("@perstack/runtime: McpSkillManager", () => { - it("starts init without awaiting when lazyInit true", async () => { - const skill = createMcpSkill() - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId) - const sm = skillManager as unknown as McpSkillManagerInternal - const initSpy = vi - .spyOn(sm, "_doInit") - .mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 10))) - await skillManager.init() - expect(initSpy).toHaveBeenCalledTimes(1) - expect(skillManager.isInitialized()).toBe(false) - }) - - it("waits for init completion when lazyInit false", async () => { - const skill = createMcpSkill({ lazyInit: false }) - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId) - const sm = skillManager as unknown as McpSkillManagerInternal - const initSpy = vi - .spyOn(sm, "_doInit") - .mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 10))) - await skillManager.init() - expect(initSpy).toHaveBeenCalledTimes(1) - expect(skillManager.isInitialized()).toBe(true) - }) - - it("awaits initialization when getToolDefinitions called while lazyInit in progress", async () => { - const skill = createMcpSkill() - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId) - const sm = skillManager as unknown as McpSkillManagerInternal - vi.spyOn(sm, "_doInit").mockImplementation( - () => - new Promise((resolve) => { - setTimeout(() => { - sm._toolDefinitions = [ - { - name: "lazy-tool", - skillName: "test-skill", - inputSchema: {}, - interactive: false, - }, - ] - resolve(undefined) - }, 10) - }), - ) - await skillManager.init() - expect(skillManager.isInitialized()).toBe(false) - // getToolDefinitions should wait for initialization to complete - const tools = await skillManager.getToolDefinitions() - expect(skillManager.isInitialized()).toBe(true) - expect(tools).toHaveLength(1) - expect(tools[0].name).toBe("lazy-tool") - }) - - it("returns array from getToolDefinitions when lazyInit false", async () => { - const skill = createMcpSkill({ lazyInit: false }) - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId) - const sm = skillManager as unknown as McpSkillManagerInternal - vi.spyOn(sm, "_doInit").mockImplementation( - () => - new Promise((resolve) => { - setTimeout(() => { - sm._toolDefinitions = [ - { - name: "test-tool", - skillName: "test-skill", - inputSchema: { - type: "object", - properties: { test: { type: "string" } }, - required: ["test"], - }, - interactive: false, - }, - ] - resolve(undefined) - }, 10) - }), - ) - await skillManager.init() - expect(skillManager.isInitialized()).toBe(true) - const tools = await skillManager.getToolDefinitions() - expect(tools).toHaveLength(1) - expect(tools[0].name).toBe("test-tool") - }) - - it("throws error from getToolDefinitions when init not called", async () => { - const skill = createMcpSkill({ lazyInit: false, name: "test-eager" }) - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId) - await expect(skillManager.getToolDefinitions()).rejects.toThrow("not initialized") - }) - - it("filters tools with pick option", async () => { - const skill = createMcpSkill({ lazyInit: false, pick: ["allowed-tool"] }) - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId) - const sm = skillManager as unknown as McpSkillManagerInternal - vi.spyOn(sm, "_doInit").mockImplementation( - () => - new Promise((resolve) => { - sm._toolDefinitions = [ - { - name: "allowed-tool", - skillName: "test-skill", - inputSchema: {}, - interactive: false, - }, - { - name: "blocked-tool", - skillName: "test-skill", - inputSchema: {}, - interactive: false, - }, - ] - resolve(undefined) - }), - ) - await skillManager.init() - const tools = await skillManager.getToolDefinitions() - expect(tools).toHaveLength(1) - expect(tools[0].name).toBe("allowed-tool") - }) - - it("filters tools with omit option", async () => { - const skill = createMcpSkill({ lazyInit: false, omit: ["blocked-tool"] }) - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId) - const sm = skillManager as unknown as McpSkillManagerInternal - vi.spyOn(sm, "_doInit").mockImplementation( - () => - new Promise((resolve) => { - sm._toolDefinitions = [ - { - name: "allowed-tool", - skillName: "test-skill", - inputSchema: {}, - interactive: false, - }, - { - name: "blocked-tool", - skillName: "test-skill", - inputSchema: {}, - interactive: false, - }, - ] - resolve(undefined) - }), - ) - await skillManager.init() - const tools = await skillManager.getToolDefinitions() - expect(tools).toHaveLength(1) - expect(tools[0].name).toBe("allowed-tool") - }) - - it("throws error when init called twice", async () => { - const skill = createMcpSkill({ lazyInit: false }) - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId) - const sm = skillManager as unknown as McpSkillManagerInternal - vi.spyOn(sm, "_doInit").mockResolvedValue(undefined) - await skillManager.init() - await expect(skillManager.init()).rejects.toThrow("already initialized") - }) - - it("sets lazyInit false for @perstack/base skill", () => { - const skill = createMcpSkill({ name: "@perstack/base", lazyInit: true }) - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId) - expect(skillManager.lazyInit).toBe(false) - }) - - it("resets state when init fails with lazyInit false", async () => { - const skill = createMcpSkill({ lazyInit: false }) - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId) - const sm = skillManager as unknown as McpSkillManagerInternal - vi.spyOn(sm, "_doInit").mockRejectedValue(new Error("Init failed")) - await expect(skillManager.init()).rejects.toThrow("Init failed") - expect(skillManager.isInitialized()).toBe(false) - }) - - it("throws error when init called while already initializing", async () => { - const skill = createMcpSkill({ lazyInit: false }) - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId) - const sm = skillManager as unknown as McpSkillManagerInternal - vi.spyOn(sm, "_doInit").mockImplementation( - () => new Promise((resolve) => setTimeout(resolve, 100)), - ) - const initPromise = skillManager.init() - await expect(skillManager.init()).rejects.toThrow("already initializing") - await initPromise - }) - - it("initializes mcp sse skill with lazyInit false", () => { - const skill = { - type: "mcpSseSkill" as const, - name: "sse-skill", - endpoint: "https://example.com/sse", - pick: [], - omit: [], - } - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId) - expect(skillManager.type).toBe("mcp") - expect(skillManager.name).toBe("sse-skill") - expect(skillManager.lazyInit).toBe(false) - }) - - it("accepts event listener in constructor", () => { - const skill = createMcpSkill({ lazyInit: false }) - const events: (RunEvent | RuntimeEvent)[] = [] - const eventListener = (event: RunEvent | RuntimeEvent) => { - events.push(event) - } - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId, eventListener) - expect(skillManager).toBeDefined() - expect(skillManager.name).toBe("test-skill") - }) - - it("emits skillConnected event with timing metrics when _doInit completes", async () => { - const skill = createMcpSkill({ lazyInit: false }) - const events: RuntimeEvent[] = [] - const eventListener = (event: RunEvent | RuntimeEvent) => { - if ("type" in event && event.type === "skillConnected") { - events.push(event as RuntimeEvent) - } - } - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId, eventListener) - const sm = skillManager as unknown as McpSkillManagerInternal & { - _initStdio: () => Promise<{ - startTime: number - spawnDurationMs: number - handshakeDurationMs: number - serverInfo?: { name: string; version: string } - }> - } - - // Mock _initStdio to return timing info (this allows _doInit to run its real logic) - vi.spyOn(sm, "_initStdio").mockResolvedValue({ - startTime: Date.now() - 1000, - spawnDurationMs: 10, - handshakeDurationMs: 500, - serverInfo: { name: "test-server", version: "1.0.0" }, - }) - - await skillManager.init() - - expect(events).toHaveLength(1) - const event = events[0] - expect(event.type).toBe("skillConnected") - if (event.type === "skillConnected") { - expect(event.skillName).toBe("test-skill") - expect(event.spawnDurationMs).toBe(10) - expect(event.handshakeDurationMs).toBe(500) - expect(event.connectDurationMs).toBe(510) - expect(event.serverInfo).toEqual({ name: "test-server", version: "1.0.0" }) - expect(typeof event.toolDiscoveryDurationMs).toBe("number") - expect(typeof event.totalDurationMs).toBe("number") - } - }) - - it("does not emit skillConnected with timing for SSE skills (no timingInfo)", async () => { - const skill = { - type: "mcpSseSkill" as const, - name: "sse-skill", - endpoint: "https://example.com/sse", - pick: [], - omit: [], - } - const events: RuntimeEvent[] = [] - const eventListener = (event: RunEvent | RuntimeEvent) => { - if ("type" in event && event.type === "skillConnected") { - events.push(event as RuntimeEvent) - } - } - const skillManager = new McpSkillManager(skill, {}, testJobId, testRunId, eventListener) - const sm = skillManager as unknown as McpSkillManagerInternal & { - _initSse: () => Promise - } - - // Mock _initSse (SSE skills don't return timing info) - vi.spyOn(sm, "_initSse").mockResolvedValue(undefined) - - await skillManager.init() - - // SSE skills don't emit skillConnected with timing because timingInfo is undefined - expect(events).toHaveLength(0) - }) -}) diff --git a/packages/runtime/src/skill-manager/skill-manager-factory.test.ts b/packages/runtime/src/skill-manager/skill-manager-factory.test.ts deleted file mode 100644 index 7aabc5a2..00000000 --- a/packages/runtime/src/skill-manager/skill-manager-factory.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { Expert, InteractiveSkill, McpStdioSkill } from "@perstack/core" -import { describe, expect, it, vi } from "vitest" -import { DelegateSkillManager } from "./delegate.js" -import { InteractiveSkillManager } from "./interactive.js" -import { McpSkillManager } from "./mcp.js" -import { DefaultSkillManagerFactory, defaultSkillManagerFactory } from "./skill-manager-factory.js" - -function createMcpSkill(overrides: Partial = {}): McpStdioSkill { - return { - type: "mcpStdioSkill", - name: "test-mcp-skill", - command: "npx", - args: ["@example/pkg"], - requiredEnv: [], - pick: [], - omit: [], - lazyInit: true, - ...overrides, - } as McpStdioSkill -} - -function createInteractiveSkill(overrides: Partial = {}): InteractiveSkill { - return { - type: "interactiveSkill", - name: "test-interactive-skill", - description: "Test interactive skill", - tools: { - "test-tool": { - name: "test-tool", - description: "Test tool", - inputJsonSchema: JSON.stringify({ type: "object" }), - }, - }, - ...overrides, - } -} - -function createExpert(overrides: Partial = {}): Expert { - return { - key: "test-expert", - name: "@test/expert", - version: "1.0.0", - description: "Test expert", - instruction: "Test instruction", - skills: {}, - delegates: [], - tags: [], - minRuntimeVersion: "v1.0", - ...overrides, - } -} - -const factoryContext = { - env: {}, - jobId: "test-job-id", - runId: "test-run-id", -} - -describe("@perstack/runtime: DefaultSkillManagerFactory", () => { - describe("createMcp", () => { - it("creates McpSkillManager", () => { - const factory = new DefaultSkillManagerFactory() - const skill = createMcpSkill() - - const manager = factory.createMcp(skill, factoryContext) - - expect(manager).toBeInstanceOf(McpSkillManager) - expect(manager.name).toBe("test-mcp-skill") - }) - - it("passes environment variables to manager", () => { - const factory = new DefaultSkillManagerFactory() - const skill = createMcpSkill() - const context = { ...factoryContext, env: { API_KEY: "secret" } } - - const manager = factory.createMcp(skill, context) - - expect(manager).toBeInstanceOf(McpSkillManager) - }) - - it("passes event listener to manager", () => { - const factory = new DefaultSkillManagerFactory() - const skill = createMcpSkill() - const eventListener = vi.fn() - const context = { ...factoryContext, eventListener } - - const manager = factory.createMcp(skill, context) - - expect(manager).toBeInstanceOf(McpSkillManager) - }) - }) - - describe("createInteractive", () => { - it("creates InteractiveSkillManager", () => { - const factory = new DefaultSkillManagerFactory() - const skill = createInteractiveSkill() - - const manager = factory.createInteractive(skill, factoryContext) - - expect(manager).toBeInstanceOf(InteractiveSkillManager) - expect(manager.name).toBe("test-interactive-skill") - }) - - it("passes job and run IDs correctly", () => { - const factory = new DefaultSkillManagerFactory() - const skill = createInteractiveSkill() - const context = { ...factoryContext, jobId: "custom-job", runId: "custom-run" } - - const manager = factory.createInteractive(skill, context) - - expect(manager).toBeInstanceOf(InteractiveSkillManager) - }) - }) - - describe("createDelegate", () => { - it("creates DelegateSkillManager", () => { - const factory = new DefaultSkillManagerFactory() - const expert = createExpert() - - const manager = factory.createDelegate(expert, factoryContext) - - expect(manager).toBeInstanceOf(DelegateSkillManager) - expect(manager.name).toBe("@test/expert") - }) - - it("uses expert name as manager name", () => { - const factory = new DefaultSkillManagerFactory() - const expert = createExpert({ name: "@custom/delegate-expert" }) - - const manager = factory.createDelegate(expert, factoryContext) - - expect(manager.name).toBe("@custom/delegate-expert") - }) - }) -}) - -describe("@perstack/runtime: defaultSkillManagerFactory", () => { - it("is an instance of DefaultSkillManagerFactory", () => { - expect(defaultSkillManagerFactory).toBeInstanceOf(DefaultSkillManagerFactory) - }) - - it("has createMcp method", () => { - expect(typeof defaultSkillManagerFactory.createMcp).toBe("function") - }) - - it("has createInteractive method", () => { - expect(typeof defaultSkillManagerFactory.createInteractive).toBe("function") - }) - - it("has createDelegate method", () => { - expect(typeof defaultSkillManagerFactory.createDelegate).toBe("function") - }) -}) diff --git a/packages/runtime/src/skill-manager/skill-manager-factory.ts b/packages/runtime/src/skill-manager/skill-manager-factory.ts deleted file mode 100644 index fce4819d..00000000 --- a/packages/runtime/src/skill-manager/skill-manager-factory.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { - Expert, - InteractiveSkill, - McpSseSkill, - McpStdioSkill, - RunEvent, - RuntimeEvent, -} from "@perstack/core" -import type { BaseSkillManager } from "./base.js" -import { DelegateSkillManager } from "./delegate.js" -import { InMemoryBaseSkillManager } from "./in-memory-base.js" -import { InteractiveSkillManager } from "./interactive.js" -import { McpSkillManager, type McpSkillManagerOptions } from "./mcp.js" - -export interface SkillManagerFactoryContext { - env: Record - jobId: string - runId: string - eventListener?: (event: RunEvent | RuntimeEvent) => void - mcpOptions?: McpSkillManagerOptions -} - -/** - * Factory interface for creating skill managers. - * Allows for dependency injection and easier testing. - */ -export interface SkillManagerFactory { - createMcp( - skill: McpStdioSkill | McpSseSkill, - context: SkillManagerFactoryContext, - ): BaseSkillManager - /** - * Create an in-memory base skill manager using InMemoryTransport. - * This provides near-zero initialization latency for the bundled base skill. - */ - createInMemoryBase(skill: McpStdioSkill, context: SkillManagerFactoryContext): BaseSkillManager - createInteractive(skill: InteractiveSkill, context: SkillManagerFactoryContext): BaseSkillManager - createDelegate(expert: Expert, context: SkillManagerFactoryContext): BaseSkillManager -} - -/** - * Default implementation of SkillManagerFactory using real skill manager classes. - */ -export class DefaultSkillManagerFactory implements SkillManagerFactory { - createMcp( - skill: McpStdioSkill | McpSseSkill, - context: SkillManagerFactoryContext, - ): BaseSkillManager { - return new McpSkillManager( - skill, - context.env, - context.jobId, - context.runId, - context.eventListener, - context.mcpOptions, - ) - } - - createInMemoryBase(skill: McpStdioSkill, context: SkillManagerFactoryContext): BaseSkillManager { - return new InMemoryBaseSkillManager(skill, context.jobId, context.runId, context.eventListener) - } - - createInteractive( - skill: InteractiveSkill, - context: SkillManagerFactoryContext, - ): BaseSkillManager { - return new InteractiveSkillManager(skill, context.jobId, context.runId, context.eventListener) - } - - createDelegate(expert: Expert, context: SkillManagerFactoryContext): BaseSkillManager { - return new DelegateSkillManager(expert, context.jobId, context.runId, context.eventListener) - } -} - -/** - * Default skill manager factory instance. - */ -export const defaultSkillManagerFactory = new DefaultSkillManagerFactory() diff --git a/packages/runtime/src/skill-manager/transport-factory.test.ts b/packages/runtime/src/skill-manager/transport-factory.test.ts deleted file mode 100644 index 569c3b85..00000000 --- a/packages/runtime/src/skill-manager/transport-factory.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vitest" -import { DefaultTransportFactory, defaultTransportFactory } from "./transport-factory.js" - -describe("@perstack/runtime: DefaultTransportFactory", () => { - describe("createStdio", () => { - it("creates StdioClientTransport with correct options", () => { - const factory = new DefaultTransportFactory() - const options = { - command: "npx", - args: ["-y", "@example/pkg"], - env: { PATH: "/usr/bin" }, - stderr: "pipe" as const, - } - - const transport = factory.createStdio(options) - - expect(transport).toBeDefined() - // StdioClientTransport stores options internally - expect(typeof transport.start).toBe("function") - expect(typeof transport.close).toBe("function") - }) - - it("returns transport with start and close methods", () => { - const factory = new DefaultTransportFactory() - const transport = factory.createStdio({ - command: "node", - args: ["script.js"], - env: {}, - }) - - expect(transport).toHaveProperty("start") - expect(transport).toHaveProperty("close") - }) - }) - - describe("createSse", () => { - it("creates SSEClientTransport", () => { - const factory = new DefaultTransportFactory() - const url = new URL("https://api.example.com/sse") - - const transport = factory.createSse({ url }) - - expect(transport).toBeDefined() - expect(typeof transport.start).toBe("function") - expect(typeof transport.close).toBe("function") - }) - }) - - describe("createInMemoryPair", () => { - it("creates a linked pair of transports", () => { - const factory = new DefaultTransportFactory() - - const [clientTransport, serverTransport] = factory.createInMemoryPair() - - expect(clientTransport).toBeDefined() - expect(serverTransport).toBeDefined() - expect(typeof clientTransport.start).toBe("function") - expect(typeof clientTransport.close).toBe("function") - expect(typeof serverTransport.start).toBe("function") - expect(typeof serverTransport.close).toBe("function") - }) - - it("returns two distinct transport instances", () => { - const factory = new DefaultTransportFactory() - - const [clientTransport, serverTransport] = factory.createInMemoryPair() - - expect(clientTransport).not.toBe(serverTransport) - }) - }) -}) - -describe("@perstack/runtime: defaultTransportFactory", () => { - it("is an instance of DefaultTransportFactory", () => { - expect(defaultTransportFactory).toBeInstanceOf(DefaultTransportFactory) - }) - - it("has createStdio method", () => { - expect(typeof defaultTransportFactory.createStdio).toBe("function") - }) - - it("has createSse method", () => { - expect(typeof defaultTransportFactory.createSse).toBe("function") - }) -}) diff --git a/packages/runtime/src/state-machine/actor-factory.test.ts b/packages/runtime/src/state-machine/actor-factory.test.ts index 646fe2d2..132b7bef 100644 --- a/packages/runtime/src/state-machine/actor-factory.test.ts +++ b/packages/runtime/src/state-machine/actor-factory.test.ts @@ -1,4 +1,5 @@ import type { Checkpoint, RunSetting } from "@perstack/core" +import type { SkillManager } from "@perstack/skill-manager" import { describe, expect, it } from "vitest" import type { LLMExecutor } from "../llm/index.js" import { createMockLLMExecutor } from "../llm/index.js" @@ -39,7 +40,7 @@ describe("@perstack/runtime: DefaultActorFactory", () => { }, } as unknown as Checkpoint, eventListener: async () => {}, - skillManagers: {}, + skillManager: {} as unknown as SkillManager, llmExecutor: createMockLLMExecutor() as unknown as LLMExecutor, }, }) diff --git a/packages/runtime/src/state-machine/coordinator.test.ts b/packages/runtime/src/state-machine/coordinator.test.ts index 83f1cf40..123a3a69 100644 --- a/packages/runtime/src/state-machine/coordinator.test.ts +++ b/packages/runtime/src/state-machine/coordinator.test.ts @@ -32,7 +32,7 @@ function createMockParams(overrides: Partial = {}): StateMac }, }, eventListener: vi.fn().mockResolvedValue(undefined), - skillManagers: {}, + skillManager: {} as never, eventEmitter: { emit: vi.fn().mockResolvedValue(undefined), }, @@ -72,7 +72,7 @@ function createMockActor(subscribeCallback?: (state: RunSnapshot) => void) { }, }, eventListener: vi.fn(), - skillManagers: {}, + skillManager: {} as never, }, } as unknown as RunSnapshot) } @@ -104,7 +104,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { const coordinator = new StateMachineCoordinator(params, { actorFactory: mockFactory, - closeSkillManagers: mockCloseManagers, + closeSkillManager: mockCloseManagers, logics: mockLogics, }) @@ -121,7 +121,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { const coordinator = new StateMachineCoordinator(params, { actorFactory: mockFactory, - closeSkillManagers: mockCloseManagers, + closeSkillManager: mockCloseManagers, }) await coordinator.execute() @@ -132,7 +132,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { setting: params.setting, initialCheckpoint: params.initialCheckpoint, eventListener: params.eventListener, - skillManagers: params.skillManagers, + skillManager: params.skillManager, }, }) }) @@ -145,7 +145,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { const coordinator = new StateMachineCoordinator(params, { actorFactory: mockFactory, - closeSkillManagers: mockCloseManagers, + closeSkillManager: mockCloseManagers, }) await coordinator.execute() @@ -161,7 +161,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { const coordinator = new StateMachineCoordinator(params, { actorFactory: mockFactory, - closeSkillManagers: mockCloseManagers, + closeSkillManager: mockCloseManagers, }) await coordinator.execute() @@ -177,7 +177,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { const coordinator = new StateMachineCoordinator(params, { actorFactory: mockFactory, - closeSkillManagers: mockCloseManagers, + closeSkillManager: mockCloseManagers, }) const result = await coordinator.execute() @@ -194,7 +194,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { const coordinator = new StateMachineCoordinator(params, { actorFactory: mockFactory, - closeSkillManagers: mockCloseManagers, + closeSkillManager: mockCloseManagers, }) await coordinator.execute() @@ -219,7 +219,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { const coordinator = new StateMachineCoordinator(params, { actorFactory: mockFactory, - closeSkillManagers: mockCloseManagers, + closeSkillManager: mockCloseManagers, }) const stoppedState = { @@ -237,7 +237,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { cachedInputTokens: 0, }, }, - skillManagers: {}, + skillManager: {} as never, }, } as unknown as RunSnapshot @@ -273,14 +273,14 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { step: {}, checkpoint: params.initialCheckpoint, eventListener: params.eventListener, - skillManagers: {}, + skillManager: {} as never, }, }, { value: "Stopped", context: { checkpoint: { status: "completed", stepNumber: 1, messages: [], usage: {} }, - skillManagers: {}, + skillManager: {} as never, }, }, ] @@ -308,7 +308,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { const coordinator = new StateMachineCoordinator(params, { actorFactory: mockFactory, - closeSkillManagers: mockCloseManagers, + closeSkillManager: mockCloseManagers, logics: mockLogics, }) @@ -342,10 +342,13 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { let stateIndex = 0 const states = [ - { value: "Init", context: { checkpoint: params.initialCheckpoint, skillManagers: {} } }, + { + value: "Init", + context: { checkpoint: params.initialCheckpoint, skillManager: {} as never }, + }, { value: "Stopped", - context: { checkpoint: { status: "completed" }, skillManagers: {} }, + context: { checkpoint: { status: "completed" }, skillManager: {} as never }, }, ] @@ -371,7 +374,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { const coordinator = new StateMachineCoordinator(params, { actorFactory: mockFactory, - closeSkillManagers: mockCloseManagers, + closeSkillManager: mockCloseManagers, logics: mockLogics, }) @@ -404,7 +407,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { setting: params.setting, step: {}, checkpoint: params.initialCheckpoint, - skillManagers: {}, + skillManager: {} as never, }, } as unknown as RunSnapshot) } @@ -416,7 +419,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { const coordinator = new StateMachineCoordinator(params, { actorFactory: mockFactory, - closeSkillManagers: mockCloseManagers, + closeSkillManager: mockCloseManagers, logics: mockLogics, }) @@ -450,7 +453,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { for (const sub of subscribers) { sub({ value: "Init", - context: { checkpoint: params.initialCheckpoint, skillManagers: {} }, + context: { checkpoint: params.initialCheckpoint, skillManager: {} as never }, } as unknown as RunSnapshot) } }), @@ -461,7 +464,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { const coordinator = new StateMachineCoordinator(params, { actorFactory: mockFactory, - closeSkillManagers: mockCloseManagers, + closeSkillManager: mockCloseManagers, logics: mockLogics, }) @@ -483,7 +486,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { for (const sub of subscribers) { sub({ value: "Stopped", - context: { checkpoint: undefined, skillManagers: {} }, + context: { checkpoint: undefined, skillManager: {} as never }, } as unknown as RunSnapshot) } }), @@ -494,7 +497,7 @@ describe("@perstack/runtime: StateMachineCoordinator", () => { const coordinator = new StateMachineCoordinator(params, { actorFactory: mockFactory, - closeSkillManagers: mockCloseManagers, + closeSkillManager: mockCloseManagers, }) await expect(coordinator.execute()).rejects.toThrow("Checkpoint is undefined") diff --git a/packages/runtime/src/state-machine/coordinator.ts b/packages/runtime/src/state-machine/coordinator.ts index 7eb03248..aabf36af 100644 --- a/packages/runtime/src/state-machine/coordinator.ts +++ b/packages/runtime/src/state-machine/coordinator.ts @@ -1,7 +1,7 @@ import type { Checkpoint, Expert, RunEvent, RunSetting, RuntimeEvent, Step } from "@perstack/core" +import type { SkillManager } from "@perstack/skill-manager" import type { RunEventEmitter } from "../events/event-emitter.js" import type { LLMExecutor } from "../llm/index.js" -import { type BaseSkillManager, closeSkillManagers } from "../skill-manager/index.js" import { type ActorFactory, defaultActorFactory } from "./actor-factory.js" import { type RunActor, type RunSnapshot, StateMachineLogics } from "./machine.js" @@ -9,7 +9,7 @@ export type StateMachineParams = { setting: RunSetting & { experts: Record } initialCheckpoint: Checkpoint eventListener: (event: RunEvent | RuntimeEvent) => Promise - skillManagers: Record + skillManager: SkillManager llmExecutor: LLMExecutor eventEmitter: RunEventEmitter storeCheckpoint: (checkpoint: Checkpoint) => Promise @@ -20,7 +20,7 @@ export type StateMachineLogicsType = typeof StateMachineLogics export interface CoordinatorDependencies { actorFactory?: ActorFactory - closeSkillManagers?: (managers: Record) => Promise + closeSkillManager?: (manager: SkillManager) => Promise logics?: StateMachineLogicsType } @@ -30,7 +30,7 @@ export interface CoordinatorDependencies { */ export class StateMachineCoordinator { private readonly actorFactory: ActorFactory - private readonly closeManagers: (managers: Record) => Promise + private readonly closeManagers: (manager: SkillManager) => Promise private readonly logics: StateMachineLogicsType private actor: RunActor | null = null @@ -42,7 +42,7 @@ export class StateMachineCoordinator { deps: CoordinatorDependencies = {}, ) { this.actorFactory = deps.actorFactory ?? defaultActorFactory - this.closeManagers = deps.closeSkillManagers ?? closeSkillManagers + this.closeManagers = deps.closeSkillManager ?? ((m) => m.close()) this.logics = deps.logics ?? StateMachineLogics } @@ -50,14 +50,14 @@ export class StateMachineCoordinator { * Execute the state machine and return the final checkpoint. */ async execute(): Promise { - const { setting, initialCheckpoint, eventListener, skillManagers, llmExecutor } = this.params + const { setting, initialCheckpoint, eventListener, skillManager, llmExecutor } = this.params this.actor = this.actorFactory.create({ input: { setting, initialCheckpoint, eventListener, - skillManagers, + skillManager, llmExecutor, }, }) @@ -92,13 +92,13 @@ export class StateMachineCoordinator { * Handle the stopped state - cleanup and resolve. */ private async handleStoppedState(runState: RunSnapshot): Promise { - const { checkpoint, skillManagers } = runState.context + const { checkpoint, skillManager } = runState.context if (!checkpoint) { throw new Error("Checkpoint is undefined") } - await this.closeManagers(skillManagers) + await this.closeManagers(skillManager) this.resolvePromise?.(checkpoint) } @@ -126,7 +126,7 @@ export class StateMachineCoordinator { if (!shouldContinue) { this.actor?.stop() - await this.closeManagers(runState.context.skillManagers) + await this.closeManagers(runState.context.skillManager) const cancelledCheckpoint = { ...runState.context.checkpoint, status: "stoppedByCancellation" as const, @@ -143,7 +143,7 @@ export class StateMachineCoordinator { * Handle errors - cleanup and reject. */ private async handleError(error: unknown): Promise { - await this.closeManagers(this.params.skillManagers).catch(() => {}) + await this.closeManagers(this.params.skillManager).catch(() => {}) this.rejectPromise?.(error instanceof Error ? error : new Error(String(error))) } } diff --git a/packages/runtime/src/state-machine/executor.ts b/packages/runtime/src/state-machine/executor.ts index 5f5e9177..14745587 100644 --- a/packages/runtime/src/state-machine/executor.ts +++ b/packages/runtime/src/state-machine/executor.ts @@ -1,14 +1,14 @@ import type { Checkpoint, Expert, RunEvent, RunSetting, RuntimeEvent, Step } from "@perstack/core" +import type { SkillManager } from "@perstack/skill-manager" import type { RunEventEmitter } from "../events/event-emitter.js" import type { LLMExecutor } from "../llm/index.js" -import type { BaseSkillManager } from "../skill-manager/index.js" import { StateMachineCoordinator } from "./coordinator.js" export type ExecuteStateMachineParams = { setting: RunSetting & { experts: Record } initialCheckpoint: Checkpoint eventListener: (event: RunEvent | RuntimeEvent) => Promise - skillManagers: Record + skillManager: SkillManager llmExecutor: LLMExecutor eventEmitter: RunEventEmitter storeCheckpoint: (checkpoint: Checkpoint) => Promise diff --git a/packages/runtime/src/state-machine/machine.ts b/packages/runtime/src/state-machine/machine.ts index fabfaa01..012c97a7 100644 --- a/packages/runtime/src/state-machine/machine.ts +++ b/packages/runtime/src/state-machine/machine.ts @@ -1,9 +1,9 @@ import type { Checkpoint, RunEvent, RunSetting, RuntimeEvent, Step } from "@perstack/core" +import type { SkillManager } from "@perstack/skill-manager" import { type ActorRefFrom, assign, type SnapshotFrom, setup } from "xstate" import { calculateContextWindowUsage } from "../helpers/model.js" import { createEmptyUsage, sumUsage } from "../helpers/usage.js" import type { LLMExecutor } from "../llm/index.js" -import type { BaseSkillManager } from "../skill-manager/index.js" import { callingDelegatesLogic } from "./states/calling-delegates.js" import { callingInteractiveToolsLogic } from "./states/calling-interactive-tools.js" import { callingMcpToolsLogic } from "./states/calling-mcp-tools.js" @@ -21,7 +21,7 @@ export const runtimeStateMachine = setup({ setting: RunSetting initialCheckpoint: Checkpoint eventListener: (event: RunEvent | RuntimeEvent) => Promise - skillManagers: Record + skillManager: SkillManager llmExecutor: LLMExecutor }, context: {} as { @@ -29,7 +29,7 @@ export const runtimeStateMachine = setup({ step: Step checkpoint: Checkpoint eventListener: (event: RunEvent | RuntimeEvent) => Promise - skillManagers: Record + skillManager: SkillManager llmExecutor: LLMExecutor }, events: {} as RunEvent, @@ -49,7 +49,7 @@ export const runtimeStateMachine = setup({ startedAt: Date.now(), }, eventListener: input.eventListener, - skillManagers: input.skillManagers, + skillManager: input.skillManager, llmExecutor: input.llmExecutor, }), states: { diff --git a/packages/runtime/src/state-machine/states/calling-delegates.test.ts b/packages/runtime/src/state-machine/states/calling-delegates.test.ts index 4ca48b8c..a46d6b06 100644 --- a/packages/runtime/src/state-machine/states/calling-delegates.test.ts +++ b/packages/runtime/src/state-machine/states/calling-delegates.test.ts @@ -6,12 +6,60 @@ type StopRunByDelegateResult = { checkpoint: Checkpoint } +import type { SkillAdapter, SkillManager } from "@perstack/skill-manager" import { createCheckpoint, createRunSetting, createStep } from "../../../test/run-params.js" import type { LLMExecutor } from "../../llm/index.js" import { createMockLLMExecutor } from "../../llm/index.js" -import type { BaseSkillManager } from "../../skill-manager/index.js" import { StateMachineLogics } from "../index.js" +function createMockAdapter(def: { + name: string + type: "mcp" | "delegate" | "interactive" + expert?: { key: string; name: string; version: string } + tools?: Array<{ name: string; description?: string }> +}): SkillAdapter { + return { + name: def.name, + type: def.type, + expert: def.expert, + callTool: async () => [], + getToolDefinitions: () => + (def.tools ?? []).map((t) => ({ + name: t.name, + skillName: def.name, + description: t.description, + inputSchema: {}, + interactive: false, + })), + } as unknown as SkillAdapter +} + +function createMockSkillManager(adapters: Record): SkillManager { + const adapterList = Object.values(adapters) + return { + getToolDefinitions: () => adapterList.flatMap((a) => a.getToolDefinitions()), + getAdapterByToolName: (toolName: string) => { + for (const adapter of adapterList) { + if (adapter.getToolDefinitions().some((t: { name: string }) => t.name === toolName)) { + return adapter + } + } + throw new Error(`Tool "${toolName}" not found`) + }, + callTool: async (toolName: string, input: Record) => { + for (const adapter of adapterList) { + if (adapter.getToolDefinitions().some((t: { name: string }) => t.name === toolName)) { + return adapter.callTool(toolName, input) + } + } + throw new Error(`Tool "${toolName}" not found`) + }, + close: async () => {}, + isClosed: false, + getAdapters: () => new Map(adapterList.map((a) => [a.name, a])), + } as unknown as SkillManager +} + const mockLLMExecutor = createMockLLMExecutor() as unknown as LLMExecutor describe("@perstack/runtime: StateMachineLogic['CallingDelegates']", () => { @@ -29,32 +77,21 @@ describe("@perstack/runtime: StateMachineLogic['CallingDelegates']", () => { ], partialToolResults: [], }) - const skillManagers = { - "@perstack/math-expert": { + const skillManager = createMockSkillManager({ + "@perstack/math-expert": createMockAdapter({ name: "@perstack/math-expert", - type: "delegate" as const, - _toolDefinitions: [], - _initialized: true, - expert: { - key: "@perstack/math-expert", - name: "@perstack/math-expert", - version: "1.0.0", - }, - init: async () => {}, - getToolDefinitions: async () => [ - { name: "@perstack/math-expert", description: "Math calculations" }, - ], - callTool: async () => {}, - close: async () => {}, - } as unknown as BaseSkillManager, - } + type: "delegate", + expert: { key: "@perstack/math-expert", name: "@perstack/math-expert", version: "1.0.0" }, + tools: [{ name: "@perstack/math-expert", description: "Math calculations" }], + }), + }) await expect( StateMachineLogics.CallingDelegates({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }), ).resolves.toStrictEqual({ @@ -102,7 +139,7 @@ describe("@perstack/runtime: StateMachineLogic['CallingDelegates']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManager({}), llmExecutor: mockLLMExecutor, }), ).rejects.toThrow("CallingDelegates: tool handling state is undefined (invariant violation)") @@ -120,7 +157,7 @@ describe("@perstack/runtime: StateMachineLogic['CallingDelegates']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManager({}), llmExecutor: mockLLMExecutor, }) expect(result.type).toBe("skipDelegates") @@ -140,28 +177,21 @@ describe("@perstack/runtime: StateMachineLogic['CallingDelegates']", () => { ], partialToolResults: [], }) - const skillManagers = { - "@perstack/math-expert": { + const skillManager = createMockSkillManager({ + "@perstack/math-expert": createMockAdapter({ name: "@perstack/math-expert", - type: "delegate" as const, - _toolDefinitions: [], - _initialized: true, + type: "delegate", expert: undefined, - init: async () => {}, - getToolDefinitions: async () => [ - { name: "@perstack/math-expert", description: "Math calculations" }, - ], - callTool: async () => {}, - close: async () => {}, - } as unknown as BaseSkillManager, - } + tools: [{ name: "@perstack/math-expert", description: "Math calculations" }], + }), + }) await expect( StateMachineLogics.CallingDelegates({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }), ).rejects.toThrow('Delegation error: skill manager "@perstack/math-expert" not found') @@ -181,32 +211,21 @@ describe("@perstack/runtime: StateMachineLogic['CallingDelegates']", () => { ], partialToolResults: [], }) - const skillManagers = { - "@perstack/math-expert": { + const skillManager = createMockSkillManager({ + "@perstack/math-expert": createMockAdapter({ name: "@perstack/math-expert", - type: "delegate" as const, - _toolDefinitions: [], - _initialized: true, - expert: { - key: "@perstack/math-expert", - name: "@perstack/math-expert", - version: "1.0.0", - }, - init: async () => {}, - getToolDefinitions: async () => [ - { name: "@perstack/math-expert", description: "Math calculations" }, - ], - callTool: async () => {}, - close: async () => {}, - } as unknown as BaseSkillManager, - } + type: "delegate", + expert: { key: "@perstack/math-expert", name: "@perstack/math-expert", version: "1.0.0" }, + tools: [{ name: "@perstack/math-expert", description: "Math calculations" }], + }), + }) await expect( StateMachineLogics.CallingDelegates({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }), ).rejects.toThrow("Delegation error: query is undefined") @@ -232,48 +251,26 @@ describe("@perstack/runtime: StateMachineLogic['CallingDelegates']", () => { ], partialToolResults: [], }) - const skillManagers = { - "@perstack/math-expert": { + const skillManager = createMockSkillManager({ + "@perstack/math-expert": createMockAdapter({ name: "@perstack/math-expert", - type: "delegate" as const, - _toolDefinitions: [], - _initialized: true, - expert: { - key: "@perstack/math-expert", - name: "@perstack/math-expert", - version: "1.0.0", - }, - init: async () => {}, - getToolDefinitions: async () => [ - { name: "@perstack/math-expert", description: "Math calculations" }, - ], - callTool: async () => {}, - close: async () => {}, - } as unknown as BaseSkillManager, - "@perstack/text-expert": { + type: "delegate", + expert: { key: "@perstack/math-expert", name: "@perstack/math-expert", version: "1.0.0" }, + tools: [{ name: "@perstack/math-expert", description: "Math calculations" }], + }), + "@perstack/text-expert": createMockAdapter({ name: "@perstack/text-expert", - type: "delegate" as const, - _toolDefinitions: [], - _initialized: true, - expert: { - key: "@perstack/text-expert", - name: "@perstack/text-expert", - version: "1.0.0", - }, - init: async () => {}, - getToolDefinitions: async () => [ - { name: "@perstack/text-expert", description: "Text processing" }, - ], - callTool: async () => {}, - close: async () => {}, - } as unknown as BaseSkillManager, - } + type: "delegate", + expert: { key: "@perstack/text-expert", name: "@perstack/text-expert", version: "1.0.0" }, + tools: [{ name: "@perstack/text-expert", description: "Text processing" }], + }), + }) const result = (await StateMachineLogics.CallingDelegates({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, })) as StopRunByDelegateResult expect(result.type).toBe("stopRunByDelegate") @@ -330,41 +327,25 @@ describe("@perstack/runtime: StateMachineLogic['CallingDelegates']", () => { }, ], }) - const skillManagers = { - "@perstack/math-expert": { + const skillManager = createMockSkillManager({ + "@perstack/math-expert": createMockAdapter({ name: "@perstack/math-expert", - type: "delegate" as const, - _toolDefinitions: [], - _initialized: true, - expert: { - key: "@perstack/math-expert", - name: "@perstack/math-expert", - version: "1.0.0", - }, - init: async () => {}, - getToolDefinitions: async () => [ - { name: "@perstack/math-expert", description: "Math calculations" }, - ], - callTool: async () => {}, - close: async () => {}, - } as unknown as BaseSkillManager, - "@perstack/base": { + type: "delegate", + expert: { key: "@perstack/math-expert", name: "@perstack/math-expert", version: "1.0.0" }, + tools: [{ name: "@perstack/math-expert", description: "Math calculations" }], + }), + "@perstack/base": createMockAdapter({ name: "@perstack/base", - type: "mcp" as const, - _toolDefinitions: [], - _initialized: true, - init: async () => {}, - getToolDefinitions: async () => [{ name: "readFile", description: "Read file" }], - callTool: async () => {}, - close: async () => {}, - } as unknown as BaseSkillManager, - } + type: "mcp", + tools: [{ name: "readFile", description: "Read file" }], + }), + }) const result = (await StateMachineLogics.CallingDelegates({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, })) as StopRunByDelegateResult expect(result.type).toBe("stopRunByDelegate") diff --git a/packages/runtime/src/state-machine/states/calling-delegates.ts b/packages/runtime/src/state-machine/states/calling-delegates.ts index 8975a038..b04469a2 100644 --- a/packages/runtime/src/state-machine/states/calling-delegates.ts +++ b/packages/runtime/src/state-machine/states/calling-delegates.ts @@ -4,7 +4,6 @@ import { skipDelegates, stopRunByDelegate, } from "@perstack/core" -import { getSkillManagerByToolName } from "../../skill-manager/index.js" import { getToolTypeByName } from "../../tool-execution/index.js" import type { RunSnapshot } from "../machine.js" @@ -24,7 +23,7 @@ export async function callingDelegatesLogic({ setting, checkpoint, step, - skillManagers, + skillManager, }: RunSnapshot["context"]): Promise { // Invariant: pendingToolCalls and partialToolResults must be defined when entering CallingDelegates if (step.pendingToolCalls === undefined || step.partialToolResults === undefined) { @@ -37,12 +36,10 @@ export async function callingDelegatesLogic({ } // Classify pending tool calls - const toolCallTypes = await Promise.all( - step.pendingToolCalls.map(async (tc) => ({ - toolCall: tc, - type: await getToolTypeByName(tc.toolName, skillManagers), - })), - ) + const toolCallTypes = step.pendingToolCalls.map((tc) => ({ + toolCall: tc, + type: getToolTypeByName(tc.toolName, skillManager), + })) const delegateToolCalls = toolCallTypes .filter((t) => t.type === "delegate") @@ -57,27 +54,25 @@ export async function callingDelegatesLogic({ } // Build delegation targets - const delegations: DelegationTarget[] = await Promise.all( - delegateToolCalls.map(async (tc) => { - const skillManager = await getSkillManagerByToolName(skillManagers, tc.toolName) - if (!skillManager.expert) { - throw new Error(`Delegation error: skill manager "${tc.toolName}" not found`) - } - if (!tc.args || !tc.args.query || typeof tc.args.query !== "string") { - throw new Error(`Delegation error: query is undefined for ${tc.toolName}`) - } - return { - expert: { - key: skillManager.expert.key, - name: skillManager.expert.name, - version: skillManager.expert.version, - }, - toolCallId: tc.id, - toolName: tc.toolName, - query: tc.args.query, - } - }), - ) + const delegations: DelegationTarget[] = delegateToolCalls.map((tc) => { + const adapter = skillManager.getAdapterByToolName(tc.toolName) + if (!adapter.expert) { + throw new Error(`Delegation error: skill manager "${tc.toolName}" not found`) + } + if (!tc.args || !tc.args.query || typeof tc.args.query !== "string") { + throw new Error(`Delegation error: query is undefined for ${tc.toolName}`) + } + return { + expert: { + key: adapter.expert.key, + name: adapter.expert.name, + version: adapter.expert.version, + }, + toolCallId: tc.id, + toolName: tc.toolName, + query: tc.args.query, + } + }) return stopRunByDelegate(setting, checkpoint, { checkpoint: { diff --git a/packages/runtime/src/state-machine/states/calling-interactive-tools.test.ts b/packages/runtime/src/state-machine/states/calling-interactive-tools.test.ts index 5ac79d61..5e38065b 100644 --- a/packages/runtime/src/state-machine/states/calling-interactive-tools.test.ts +++ b/packages/runtime/src/state-machine/states/calling-interactive-tools.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest" -import { createCheckpoint, createRunSetting, createStep } from "../../../test/run-params.js" +import { + createCheckpoint, + createMockSkillManagerFromAdapters, + createRunSetting, + createStep, +} from "../../../test/run-params.js" import type { LLMExecutor } from "../../llm/index.js" import { createMockLLMExecutor } from "../../llm/index.js" import { StateMachineLogics } from "../index.js" @@ -30,7 +35,7 @@ describe("@perstack/runtime: StateMachineLogic['CallingInteractiveTools']", () = checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).resolves.toStrictEqual({ @@ -71,7 +76,7 @@ describe("@perstack/runtime: StateMachineLogic['CallingInteractiveTools']", () = checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }) expect(result.type).toBe("resolveToolResults") @@ -87,7 +92,7 @@ describe("@perstack/runtime: StateMachineLogic['CallingInteractiveTools']", () = checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).rejects.toThrow( @@ -110,7 +115,7 @@ describe("@perstack/runtime: StateMachineLogic['CallingInteractiveTools']", () = checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }) expect(result.type).toBe("stopRunByInteractiveTool") @@ -134,7 +139,7 @@ describe("@perstack/runtime: StateMachineLogic['CallingInteractiveTools']", () = checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }) expect(result.type).toBe("stopRunByInteractiveTool") diff --git a/packages/runtime/src/state-machine/states/calling-mcp-tools.test.ts b/packages/runtime/src/state-machine/states/calling-mcp-tools.test.ts index 7c4d649d..0a041daf 100644 --- a/packages/runtime/src/state-machine/states/calling-mcp-tools.test.ts +++ b/packages/runtime/src/state-machine/states/calling-mcp-tools.test.ts @@ -1,9 +1,9 @@ import { createId } from "@paralleldrive/cuid2" +import type { SkillAdapter, SkillManager } from "@perstack/skill-manager" import { describe, expect, it, vi } from "vitest" import { createCheckpoint, createRunSetting, createStep } from "../../../test/run-params.js" import type { LLMExecutor } from "../../llm/index.js" import { createMockLLMExecutor } from "../../llm/index.js" -import type { BaseSkillManager } from "../../skill-manager/index.js" import { callingMcpToolsLogic } from "./calling-mcp-tools.js" const mockLLMExecutor = createMockLLMExecutor() as unknown as LLMExecutor @@ -11,11 +11,11 @@ const mockLLMExecutor = createMockLLMExecutor() as unknown as LLMExecutor type CallToolResult = Array<{ type: string; text?: string; id: string }> type CallToolFn = (toolName: string, args: unknown) => Promise -function createMockMcpSkillManager( +function createMockMcpAdapter( name: string, toolNames: string | string[], callToolFnOrResult?: CallToolFn | CallToolResult, -): BaseSkillManager { +): SkillAdapter { const tools = Array.isArray(toolNames) ? toolNames : [toolNames] const defaultCallTool = async () => [ { type: "textPart", text: "Tool executed successfully", id: createId() }, @@ -29,56 +29,57 @@ function createMockMcpSkillManager( return { name, type: "mcp" as const, - lazyInit: false, - _toolDefinitions: tools.map((t) => ({ - name: t, - skillName: name, - inputSchema: {}, - interactive: false, - })), - _initialized: true, - init: async () => {}, - isInitialized: () => true, - getToolDefinitions: async () => - tools.map((t) => ({ name: t, skillName: name, inputSchema: {}, interactive: false })), callTool, - close: async () => {}, - } as unknown as BaseSkillManager + getToolDefinitions: () => + tools.map((t) => ({ name: t, skillName: name, inputSchema: {}, interactive: false })), + } as unknown as SkillAdapter } -function createMockDelegateSkillManager(name: string): BaseSkillManager { +function createMockDelegateAdapter(name: string): SkillAdapter { return { name, type: "delegate" as const, - lazyInit: false, - _toolDefinitions: [], - _initialized: true, expert: { key: name, name, version: "1.0.0" }, - init: async () => {}, - isInitialized: () => true, - getToolDefinitions: async () => [ - { name, skillName: name, inputSchema: {}, interactive: false }, - ], callTool: async () => [], - close: async () => {}, - } as unknown as BaseSkillManager + getToolDefinitions: () => [{ name, skillName: name, inputSchema: {}, interactive: false }], + } as unknown as SkillAdapter } -function createMockInteractiveSkillManager(name: string, toolName: string): BaseSkillManager { +function createMockInteractiveAdapter(name: string, toolName: string): SkillAdapter { return { name, type: "interactive" as const, - lazyInit: false, - _toolDefinitions: [{ name: toolName, skillName: name, inputSchema: {}, interactive: true }], - _initialized: true, - init: async () => {}, - isInitialized: () => true, - getToolDefinitions: async () => [ + callTool: async () => [], + getToolDefinitions: () => [ { name: toolName, skillName: name, inputSchema: {}, interactive: true }, ], - callTool: async () => [], + } as unknown as SkillAdapter +} + +function createMockSkillManager(adapters: Record): SkillManager { + const adapterList = Object.values(adapters) + return { + getToolDefinitions: () => adapterList.flatMap((a) => a.getToolDefinitions()), + getAdapterByToolName: (toolName: string) => { + for (const adapter of adapterList) { + if (adapter.getToolDefinitions().some((t: { name: string }) => t.name === toolName)) { + return adapter + } + } + throw new Error(`Tool "${toolName}" not found`) + }, + callTool: async (toolName: string, input: Record) => { + for (const adapter of adapterList) { + if (adapter.getToolDefinitions().some((t: { name: string }) => t.name === toolName)) { + return adapter.callTool(toolName, input) + } + } + throw new Error(`Tool "${toolName}" not found`) + }, close: async () => {}, - } as unknown as BaseSkillManager + isClosed: false, + getAdapters: () => new Map(adapterList.map((a) => [a.name, a])), + } as unknown as SkillManager } describe("@perstack/runtime: callingMcpToolsLogic", () => { @@ -99,16 +100,16 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { const callToolB = vi.fn(async (toolName: string) => [ { type: "textPart", text: `Result from ${toolName}`, id: createId() }, ]) - const skillManagers = { - "skill-a": createMockMcpSkillManager("skill-a", ["tool1", "tool2"], callToolA), - "skill-b": createMockMcpSkillManager("skill-b", ["tool3"], callToolB), - } + const skillManager = createMockSkillManager({ + "skill-a": createMockMcpAdapter("skill-a", ["tool1", "tool2"], callToolA), + "skill-b": createMockMcpAdapter("skill-b", ["tool3"], callToolB), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("resolveToolResults") @@ -137,15 +138,15 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { } return [{ type: "textPart", text: toolName, id: createId() }] }) - const skillManagers = { - "test-skill": createMockMcpSkillManager("test-skill", ["slowTool", "fastTool"], callTool), - } + const skillManager = createMockSkillManager({ + "test-skill": createMockMcpAdapter("test-skill", ["slowTool", "fastTool"], callTool), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("resolveToolResults") @@ -170,20 +171,16 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { await new Promise((r) => setTimeout(r, DELAY_MS)) return [{ type: "textPart", text: "done", id: createId() }] }) - const skillManagers = { - "test-skill": createMockMcpSkillManager( - "test-skill", - ["tool1", "tool2", "tool3"], - callTool, - ), - } + const skillManager = createMockSkillManager({ + "test-skill": createMockMcpAdapter("test-skill", ["tool1", "tool2", "tool3"], callTool), + }) const start = Date.now() await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) const elapsed = Date.now() - start @@ -200,15 +197,15 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { { id: "tc_123", skillName: "test-skill", toolName: "testTool", args: { param: "value" } }, ], }) - const skillManagers = { - "test-skill": createMockMcpSkillManager("test-skill", "testTool"), - } + const skillManager = createMockSkillManager({ + "test-skill": createMockMcpAdapter("test-skill", "testTool"), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("resolveToolResults") @@ -229,7 +226,7 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManager({}), llmExecutor: mockLLMExecutor, }), ).rejects.toThrow("CallingMcpTools: tool handling state is undefined (invariant violation)") @@ -243,15 +240,15 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { { id: "tc_123", skillName: "delegate-skill", toolName: "delegate-skill", args: {} }, ], }) - const skillManagers = { - "delegate-skill": createMockDelegateSkillManager("delegate-skill"), - } + const skillManager = createMockSkillManager({ + "delegate-skill": createMockDelegateAdapter("delegate-skill"), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("finishMcpTools") @@ -265,18 +262,15 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { { id: "tc_123", skillName: "interactive-skill", toolName: "humanApproval", args: {} }, ], }) - const skillManagers = { - "interactive-skill": createMockInteractiveSkillManager( - "interactive-skill", - "humanApproval", - ), - } + const skillManager = createMockSkillManager({ + "interactive-skill": createMockInteractiveAdapter("interactive-skill", "humanApproval"), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("finishMcpTools") @@ -296,10 +290,10 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManager({}), llmExecutor: mockLLMExecutor, }), - ).rejects.toThrow("Tool unknownTool not found") + ).rejects.toThrow('Tool "unknownTool" not found') }) }) @@ -316,15 +310,15 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { }, ], }) - const skillManagers = { - "@perstack/base": createMockMcpSkillManager("@perstack/base", "think"), - } + const skillManager = createMockSkillManager({ + "@perstack/base": createMockMcpAdapter("@perstack/base", "think"), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("resolveToolResults") @@ -344,19 +338,15 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { ], }) const emptyResult = [{ type: "textPart", text: JSON.stringify({}), id: createId() }] - const skillManagers = { - "@perstack/base": createMockMcpSkillManager( - "@perstack/base", - "attemptCompletion", - emptyResult, - ), - } + const skillManager = createMockSkillManager({ + "@perstack/base": createMockMcpAdapter("@perstack/base", "attemptCompletion", emptyResult), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("attemptCompletion") @@ -412,19 +402,15 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { ], }) const emptyResult = [{ type: "textPart", text: JSON.stringify({}), id: createId() }] - const skillManagers = { - "@perstack/base": createMockMcpSkillManager( - "@perstack/base", - "attemptCompletion", - emptyResult, - ), - } + const skillManager = createMockSkillManager({ + "@perstack/base": createMockMcpAdapter("@perstack/base", "attemptCompletion", emptyResult), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("completeRun") @@ -477,19 +463,15 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { ], }) const emptyResult = [{ type: "textPart", text: JSON.stringify({}), id: createId() }] - const skillManagers = { - "@perstack/base": createMockMcpSkillManager( - "@perstack/base", - "attemptCompletion", - emptyResult, - ), - } + const skillManager = createMockSkillManager({ + "@perstack/base": createMockMcpAdapter("@perstack/base", "attemptCompletion", emptyResult), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) // Should transition to GeneratingRunResult since textPart is empty @@ -516,19 +498,19 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { id: createId(), }, ] - const skillManagers = { - "@perstack/base": createMockMcpSkillManager( + const skillManager = createMockSkillManager({ + "@perstack/base": createMockMcpAdapter( "@perstack/base", "attemptCompletion", remainingTodosResult, ), - } + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("resolveToolResults") @@ -548,15 +530,15 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { ], }) const pdfResult = [{ type: "textPart", text: "PDF content", id: createId() }] - const skillManagers = { - "@perstack/base": createMockMcpSkillManager("@perstack/base", "readPdfFile", pdfResult), - } + const skillManager = createMockSkillManager({ + "@perstack/base": createMockMcpAdapter("@perstack/base", "readPdfFile", pdfResult), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("resolveToolResults") @@ -578,15 +560,15 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { const imageResult = [ { type: "imageInlinePart", encodedData: "base64data", mimeType: "image/png", id: createId() }, ] - const skillManagers = { - "@perstack/base": createMockMcpSkillManager("@perstack/base", "readImageFile", imageResult), - } + const skillManager = createMockSkillManager({ + "@perstack/base": createMockMcpAdapter("@perstack/base", "readImageFile", imageResult), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("resolveToolResults") @@ -605,15 +587,15 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { }, ], }) - const skillManagers = { - "@perstack/base": createMockMcpSkillManager("@perstack/base", "readTextFile"), - } + const skillManager = createMockSkillManager({ + "@perstack/base": createMockMcpAdapter("@perstack/base", "readTextFile"), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("resolveToolResults") @@ -638,10 +620,10 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManager({}), llmExecutor: mockLLMExecutor, }), - ).rejects.toThrow("Tool unknownTool not found") + ).rejects.toThrow('Tool "unknownTool" not found') }) it("executes multiple tools in parallel", async () => { @@ -663,32 +645,17 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { }, ], }) - const skillManagers = { - "test-skill": { - name: "test-skill", - type: "mcp" as const, - lazyInit: false, - _toolDefinitions: [ - { name: "testTool1", skillName: "test-skill", inputSchema: {}, interactive: false }, - { name: "testTool2", skillName: "test-skill", inputSchema: {}, interactive: false }, - ], - _initialized: true, - init: async () => {}, - isInitialized: () => true, - getToolDefinitions: async () => [ - { name: "testTool1", skillName: "test-skill", inputSchema: {}, interactive: false }, - { name: "testTool2", skillName: "test-skill", inputSchema: {}, interactive: false }, - ], - callTool: async () => [{ type: "textPart", text: "Success", id: createId() }], - close: async () => {}, - } as unknown as BaseSkillManager, - } + const skillManager = createMockSkillManager({ + "test-skill": createMockMcpAdapter("test-skill", ["testTool1", "testTool2"], async () => [ + { type: "textPart", text: "Success", id: createId() }, + ]), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("resolveToolResults") @@ -707,16 +674,16 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { { id: "tc_delegate", skillName: "delegate-skill", toolName: "delegate-skill", args: {} }, ], }) - const skillManagers = { - "mcp-skill": createMockMcpSkillManager("mcp-skill", "mcpTool"), - "delegate-skill": createMockDelegateSkillManager("delegate-skill"), - } + const skillManager = createMockSkillManager({ + "mcp-skill": createMockMcpAdapter("mcp-skill", "mcpTool"), + "delegate-skill": createMockDelegateAdapter("delegate-skill"), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("finishMcpTools") @@ -740,19 +707,16 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { }, ], }) - const skillManagers = { - "mcp-skill": createMockMcpSkillManager("mcp-skill", "mcpTool"), - "interactive-skill": createMockInteractiveSkillManager( - "interactive-skill", - "humanApproval", - ), - } + const skillManager = createMockSkillManager({ + "mcp-skill": createMockMcpAdapter("mcp-skill", "mcpTool"), + "interactive-skill": createMockInteractiveAdapter("interactive-skill", "humanApproval"), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("finishMcpTools") @@ -776,19 +740,16 @@ describe("@perstack/runtime: callingMcpToolsLogic", () => { }, ], }) - const skillManagers = { - "delegate-skill": createMockDelegateSkillManager("delegate-skill"), - "interactive-skill": createMockInteractiveSkillManager( - "interactive-skill", - "humanApproval", - ), - } + const skillManager = createMockSkillManager({ + "delegate-skill": createMockDelegateAdapter("delegate-skill"), + "interactive-skill": createMockInteractiveAdapter("interactive-skill", "humanApproval"), + }) const event = await callingMcpToolsLogic({ setting, checkpoint, step, eventListener: async () => {}, - skillManagers, + skillManager, llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("finishMcpTools") diff --git a/packages/runtime/src/state-machine/states/calling-mcp-tools.ts b/packages/runtime/src/state-machine/states/calling-mcp-tools.ts index 3e003aa5..ca502bd6 100644 --- a/packages/runtime/src/state-machine/states/calling-mcp-tools.ts +++ b/packages/runtime/src/state-machine/states/calling-mcp-tools.ts @@ -64,7 +64,7 @@ export async function callingMcpToolsLogic({ setting, checkpoint, step, - skillManagers, + skillManager, }: RunSnapshot["context"]): Promise { // Invariant: pendingToolCalls and partialToolResults must be defined when entering CallingMcpTools if (step.pendingToolCalls === undefined || step.partialToolResults === undefined) { @@ -82,11 +82,7 @@ export async function callingMcpToolsLogic({ (tc) => tc.skillName === "@perstack/base" && tc.toolName === "attemptCompletion", ) if (attemptCompletionTool) { - const toolResult = await toolExecutorFactory.execute( - attemptCompletionTool, - "mcp", - skillManagers, - ) + const toolResult = await toolExecutorFactory.execute(attemptCompletionTool, "mcp", skillManager) if (hasRemainingTodos(toolResult)) { return resolveToolResults(setting, checkpoint, { toolResults: [toolResult] }) } @@ -143,12 +139,12 @@ export async function callingMcpToolsLogic({ } // Classify tool calls by type - const classified = await classifyToolCalls(pendingToolCalls, skillManagers) + const classified = classifyToolCalls(pendingToolCalls, skillManager) // Execute MCP tools if (classified.mcp.length > 0) { const mcpResults = await Promise.all( - classified.mcp.map((c) => toolExecutorFactory.execute(c.toolCall, "mcp", skillManagers)), + classified.mcp.map((c) => toolExecutorFactory.execute(c.toolCall, "mcp", skillManager)), ) toolResults.push(...mcpResults) } diff --git a/packages/runtime/src/state-machine/states/finishing-step.test.ts b/packages/runtime/src/state-machine/states/finishing-step.test.ts index 2316372b..e0f18b27 100644 --- a/packages/runtime/src/state-machine/states/finishing-step.test.ts +++ b/packages/runtime/src/state-machine/states/finishing-step.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest" -import { createCheckpoint, createRunSetting, createStep } from "../../../test/run-params.js" +import { + createCheckpoint, + createMockSkillManagerFromAdapters, + createRunSetting, + createStep, +} from "../../../test/run-params.js" import type { LLMExecutor } from "../../llm/index.js" import { createMockLLMExecutor } from "../../llm/index.js" import { StateMachineLogics } from "../machine.js" @@ -17,7 +22,7 @@ describe("@perstack/runtime: StateMachineLogic['FinishingStep']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).resolves.toStrictEqual({ @@ -55,7 +60,7 @@ describe("@perstack/runtime: StateMachineLogic['FinishingStep']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).resolves.toStrictEqual({ @@ -87,7 +92,7 @@ describe("@perstack/runtime: StateMachineLogic['FinishingStep']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).resolves.toStrictEqual({ diff --git a/packages/runtime/src/state-machine/states/generating-run-result.test.ts b/packages/runtime/src/state-machine/states/generating-run-result.test.ts index 8510a738..5482bb34 100644 --- a/packages/runtime/src/state-machine/states/generating-run-result.test.ts +++ b/packages/runtime/src/state-machine/states/generating-run-result.test.ts @@ -1,7 +1,12 @@ import { createId } from "@paralleldrive/cuid2" import type { GenerateTextResult, ToolSet } from "ai" import { beforeEach, describe, expect, it } from "vitest" -import { createCheckpoint, createRunSetting, createStep } from "../../../test/run-params.js" +import { + createCheckpoint, + createMockSkillManagerFromAdapters, + createRunSetting, + createStep, +} from "../../../test/run-params.js" import type { LLMExecutor } from "../../llm/index.js" import { createMockLLMExecutor, type MockLLMExecutor } from "../../llm/index.js" import type { LLMExecutionResult } from "../../llm/types.js" @@ -84,7 +89,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingRunResult']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("completeRun") @@ -122,7 +127,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingRunResult']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("retry") @@ -160,7 +165,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingRunResult']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("stopRunByError") @@ -203,7 +208,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingRunResult']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("retry") @@ -219,7 +224,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingRunResult']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }), ).rejects.toThrow("No tool calls or tool results found") @@ -252,7 +257,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingRunResult']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.id).toBeDefined() @@ -288,7 +293,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingRunResult']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("stopRunByError") @@ -324,7 +329,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingRunResult']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("completeRun") diff --git a/packages/runtime/src/state-machine/states/generating-tool-call.test.ts b/packages/runtime/src/state-machine/states/generating-tool-call.test.ts index 2a7dd6a1..1b2a22f5 100644 --- a/packages/runtime/src/state-machine/states/generating-tool-call.test.ts +++ b/packages/runtime/src/state-machine/states/generating-tool-call.test.ts @@ -1,10 +1,10 @@ +import type { SkillAdapter, SkillManager } from "@perstack/skill-manager" import type { GenerateTextResult, ToolSet } from "ai" import { beforeEach, describe, expect, it } from "vitest" import { createCheckpoint, createRunSetting, createStep } from "../../../test/run-params.js" import type { LLMExecutor } from "../../llm/index.js" import { createMockLLMExecutor, type MockLLMExecutor } from "../../llm/index.js" import type { LLMExecutionResult } from "../../llm/types.js" -import type { BaseSkillManager } from "../../skill-manager/index.js" import { StateMachineLogics } from "../index.js" let mockLLMExecutor: MockLLMExecutor @@ -66,25 +66,45 @@ function createMockErrorResult( } } -function createMockSkillManager( +function createMockAdapter( name: string, type: "mcp" | "interactive" | "delegate", toolName: string, -): BaseSkillManager { +): SkillAdapter { return { name, type, - lazyInit: false, - _toolDefinitions: [{ name: toolName, skillName: name, inputSchema: {}, interactive: false }], - _initialized: true, - init: async () => {}, - isInitialized: () => true, - getToolDefinitions: async () => [ + callTool: async () => [], + getToolDefinitions: () => [ { name: toolName, skillName: name, inputSchema: {}, interactive: false }, ], - callTool: async () => [], + } as unknown as SkillAdapter +} + +function wrapAdaptersAsSkillManager(adapters: Record): SkillManager { + const adapterList = Object.values(adapters) + return { + getToolDefinitions: () => adapterList.flatMap((a) => a.getToolDefinitions()), + getAdapterByToolName: (toolName: string) => { + for (const adapter of adapterList) { + if (adapter.getToolDefinitions().some((t: { name: string }) => t.name === toolName)) { + return adapter + } + } + throw new Error(`Tool "${toolName}" not found`) + }, + callTool: async (toolName: string, input: Record) => { + for (const adapter of adapterList) { + if (adapter.getToolDefinitions().some((t: { name: string }) => t.name === toolName)) { + return adapter.callTool(toolName, input) + } + } + throw new Error(`Tool "${toolName}" not found`) + }, close: async () => {}, - } as unknown as BaseSkillManager + isClosed: false, + getAdapters: () => new Map(adapterList.map((a) => [a.name, a])), + } as unknown as SkillManager } describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { @@ -102,7 +122,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: wrapAdaptersAsSkillManager({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("completeRun") @@ -122,7 +142,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: wrapAdaptersAsSkillManager({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("retry") @@ -141,7 +161,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: wrapAdaptersAsSkillManager({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("retry") @@ -162,7 +182,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: wrapAdaptersAsSkillManager({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("stopRunByError") @@ -189,7 +209,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: wrapAdaptersAsSkillManager({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("retry") @@ -205,7 +225,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: wrapAdaptersAsSkillManager({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.id).toBeDefined() @@ -223,7 +243,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: wrapAdaptersAsSkillManager({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) // Text-only with stop = implicit completion @@ -241,7 +261,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: wrapAdaptersAsSkillManager({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("completeRun") @@ -252,7 +272,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { const setting = createRunSetting() const checkpoint = createCheckpoint() const step = createStep() - const skillManager = createMockSkillManager("test-skill", "mcp", "testTool") + const testAdapter = createMockAdapter("test-skill", "mcp", "testTool") mockLLMExecutor.setMockResult( createMockResult({ finishReason: "tool-calls", @@ -264,7 +284,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: { "test-skill": skillManager }, + skillManager: wrapAdaptersAsSkillManager({ "test-skill": testAdapter }), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("callTools") @@ -274,7 +294,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { const setting = createRunSetting() const checkpoint = createCheckpoint() const step = createStep() - const skillManager = createMockSkillManager("interactive-skill", "interactive", "askUser") + const testAdapter = createMockAdapter("interactive-skill", "interactive", "askUser") mockLLMExecutor.setMockResult( createMockResult({ finishReason: "stop", @@ -286,7 +306,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: { "interactive-skill": skillManager }, + skillManager: wrapAdaptersAsSkillManager({ "interactive-skill": testAdapter }), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("callTools") @@ -296,7 +316,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { const setting = createRunSetting() const checkpoint = createCheckpoint() const step = createStep() - const skillManager = createMockSkillManager("delegate-skill", "delegate", "delegateTask") + const testAdapter = createMockAdapter("delegate-skill", "delegate", "delegateTask") mockLLMExecutor.setMockResult( createMockResult({ finishReason: "tool-calls", @@ -310,7 +330,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: { "delegate-skill": skillManager }, + skillManager: wrapAdaptersAsSkillManager({ "delegate-skill": testAdapter }), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("callTools") @@ -320,13 +340,9 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { const setting = createRunSetting() const checkpoint = createCheckpoint() const step = createStep() - const mcpSkillManager = createMockSkillManager("mcp-skill", "mcp", "mcpTool") - const delegateSkillManager = createMockSkillManager( - "delegate-skill", - "delegate", - "delegateTool", - ) - const interactiveSkillManager = createMockSkillManager( + const mcpAdapter = createMockAdapter("mcp-skill", "mcp", "mcpTool") + const delegateAdapter = createMockAdapter("delegate-skill", "delegate", "delegateTool") + const interactiveAdapter = createMockAdapter( "interactive-skill", "interactive", "interactiveTool", @@ -346,11 +362,11 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: { - "mcp-skill": mcpSkillManager, - "delegate-skill": delegateSkillManager, - "interactive-skill": interactiveSkillManager, - }, + skillManager: wrapAdaptersAsSkillManager({ + "mcp-skill": mcpAdapter, + "delegate-skill": delegateAdapter, + "interactive-skill": interactiveAdapter, + }), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("callTools") @@ -365,7 +381,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { const setting = createRunSetting() const checkpoint = createCheckpoint() const step = createStep() - const skillManager = createMockSkillManager("test-skill", "mcp", "testTool") + const testAdapter = createMockAdapter("test-skill", "mcp", "testTool") mockLLMExecutor.setMockResult( createMockResult({ finishReason: "length", @@ -377,7 +393,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: { "test-skill": skillManager }, + skillManager: wrapAdaptersAsSkillManager({ "test-skill": testAdapter }), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("retry") @@ -387,7 +403,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { const setting = createRunSetting() const checkpoint = createCheckpoint() const step = createStep() - const skillManager = createMockSkillManager("test-skill", "mcp", "testTool") + const testAdapter = createMockAdapter("test-skill", "mcp", "testTool") mockLLMExecutor.setMockResult( createMockResult({ finishReason: "error" as "stop", @@ -400,7 +416,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: { "test-skill": skillManager }, + skillManager: wrapAdaptersAsSkillManager({ "test-skill": testAdapter }), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }), ).rejects.toThrow("Unexpected finish reason: error") @@ -410,7 +426,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { const setting = createRunSetting() const checkpoint = createCheckpoint() const step = createStep() - const skillManager = createMockSkillManager("test-skill", "mcp", "testTool") + const testAdapter = createMockAdapter("test-skill", "mcp", "testTool") mockLLMExecutor.setMockResult( createMockResult({ finishReason: "tool-calls", @@ -423,7 +439,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: { "test-skill": skillManager }, + skillManager: wrapAdaptersAsSkillManager({ "test-skill": testAdapter }), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("callTools") @@ -433,7 +449,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { const setting = createRunSetting({ maxRetries: 3 }) const checkpoint = createCheckpoint({ retryCount: 3 }) const step = createStep() - const skillManager = createMockSkillManager("test-skill", "mcp", "testTool") + const testAdapter = createMockAdapter("test-skill", "mcp", "testTool") mockLLMExecutor.setMockResult( createMockErrorResult({ name: "RateLimitError", message: "Rate limited" }, true), ) @@ -442,7 +458,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: { "test-skill": skillManager }, + skillManager: wrapAdaptersAsSkillManager({ "test-skill": testAdapter }), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("stopRunByError") @@ -455,7 +471,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { const setting = createRunSetting({ maxRetries: 3 }) const checkpoint = createCheckpoint({ retryCount: 3 }) const step = createStep() - const skillManager = createMockSkillManager("test-skill", "mcp", "testTool") + const testAdapter = createMockAdapter("test-skill", "mcp", "testTool") mockLLMExecutor.setMockResult( createMockResult({ finishReason: "stop", @@ -468,7 +484,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: { "test-skill": skillManager }, + skillManager: wrapAdaptersAsSkillManager({ "test-skill": testAdapter }), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("stopRunByError") @@ -481,7 +497,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { const setting = createRunSetting({ maxRetries: 3 }) const checkpoint = createCheckpoint({ retryCount: 3 }) const step = createStep() - const skillManager = createMockSkillManager("test-skill", "mcp", "testTool") + const testAdapter = createMockAdapter("test-skill", "mcp", "testTool") mockLLMExecutor.setMockResult( createMockResult({ finishReason: "length", @@ -493,7 +509,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingToolCall']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: { "test-skill": skillManager }, + skillManager: wrapAdaptersAsSkillManager({ "test-skill": testAdapter }), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) expect(event.type).toBe("stopRunByError") diff --git a/packages/runtime/src/state-machine/states/generating-tool-call.ts b/packages/runtime/src/state-machine/states/generating-tool-call.ts index e1a6e81e..1f7fd8a7 100644 --- a/packages/runtime/src/state-machine/states/generating-tool-call.ts +++ b/packages/runtime/src/state-machine/states/generating-tool-call.ts @@ -11,12 +11,14 @@ import { type ToolCall, type ToolCallPart, } from "@perstack/core" +import type { SkillAdapter, SkillManager } from "@perstack/skill-manager" import { calculateContextWindowUsage } from "../../helpers/model.js" import { extractThinkingParts, extractThinkingText, type ReasoningPart, } from "../../helpers/thinking.js" +import { getToolSet } from "../../helpers/tool-set.js" import { createEmptyUsage, sumUsage, usageFromGenerateTextResult } from "../../helpers/usage.js" import type { StreamCallbacks } from "../../llm/types.js" import { @@ -25,38 +27,34 @@ import { createUserMessage, messageToCoreMessage, } from "../../messages/message.js" -import type { BaseSkillManager } from "../../skill-manager/index.js" -import { getSkillManagerByToolName, getToolSet } from "../../skill-manager/index.js" import type { RunSnapshot } from "../machine.js" type ClassifiedToolCall = { toolCallId: string toolName: string input: Record - skillManager: BaseSkillManager + adapter: SkillAdapter } -async function classifyToolCalls( +function classifyToolCalls( toolCalls: Array<{ toolCallId: string; toolName: string; input: unknown }>, - skillManagers: Record, -): Promise { - return Promise.all( - toolCalls.map(async (tc) => { - const skillManager = await getSkillManagerByToolName(skillManagers, tc.toolName) - return { - toolCallId: tc.toolCallId, - toolName: tc.toolName, - input: tc.input as Record, - skillManager, - } - }), - ) + skillManager: SkillManager, +): ClassifiedToolCall[] { + return toolCalls.map((tc) => { + const adapter = skillManager.getAdapterByToolName(tc.toolName) + return { + toolCallId: tc.toolCallId, + toolName: tc.toolName, + input: tc.input as Record, + adapter, + } + }) } function sortToolCallsByPriority(toolCalls: ClassifiedToolCall[]): ClassifiedToolCall[] { const priority = { mcp: 0, delegate: 1, interactive: 2 } return [...toolCalls].sort( - (a, b) => (priority[a.skillManager.type] ?? 99) - (priority[b.skillManager.type] ?? 99), + (a, b) => (priority[a.adapter.type] ?? 99) - (priority[b.adapter.type] ?? 99), ) } @@ -72,7 +70,7 @@ function buildToolCallParts(toolCalls: ClassifiedToolCall[]): Array ({ id: tc.toolCallId, - skillName: tc.skillManager.name, + skillName: tc.adapter.name, toolName: tc.toolName, args: tc.input, })) @@ -97,7 +95,7 @@ export async function generatingToolCallLogic({ setting, checkpoint, step, - skillManagers, + skillManager, eventListener, llmExecutor, }: RunSnapshot["context"]): Promise { @@ -131,7 +129,7 @@ export async function generatingToolCallLogic({ { messages: messages.map(messageToCoreMessage), maxRetries: setting.maxRetries, - tools: await getToolSet(skillManagers), + tools: getToolSet(skillManager), toolChoice: "auto", abortSignal: AbortSignal.timeout(setting.timeout), reasoningBudget: setting.reasoningBudget, @@ -250,7 +248,7 @@ export async function generatingToolCallLogic({ usage, }) } - const classified = await classifyToolCalls(toolCalls, skillManagers) + const classified = classifyToolCalls(toolCalls, skillManager) const sorted = sortToolCallsByPriority(classified) if (finishReason === "tool-calls" || finishReason === "stop") { const toolCallParts = buildToolCallParts(sorted) @@ -327,7 +325,7 @@ export async function generatingToolCallLogic({ toolCalls: [ { id: firstToolCall.toolCallId, - skillName: firstToolCall.skillManager.name, + skillName: firstToolCall.adapter.name, toolName: firstToolCall.toolName, args: firstToolCall.input, }, @@ -335,7 +333,7 @@ export async function generatingToolCallLogic({ toolResults: [ { id: firstToolCall.toolCallId, - skillName: firstToolCall.skillManager.name, + skillName: firstToolCall.adapter.name, toolName: firstToolCall.toolName, result: [{ type: "textPart", id: createId(), text: reason }], }, diff --git a/packages/runtime/src/state-machine/states/init.test.ts b/packages/runtime/src/state-machine/states/init.test.ts index 4a81e1c8..95f745cb 100644 --- a/packages/runtime/src/state-machine/states/init.test.ts +++ b/packages/runtime/src/state-machine/states/init.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest" -import { createCheckpoint, createRunSetting, createStep } from "../../../test/run-params.js" +import { + createCheckpoint, + createMockSkillManagerFromAdapters, + createRunSetting, + createStep, +} from "../../../test/run-params.js" import type { LLMExecutor } from "../../llm/index.js" import { createMockLLMExecutor } from "../../llm/index.js" import { StateMachineLogics } from "../index.js" @@ -21,7 +26,7 @@ describe("@perstack/runtime: StateMachineLogic['Init']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).resolves.toStrictEqual({ @@ -63,7 +68,7 @@ describe("@perstack/runtime: StateMachineLogic['Init']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).rejects.toThrow("Input message is undefined") @@ -89,7 +94,7 @@ describe("@perstack/runtime: StateMachineLogic['Init']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("resumeFromStop") @@ -114,7 +119,7 @@ describe("@perstack/runtime: StateMachineLogic['Init']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).rejects.toThrow("Interactive tool call result is undefined") @@ -140,7 +145,7 @@ describe("@perstack/runtime: StateMachineLogic['Init']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }) expect(event.type).toBe("resumeFromStop") @@ -165,7 +170,7 @@ describe("@perstack/runtime: StateMachineLogic['Init']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).rejects.toThrow("Interactive tool call result is undefined") @@ -185,7 +190,7 @@ describe("@perstack/runtime: StateMachineLogic['Init']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).resolves.toStrictEqual({ @@ -221,7 +226,7 @@ describe("@perstack/runtime: StateMachineLogic['Init']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).rejects.toThrow("Input message is undefined") @@ -241,7 +246,7 @@ describe("@perstack/runtime: StateMachineLogic['Init']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).resolves.toStrictEqual({ diff --git a/packages/runtime/src/state-machine/states/preparing-for-step.test.ts b/packages/runtime/src/state-machine/states/preparing-for-step.test.ts index f3b2f6f5..63f00b91 100644 --- a/packages/runtime/src/state-machine/states/preparing-for-step.test.ts +++ b/packages/runtime/src/state-machine/states/preparing-for-step.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest" -import { createCheckpoint, createRunSetting, createStep } from "../../../test/run-params.js" +import { + createCheckpoint, + createMockSkillManagerFromAdapters, + createRunSetting, + createStep, +} from "../../../test/run-params.js" import type { LLMExecutor } from "../../llm/index.js" import { createMockLLMExecutor } from "../../llm/index.js" import { StateMachineLogics } from "../index.js" @@ -17,7 +22,7 @@ describe("@perstack/runtime: StateMachineLogic['PreparingForStep']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).resolves.toStrictEqual({ diff --git a/packages/runtime/src/state-machine/states/resolving-tool-result.test.ts b/packages/runtime/src/state-machine/states/resolving-tool-result.test.ts index ba31b384..f01b24af 100644 --- a/packages/runtime/src/state-machine/states/resolving-tool-result.test.ts +++ b/packages/runtime/src/state-machine/states/resolving-tool-result.test.ts @@ -1,6 +1,11 @@ import { createId } from "@paralleldrive/cuid2" import { describe, expect, it } from "vitest" -import { createCheckpoint, createRunSetting, createStep } from "../../../test/run-params.js" +import { + createCheckpoint, + createMockSkillManagerFromAdapters, + createRunSetting, + createStep, +} from "../../../test/run-params.js" import { createEmptyUsage } from "../../helpers/usage.js" import type { LLMExecutor } from "../../llm/index.js" import { createMockLLMExecutor } from "../../llm/index.js" @@ -42,7 +47,7 @@ describe("@perstack/runtime: StateMachineLogic['ResolvingToolResult']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).resolves.toStrictEqual({ @@ -92,7 +97,7 @@ describe("@perstack/runtime: StateMachineLogic['ResolvingToolResult']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).rejects.toThrow( @@ -140,7 +145,7 @@ describe("@perstack/runtime: StateMachineLogic['ResolvingToolResult']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }) expect(result.type).toBe("finishToolCall") diff --git a/packages/runtime/src/state-machine/states/resuming-from-stop.test.ts b/packages/runtime/src/state-machine/states/resuming-from-stop.test.ts index 8ad40604..df4224db 100644 --- a/packages/runtime/src/state-machine/states/resuming-from-stop.test.ts +++ b/packages/runtime/src/state-machine/states/resuming-from-stop.test.ts @@ -1,6 +1,11 @@ import { createId } from "@paralleldrive/cuid2" import { describe, expect, it } from "vitest" -import { createCheckpoint, createRunSetting, createStep } from "../../../test/run-params.js" +import { + createCheckpoint, + createMockSkillManagerFromAdapters, + createRunSetting, + createStep, +} from "../../../test/run-params.js" import type { LLMExecutor } from "../../llm/index.js" import { createMockLLMExecutor } from "../../llm/index.js" import { StateMachineLogics } from "../index.js" @@ -23,7 +28,7 @@ describe("@perstack/runtime: StateMachineLogic['ResumingFromStop']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }) expect(result.type).toBe("proceedToInteractiveTools") @@ -50,7 +55,7 @@ describe("@perstack/runtime: StateMachineLogic['ResumingFromStop']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }) expect(result.type).toBe("resolveToolResults") @@ -71,7 +76,7 @@ describe("@perstack/runtime: StateMachineLogic['ResumingFromStop']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).rejects.toThrow("ResumingFromStop: tool handling state is undefined (invariant violation)") @@ -88,7 +93,7 @@ describe("@perstack/runtime: StateMachineLogic['ResumingFromStop']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }), ).rejects.toThrow("ResumingFromStop: tool handling state is undefined (invariant violation)") @@ -111,7 +116,7 @@ describe("@perstack/runtime: StateMachineLogic['ResumingFromStop']", () => { checkpoint, step, eventListener: async () => {}, - skillManagers: {}, + skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor, }) expect(result.type).toBe("resolveToolResults") diff --git a/packages/runtime/src/tool-execution/executor-factory.test.ts b/packages/runtime/src/tool-execution/executor-factory.test.ts index 64c1be51..80d328f7 100644 --- a/packages/runtime/src/tool-execution/executor-factory.test.ts +++ b/packages/runtime/src/tool-execution/executor-factory.test.ts @@ -1,27 +1,27 @@ import type { ToolCall } from "@perstack/core" +import type { SkillAdapter, SkillManager } from "@perstack/skill-manager" import { beforeEach, describe, expect, it, vi } from "vitest" -import type { BaseSkillManager } from "../skill-manager/base.js" import { ToolExecutorFactory, toolExecutorFactory } from "./executor-factory.js" -// Mock getSkillManagerByToolName -vi.mock("../skill-manager/helpers.js", () => ({ - getSkillManagerByToolName: vi.fn(), -})) - const createMockSkillManager = ( type: "mcp" | "delegate" | "interactive", callToolFn = vi.fn(), -): BaseSkillManager => - ({ +): SkillManager => { + const adapter = { type, name: "mock-skill", - lazyInit: false, - init: vi.fn(), - isInitialized: vi.fn().mockReturnValue(true), - getToolDefinitions: vi.fn().mockResolvedValue([]), callTool: callToolFn, - close: vi.fn(), - }) as unknown as BaseSkillManager + getToolDefinitions: () => [{ name: "any-tool", skillName: "mock-skill", inputSchema: {} }], + } as unknown as SkillAdapter + return { + getAdapterByToolName: () => adapter, + getToolDefinitions: () => adapter.getToolDefinitions(), + callTool: callToolFn, + close: async () => {}, + isClosed: false, + getAdapters: () => new Map([["mock-skill", adapter]]), + } as unknown as SkillManager +} describe("@perstack/runtime: executor-factory", () => { beforeEach(() => { @@ -55,14 +55,11 @@ describe("@perstack/runtime: executor-factory", () => { describe("execute()", () => { it("executes MCP tool calls successfully", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") const callToolFn = vi .fn() .mockResolvedValue([{ type: "textPart", id: "1", text: "result" }]) - const mcpManager = createMockSkillManager("mcp", callToolFn) - vi.mocked(getSkillManagerByToolName).mockResolvedValue(mcpManager) + const skillManager = createMockSkillManager("mcp", callToolFn) - const skillManagers: Record = {} const toolCall: ToolCall = { id: "call-1", skillName: "mcp1", @@ -71,7 +68,7 @@ describe("@perstack/runtime: executor-factory", () => { } const factory = new ToolExecutorFactory() - const result = await factory.execute(toolCall, "mcp", skillManagers) + const result = await factory.execute(toolCall, "mcp", skillManager) expect(result.id).toBe("call-1") expect(result.toolName).toBe("testTool") @@ -79,7 +76,7 @@ describe("@perstack/runtime: executor-factory", () => { }) it("throws error for unregistered skill type", async () => { - const skillManagers: Record = {} + const skillManager = createMockSkillManager("delegate") const toolCall: ToolCall = { id: "call-1", skillName: "delegate", @@ -88,13 +85,13 @@ describe("@perstack/runtime: executor-factory", () => { } const factory = new ToolExecutorFactory() - await expect(factory.execute(toolCall, "delegate", skillManagers)).rejects.toThrow( + await expect(factory.execute(toolCall, "delegate", skillManager)).rejects.toThrow( "No executor registered for skill type: delegate", ) }) it("throws error for interactive skill type", async () => { - const skillManagers: Record = {} + const skillManager = createMockSkillManager("interactive") const toolCall: ToolCall = { id: "call-1", skillName: "interactive", @@ -103,7 +100,7 @@ describe("@perstack/runtime: executor-factory", () => { } const factory = new ToolExecutorFactory() - await expect(factory.execute(toolCall, "interactive", skillManagers)).rejects.toThrow( + await expect(factory.execute(toolCall, "interactive", skillManager)).rejects.toThrow( "No executor registered for skill type: interactive", ) }) diff --git a/packages/runtime/src/tool-execution/executor-factory.ts b/packages/runtime/src/tool-execution/executor-factory.ts index ae4ff742..c319d240 100644 --- a/packages/runtime/src/tool-execution/executor-factory.ts +++ b/packages/runtime/src/tool-execution/executor-factory.ts @@ -1,5 +1,5 @@ import type { SkillType, ToolCall, ToolResult } from "@perstack/core" -import type { BaseSkillManager } from "../skill-manager/base.js" +import type { SkillManager } from "@perstack/skill-manager" import { McpToolExecutor } from "./mcp-executor.js" import type { ToolExecutor } from "./tool-executor.js" @@ -29,13 +29,13 @@ export class ToolExecutorFactory { async execute( toolCall: ToolCall, type: SkillType, - skillManagers: Record, + skillManager: SkillManager, ): Promise { const executor = this.executors.get(type) if (!executor) { throw new Error(`No executor registered for skill type: ${type}`) } - return executor.execute(toolCall, skillManagers) + return executor.execute(toolCall, skillManager) } /** diff --git a/packages/runtime/src/tool-execution/mcp-executor.test.ts b/packages/runtime/src/tool-execution/mcp-executor.test.ts index 0052f4c7..d6c1d4e0 100644 --- a/packages/runtime/src/tool-execution/mcp-executor.test.ts +++ b/packages/runtime/src/tool-execution/mcp-executor.test.ts @@ -1,27 +1,27 @@ import type { ToolCall } from "@perstack/core" +import type { SkillAdapter, SkillManager } from "@perstack/skill-manager" import { beforeEach, describe, expect, it, vi } from "vitest" -import type { BaseSkillManager } from "../skill-manager/base.js" import { McpToolExecutor } from "./mcp-executor.js" -// Mock getSkillManagerByToolName -vi.mock("../skill-manager/helpers.js", () => ({ - getSkillManagerByToolName: vi.fn(), -})) - const createMockSkillManager = ( type: "mcp" | "delegate" | "interactive", callToolFn = vi.fn(), -): BaseSkillManager => - ({ +): SkillManager => { + const adapter = { type, name: "mock-skill", - lazyInit: false, - init: vi.fn(), - isInitialized: vi.fn().mockReturnValue(true), - getToolDefinitions: vi.fn().mockResolvedValue([]), callTool: callToolFn, - close: vi.fn(), - }) as unknown as BaseSkillManager + getToolDefinitions: () => [{ name: "any-tool", skillName: "mock-skill", inputSchema: {} }], + } as unknown as SkillAdapter + return { + getAdapterByToolName: () => adapter, + getToolDefinitions: () => adapter.getToolDefinitions(), + callTool: callToolFn, + close: async () => {}, + isClosed: false, + getAdapters: () => new Map([["mock-skill", adapter]]), + } as unknown as SkillManager +} describe("@perstack/runtime: mcp-executor", () => { beforeEach(() => { @@ -35,14 +35,11 @@ describe("@perstack/runtime: mcp-executor", () => { }) it("executes a simple tool call and returns result", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") const callToolFn = vi .fn() .mockResolvedValue([{ type: "textPart", id: "1", text: "Hello, World!" }]) - const mcpManager = createMockSkillManager("mcp", callToolFn) - vi.mocked(getSkillManagerByToolName).mockResolvedValue(mcpManager) + const skillManager = createMockSkillManager("mcp", callToolFn) - const skillManagers: Record = {} const toolCall: ToolCall = { id: "call-1", skillName: "myMcp", @@ -51,7 +48,7 @@ describe("@perstack/runtime: mcp-executor", () => { } const executor = new McpToolExecutor() - const result = await executor.execute(toolCall, skillManagers) + const result = await executor.execute(toolCall, skillManager) expect(result.id).toBe("call-1") expect(result.skillName).toBe("myMcp") @@ -61,11 +58,8 @@ describe("@perstack/runtime: mcp-executor", () => { }) it("throws error if skill manager is not MCP type", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") - const delegateManager = createMockSkillManager("delegate") - vi.mocked(getSkillManagerByToolName).mockResolvedValue(delegateManager) + const skillManager = createMockSkillManager("delegate") - const skillManagers: Record = {} const toolCall: ToolCall = { id: "call-1", skillName: "delegate", @@ -74,13 +68,12 @@ describe("@perstack/runtime: mcp-executor", () => { } const executor = new McpToolExecutor() - await expect(executor.execute(toolCall, skillManagers)).rejects.toThrow( + await expect(executor.execute(toolCall, skillManager)).rejects.toThrow( "Incorrect SkillType, required MCP, got delegate", ) }) it("handles readImageFile tool with valid FileInfo", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") const fileInfo = JSON.stringify({ path: "/tmp/test.png", mimeType: "image/png", @@ -89,10 +82,8 @@ describe("@perstack/runtime: mcp-executor", () => { const callToolFn = vi .fn() .mockResolvedValue([{ type: "textPart", id: "part-1", text: fileInfo }]) - const mcpManager = createMockSkillManager("mcp", callToolFn) - vi.mocked(getSkillManagerByToolName).mockResolvedValue(mcpManager) + const skillManager = createMockSkillManager("mcp", callToolFn) - const skillManagers: Record = {} const toolCall: ToolCall = { id: "call-1", skillName: "files", @@ -103,7 +94,7 @@ describe("@perstack/runtime: mcp-executor", () => { const executor = new McpToolExecutor() // This will fail because the file doesn't exist, but we can test the error path - const result = await executor.execute(toolCall, skillManagers) + const result = await executor.execute(toolCall, skillManager) expect(result.id).toBe("call-1") expect(result.result).toHaveLength(1) @@ -112,7 +103,6 @@ describe("@perstack/runtime: mcp-executor", () => { }) it("handles readPdfFile tool with valid FileInfo", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") const fileInfo = JSON.stringify({ path: "/tmp/test.pdf", mimeType: "application/pdf", @@ -121,10 +111,8 @@ describe("@perstack/runtime: mcp-executor", () => { const callToolFn = vi .fn() .mockResolvedValue([{ type: "textPart", id: "part-1", text: fileInfo }]) - const mcpManager = createMockSkillManager("mcp", callToolFn) - vi.mocked(getSkillManagerByToolName).mockResolvedValue(mcpManager) + const skillManager = createMockSkillManager("mcp", callToolFn) - const skillManagers: Record = {} const toolCall: ToolCall = { id: "call-1", skillName: "files", @@ -133,7 +121,7 @@ describe("@perstack/runtime: mcp-executor", () => { } const executor = new McpToolExecutor() - const result = await executor.execute(toolCall, skillManagers) + const result = await executor.execute(toolCall, skillManager) expect(result.id).toBe("call-1") expect(result.result).toHaveLength(1) @@ -142,14 +130,11 @@ describe("@perstack/runtime: mcp-executor", () => { }) it("handles non-textPart results unchanged", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") const callToolFn = vi .fn() .mockResolvedValue([{ type: "imagePart", id: "img-1", data: "base64data" }]) - const mcpManager = createMockSkillManager("mcp", callToolFn) - vi.mocked(getSkillManagerByToolName).mockResolvedValue(mcpManager) + const skillManager = createMockSkillManager("mcp", callToolFn) - const skillManagers: Record = {} const toolCall: ToolCall = { id: "call-1", skillName: "files", @@ -158,21 +143,18 @@ describe("@perstack/runtime: mcp-executor", () => { } const executor = new McpToolExecutor() - const result = await executor.execute(toolCall, skillManagers) + const result = await executor.execute(toolCall, skillManager) expect(result.result).toHaveLength(1) expect(result.result[0].type).toBe("imagePart") }) it("handles non-JSON text parts for file tools", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") const callToolFn = vi .fn() .mockResolvedValue([{ type: "textPart", id: "part-1", text: "not valid json" }]) - const mcpManager = createMockSkillManager("mcp", callToolFn) - vi.mocked(getSkillManagerByToolName).mockResolvedValue(mcpManager) + const skillManager = createMockSkillManager("mcp", callToolFn) - const skillManagers: Record = {} const toolCall: ToolCall = { id: "call-1", skillName: "files", @@ -181,7 +163,7 @@ describe("@perstack/runtime: mcp-executor", () => { } const executor = new McpToolExecutor() - const result = await executor.execute(toolCall, skillManagers) + const result = await executor.execute(toolCall, skillManager) expect(result.result).toHaveLength(1) expect(result.result[0].type).toBe("textPart") @@ -189,14 +171,11 @@ describe("@perstack/runtime: mcp-executor", () => { }) it("handles JSON that is not FileInfo format", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") const callToolFn = vi .fn() .mockResolvedValue([{ type: "textPart", id: "part-1", text: '{"foo": "bar"}' }]) - const mcpManager = createMockSkillManager("mcp", callToolFn) - vi.mocked(getSkillManagerByToolName).mockResolvedValue(mcpManager) + const skillManager = createMockSkillManager("mcp", callToolFn) - const skillManagers: Record = {} const toolCall: ToolCall = { id: "call-1", skillName: "files", @@ -205,7 +184,7 @@ describe("@perstack/runtime: mcp-executor", () => { } const executor = new McpToolExecutor() - const result = await executor.execute(toolCall, skillManagers) + const result = await executor.execute(toolCall, skillManager) expect(result.result).toHaveLength(1) expect(result.result[0].type).toBe("textPart") diff --git a/packages/runtime/src/tool-execution/mcp-executor.ts b/packages/runtime/src/tool-execution/mcp-executor.ts index 66534106..1dbbed93 100644 --- a/packages/runtime/src/tool-execution/mcp-executor.ts +++ b/packages/runtime/src/tool-execution/mcp-executor.ts @@ -1,8 +1,6 @@ import { readFile } from "node:fs/promises" import type { MessagePart, SkillType, ToolCall, ToolResult } from "@perstack/core" -import type { BaseSkillManager } from "../skill-manager/base.js" -import { getSkillManagerByToolName } from "../skill-manager/helpers.js" -import type { McpSkillManager } from "../skill-manager/mcp.js" +import type { SkillManager } from "@perstack/skill-manager" import type { ToolExecutor } from "./tool-executor.js" type FileInfo = { path: string; mimeType: string; size: number } @@ -80,18 +78,12 @@ async function processFileToolResult( export class McpToolExecutor implements ToolExecutor { readonly type: SkillType = "mcp" - async execute( - toolCall: ToolCall, - skillManagers: Record, - ): Promise { - const skillManager = await getSkillManagerByToolName(skillManagers, toolCall.toolName) - if (skillManager.type !== "mcp") { - throw new Error(`Incorrect SkillType, required MCP, got ${skillManager.type}`) + async execute(toolCall: ToolCall, skillManager: SkillManager): Promise { + const adapter = skillManager.getAdapterByToolName(toolCall.toolName) + if (adapter.type !== "mcp") { + throw new Error(`Incorrect SkillType, required MCP, got ${adapter.type}`) } - const result = await (skillManager as McpSkillManager).callTool( - toolCall.toolName, - toolCall.args, - ) + const result = await adapter.callTool(toolCall.toolName, toolCall.args) const toolResult: ToolResult = { id: toolCall.id, skillName: toolCall.skillName, diff --git a/packages/runtime/src/tool-execution/tool-classifier.test.ts b/packages/runtime/src/tool-execution/tool-classifier.test.ts index f8a8e8fa..2fcdcc47 100644 --- a/packages/runtime/src/tool-execution/tool-classifier.test.ts +++ b/packages/runtime/src/tool-execution/tool-classifier.test.ts @@ -1,24 +1,33 @@ import type { SkillType, ToolCall } from "@perstack/core" +import type { SkillAdapter, SkillManager } from "@perstack/skill-manager" import { beforeEach, describe, expect, it, vi } from "vitest" -import type { BaseSkillManager } from "../skill-manager/base.js" import { classifyToolCalls, getToolTypeByName } from "./tool-classifier.js" -// Mock getSkillManagerByToolName -vi.mock("../skill-manager/helpers.js", () => ({ - getSkillManagerByToolName: vi.fn(), -})) - -const createMockSkillManager = (type: SkillType): BaseSkillManager => - ({ +function createMockAdapter(type: SkillType, toolName: string): SkillAdapter { + return { type, - name: "mock-skill", - lazyInit: false, - init: vi.fn(), - isInitialized: vi.fn().mockReturnValue(true), - getToolDefinitions: vi.fn().mockResolvedValue([]), + name: `${type}-skill`, callTool: vi.fn(), - close: vi.fn(), - }) as unknown as BaseSkillManager + getToolDefinitions: () => [{ name: toolName, skillName: `${type}-skill`, inputSchema: {} }], + } as unknown as SkillAdapter +} + +function createMockSkillManager(adapters: Record): SkillManager { + const adapterList = Object.values(adapters) + return { + getToolDefinitions: () => adapterList.flatMap((a) => a.getToolDefinitions()), + getAdapterByToolName: (toolName: string) => { + for (const adapter of adapterList) { + if (adapter.getToolDefinitions().some((t: { name: string }) => t.name === toolName)) + return adapter + } + throw new Error(`Tool "${toolName}" not found`) + }, + close: async () => {}, + isClosed: false, + getAdapters: () => new Map(adapterList.map((a) => [a.name, a])), + } as unknown as SkillManager +} describe("@perstack/runtime: tool-classifier", () => { beforeEach(() => { @@ -26,37 +35,27 @@ describe("@perstack/runtime: tool-classifier", () => { }) describe("getToolTypeByName()", () => { - it("returns the skill type for a tool found in skill managers", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") - const mcpManager = createMockSkillManager("mcp") - vi.mocked(getSkillManagerByToolName).mockResolvedValue(mcpManager) - - const skillManagers: Record = { - mcp1: mcpManager, - } + it("returns the skill type for a tool found in skill managers", () => { + const mcpAdapter = createMockAdapter("mcp", "readFile") + const skillManager = createMockSkillManager({ mcp1: mcpAdapter }) - const result = await getToolTypeByName("readFile", skillManagers) + const result = getToolTypeByName("readFile", skillManager) expect(result).toBe("mcp") - expect(getSkillManagerByToolName).toHaveBeenCalledWith(skillManagers, "readFile") }) - it("returns delegate type for delegate tool", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") - const delegateManager = createMockSkillManager("delegate") - vi.mocked(getSkillManagerByToolName).mockResolvedValue(delegateManager) + it("returns delegate type for delegate tool", () => { + const delegateAdapter = createMockAdapter("delegate", "delegateTo") + const skillManager = createMockSkillManager({ delegate1: delegateAdapter }) - const skillManagers: Record = {} - const result = await getToolTypeByName("delegateTo", skillManagers) + const result = getToolTypeByName("delegateTo", skillManager) expect(result).toBe("delegate") }) - it("returns interactive type for interactive tool", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") - const interactiveManager = createMockSkillManager("interactive") - vi.mocked(getSkillManagerByToolName).mockResolvedValue(interactiveManager) + it("returns interactive type for interactive tool", () => { + const interactiveAdapter = createMockAdapter("interactive", "askUser") + const skillManager = createMockSkillManager({ interactive1: interactiveAdapter }) - const skillManagers: Record = {} - const result = await getToolTypeByName("askUser", skillManagers) + const result = getToolTypeByName("askUser", skillManager) expect(result).toBe("interactive") }) }) @@ -69,29 +68,27 @@ describe("@perstack/runtime: tool-classifier", () => { args: {}, }) - it("classifies tool calls by their type", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") - const mcpManager = createMockSkillManager("mcp") - const delegateManager = createMockSkillManager("delegate") - const interactiveManager = createMockSkillManager("interactive") + it("classifies tool calls by their type", () => { + const mcpAdapter = createMockAdapter("mcp", "readFile") + const delegateAdapter = createMockAdapter("delegate", "delegateTo") + const interactiveAdapter = createMockAdapter("interactive", "askUser") + const skillManager = createMockSkillManager({ + mcp1: mcpAdapter, + delegate1: delegateAdapter, + interactive1: interactiveAdapter, + }) - vi.mocked(getSkillManagerByToolName) - .mockResolvedValueOnce(mcpManager) - .mockResolvedValueOnce(delegateManager) - .mockResolvedValueOnce(interactiveManager) - - const skillManagers: Record = {} const toolCalls: ToolCall[] = [ createToolCall("1", "readFile"), createToolCall("2", "delegateTo"), createToolCall("3", "askUser"), ] - const classified = await classifyToolCalls(toolCalls, skillManagers) + const classified = classifyToolCalls(toolCalls, skillManager) expect(classified.mcp).toHaveLength(1) expect(classified.mcp[0].toolCall.toolName).toBe("readFile") - expect(classified.mcp[0].skillManager).toBe(mcpManager) + expect(classified.mcp[0].adapter).toBe(mcpAdapter) expect(classified.delegate).toHaveLength(1) expect(classified.delegate[0].toolCall.toolName).toBe("delegateTo") @@ -100,66 +97,64 @@ describe("@perstack/runtime: tool-classifier", () => { expect(classified.interactive[0].toolCall.toolName).toBe("askUser") }) - it("handles multiple tool calls of the same type", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") - const mcpManager = createMockSkillManager("mcp") - - vi.mocked(getSkillManagerByToolName) - .mockResolvedValueOnce(mcpManager) - .mockResolvedValueOnce(mcpManager) - .mockResolvedValueOnce(mcpManager) + it("handles multiple tool calls of the same type", () => { + const mcpAdapter1 = createMockAdapter("mcp", "readFile") + const mcpAdapter2 = createMockAdapter("mcp", "writeFile") + // Override the name to avoid collision in the map + ;(mcpAdapter2 as { name: string }).name = "mcp-skill-2" + const mcpAdapter3 = createMockAdapter("mcp", "listDir") + ;(mcpAdapter3 as { name: string }).name = "mcp-skill-3" + const skillManager = createMockSkillManager({ + mcp1: mcpAdapter1, + mcp2: mcpAdapter2, + mcp3: mcpAdapter3, + }) - const skillManagers: Record = {} const toolCalls: ToolCall[] = [ createToolCall("1", "readFile"), createToolCall("2", "writeFile"), createToolCall("3", "listDir"), ] - const classified = await classifyToolCalls(toolCalls, skillManagers) + const classified = classifyToolCalls(toolCalls, skillManager) expect(classified.mcp).toHaveLength(3) expect(classified.delegate).toHaveLength(0) expect(classified.interactive).toHaveLength(0) }) - it("returns empty arrays when no tool calls", async () => { - const skillManagers: Record = {} - const classified = await classifyToolCalls([], skillManagers) + it("returns empty arrays when no tool calls", () => { + const skillManager = createMockSkillManager({}) + const classified = classifyToolCalls([], skillManager) expect(classified.mcp).toHaveLength(0) expect(classified.delegate).toHaveLength(0) expect(classified.interactive).toHaveLength(0) }) - it("includes skill manager reference for each classified tool call", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") - const mcpManager = createMockSkillManager("mcp") - const delegateManager = createMockSkillManager("delegate") + it("includes adapter reference for each classified tool call", () => { + const mcpAdapter = createMockAdapter("mcp", "readFile") + const delegateAdapter = createMockAdapter("delegate", "delegateTo") + const skillManager = createMockSkillManager({ + mcp1: mcpAdapter, + delegate1: delegateAdapter, + }) - vi.mocked(getSkillManagerByToolName) - .mockResolvedValueOnce(mcpManager) - .mockResolvedValueOnce(delegateManager) - - const skillManagers: Record = {} const toolCalls: ToolCall[] = [ createToolCall("1", "readFile"), createToolCall("2", "delegateTo"), ] - const classified = await classifyToolCalls(toolCalls, skillManagers) + const classified = classifyToolCalls(toolCalls, skillManager) - expect(classified.mcp[0].skillManager).toBe(mcpManager) - expect(classified.delegate[0].skillManager).toBe(delegateManager) + expect(classified.mcp[0].adapter).toBe(mcpAdapter) + expect(classified.delegate[0].adapter).toBe(delegateAdapter) }) - it("preserves tool call data in classified results", async () => { - const { getSkillManagerByToolName } = await import("../skill-manager/helpers.js") - const mcpManager = createMockSkillManager("mcp") - - vi.mocked(getSkillManagerByToolName).mockResolvedValue(mcpManager) + it("preserves tool call data in classified results", () => { + const mcpAdapter = createMockAdapter("mcp", "customTool") + const skillManager = createMockSkillManager({ mcp1: mcpAdapter }) - const skillManagers: Record = {} const toolCall: ToolCall = { id: "tc-123", skillName: "fs-skill", @@ -167,7 +162,7 @@ describe("@perstack/runtime: tool-classifier", () => { args: { foo: "bar", count: 42 }, } - const classified = await classifyToolCalls([toolCall], skillManagers) + const classified = classifyToolCalls([toolCall], skillManager) expect(classified.mcp[0].toolCall).toBe(toolCall) expect(classified.mcp[0].toolCall.id).toBe("tc-123") diff --git a/packages/runtime/src/tool-execution/tool-classifier.ts b/packages/runtime/src/tool-execution/tool-classifier.ts index 40b5f31b..27fb71d2 100644 --- a/packages/runtime/src/tool-execution/tool-classifier.ts +++ b/packages/runtime/src/tool-execution/tool-classifier.ts @@ -1,11 +1,10 @@ import type { SkillType, ToolCall } from "@perstack/core" -import type { BaseSkillManager } from "../skill-manager/base.js" -import { getSkillManagerByToolName } from "../skill-manager/helpers.js" +import type { SkillAdapter, SkillManager } from "@perstack/skill-manager" export type ClassifiedToolCall = { toolCall: ToolCall type: SkillType - skillManager: BaseSkillManager + adapter: SkillAdapter } export type ClassifiedToolCalls = { @@ -17,36 +16,27 @@ export type ClassifiedToolCalls = { /** * Get the skill type for a tool by name only */ -export async function getToolTypeByName( - toolName: string, - skillManagers: Record, -): Promise { - const skillManager = await getSkillManagerByToolName(skillManagers, toolName) - return skillManager.type +export function getToolTypeByName(toolName: string, skillManager: SkillManager): SkillType { + const adapter = skillManager.getAdapterByToolName(toolName) + return adapter.type } /** * Classify multiple tool calls by their skill type */ -export async function classifyToolCalls( +export function classifyToolCalls( toolCalls: ToolCall[], - skillManagers: Record, -): Promise { + skillManager: SkillManager, +): ClassifiedToolCalls { const classified: ClassifiedToolCalls = { mcp: [], delegate: [], interactive: [], } - const results = await Promise.all( - toolCalls.map(async (toolCall) => { - const skillManager = await getSkillManagerByToolName(skillManagers, toolCall.toolName) - return { toolCall, type: skillManager.type, skillManager } - }), - ) - - for (const result of results) { - classified[result.type].push(result) + for (const toolCall of toolCalls) { + const adapter = skillManager.getAdapterByToolName(toolCall.toolName) + classified[adapter.type].push({ toolCall, type: adapter.type, adapter }) } return classified diff --git a/packages/runtime/src/tool-execution/tool-executor.ts b/packages/runtime/src/tool-execution/tool-executor.ts index 73f68be6..540c8acf 100644 --- a/packages/runtime/src/tool-execution/tool-executor.ts +++ b/packages/runtime/src/tool-execution/tool-executor.ts @@ -1,5 +1,5 @@ import type { SkillType, ToolCall, ToolResult } from "@perstack/core" -import type { BaseSkillManager } from "../skill-manager/base.js" +import type { SkillManager } from "@perstack/skill-manager" /** * Interface for tool executors following the Strategy pattern. @@ -11,5 +11,5 @@ export interface ToolExecutor { /** * Execute a tool call and return the result */ - execute(toolCall: ToolCall, skillManagers: Record): Promise + execute(toolCall: ToolCall, skillManager: SkillManager): Promise } diff --git a/packages/runtime/test/run-params.ts b/packages/runtime/test/run-params.ts index ad154bea..3caeaacf 100644 --- a/packages/runtime/test/run-params.ts +++ b/packages/runtime/test/run-params.ts @@ -7,13 +7,14 @@ import { type RunSetting, type RuntimeEvent, runParamsSchema, + type SkillType, type Step, stepSchema, } from "@perstack/core" +import type { SkillAdapter, SkillManager } from "@perstack/skill-manager" import { createEmptyUsage } from "../src/helpers/usage.js" import type { LLMExecutor } from "../src/llm/index.js" import { createMockLLMExecutor } from "../src/llm/index.js" -import type { BaseSkillManager } from "../src/skill-manager/index.js" import type { RunSnapshot } from "../src/state-machine/machine.js" export function createRunSetting(overrides: Partial = {}): RunSetting { @@ -93,12 +94,77 @@ export function createStep(overrides: Partial = {}): Step { }) } +/** + * Create a mock SkillAdapter from test data. + */ +export type MockAdapterDef = { + name: string + type: SkillType + expert?: { key: string; name: string; version: string } + tools?: Array<{ + name: string + skillName: string + inputSchema?: Record + interactive?: boolean + }> + callTool?: (toolName: string, input: Record) => Promise +} + +/** + * Create a mock SkillManager from a record of adapter-like definitions. + * This converts old Record test patterns to the new SkillManager API. + */ +export function createMockSkillManagerFromAdapters( + adapterDefs: Record, +): SkillManager { + const adapters = Object.values(adapterDefs) + + return { + getToolDefinitions: () => + adapters.flatMap( + (a) => + a.tools?.map((t) => ({ + name: t.name, + skillName: t.skillName, + inputSchema: t.inputSchema ?? {}, + interactive: t.interactive ?? false, + })) ?? [], + ), + getAdapterByToolName: (toolName: string) => { + for (const adapter of adapters) { + if (adapter.tools?.some((t) => t.name === toolName)) { + return adapter as unknown as SkillAdapter + } + } + throw new Error(`Tool "${toolName}" not found`) + }, + callTool: async (toolName: string, input: Record) => { + for (const adapter of adapters) { + if (adapter.tools?.some((t) => t.name === toolName)) { + if (adapter.callTool) { + return adapter.callTool(toolName, input) + } + return [] + } + } + throw new Error(`Tool "${toolName}" not found`) + }, + close: async () => {}, + isClosed: false, + getAdapters: () => new Map(adapters.map((a) => [a.name, a as unknown as SkillAdapter])), + } as unknown as SkillManager +} + +function createEmptyMockSkillManager(): SkillManager { + return createMockSkillManagerFromAdapters({}) +} + export interface CreateTestContextOptions { setting?: Partial checkpoint?: Partial step?: Partial eventListener?: (event: RunEvent | RuntimeEvent) => Promise - skillManagers?: Record + skillManager?: SkillManager llmExecutor?: LLMExecutor } @@ -108,7 +174,7 @@ export function createTestContext(options: CreateTestContextOptions = {}): RunSn checkpoint: createCheckpoint(options.checkpoint), step: createStep(options.step), eventListener: options.eventListener ?? (async () => {}), - skillManagers: options.skillManagers ?? {}, + skillManager: options.skillManager ?? createEmptyMockSkillManager(), llmExecutor: options.llmExecutor ?? (createMockLLMExecutor() as unknown as LLMExecutor), } } diff --git a/packages/skill-manager/package.json b/packages/skill-manager/package.json new file mode 100644 index 00000000..7e5a5dc1 --- /dev/null +++ b/packages/skill-manager/package.json @@ -0,0 +1,44 @@ +{ + "name": "@perstack/skill-manager", + "version": "0.0.1", + "description": "Perstack Skill Manager", + "author": "Wintermute Technologies, Inc.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "pnpm run clean && tsup --config ../../tsup.config.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "@paralleldrive/cuid2": "^3.3.0", + "@perstack/base": "workspace:*", + "@perstack/core": "workspace:*" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.5", + "@types/node": "^25.2.3", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/packages/skill-manager/src/adapters/delegate-adapter.test.ts b/packages/skill-manager/src/adapters/delegate-adapter.test.ts new file mode 100644 index 00000000..e25ef679 --- /dev/null +++ b/packages/skill-manager/src/adapters/delegate-adapter.test.ts @@ -0,0 +1,130 @@ +import type { Expert } from "@perstack/core" +import { describe, expect, it } from "vitest" +import { DelegateSkillAdapter } from "./delegate-adapter.js" + +function createExpert(overrides: Partial = {}): Expert { + return { + key: "test-expert", + name: "@test/expert", + version: "1.0.0", + description: "Test expert description", + instruction: "Test instruction", + skills: {}, + delegates: [], + tags: [], + minRuntimeVersion: "v1.0", + ...overrides, + } +} + +describe("@perstack/skill-manager: DelegateSkillAdapter", () => { + describe("state transitions", () => { + it("starts in idle state", () => { + const adapter = new DelegateSkillAdapter(createExpert()) + expect(adapter.state).toBe("idle") + }) + + it("transitions to ready after connect", async () => { + const adapter = new DelegateSkillAdapter(createExpert()) + await adapter.connect() + expect(adapter.state).toBe("ready") + }) + + it("transitions to closed after disconnect", async () => { + const adapter = new DelegateSkillAdapter(createExpert()) + await adapter.connect() + await adapter.disconnect() + expect(adapter.state).toBe("closed") + }) + + it("throws on double connect", async () => { + const adapter = new DelegateSkillAdapter(createExpert()) + await adapter.connect() + await expect(adapter.connect()).rejects.toThrow("already connected") + }) + + it("disconnect is idempotent", async () => { + const adapter = new DelegateSkillAdapter(createExpert()) + await adapter.connect() + await adapter.disconnect() + await adapter.disconnect() + expect(adapter.state).toBe("closed") + }) + }) + + describe("tool definitions", () => { + it("throws getToolDefinitions when not ready", () => { + const adapter = new DelegateSkillAdapter(createExpert()) + expect(() => adapter.getToolDefinitions()).toThrow("not ready") + }) + + it("creates a single tool from expert", async () => { + const adapter = new DelegateSkillAdapter(createExpert()) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools).toHaveLength(1) + expect(tools[0].name).toBe("expert") + expect(tools[0].description).toBe("Test expert description") + expect(tools[0].interactive).toBe(false) + }) + + it("uses last segment of expert name as tool name", async () => { + const adapter = new DelegateSkillAdapter(createExpert({ name: "@org/code-reviewer" })) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools[0].name).toBe("code-reviewer") + }) + + it("uses full name when no slash present", async () => { + const adapter = new DelegateSkillAdapter(createExpert({ name: "simple-expert" })) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools[0].name).toBe("simple-expert") + }) + + it("has correct input schema with query parameter", async () => { + const adapter = new DelegateSkillAdapter(createExpert()) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools[0].inputSchema).toEqual({ + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }) + }) + }) + + describe("callTool", () => { + it("returns empty array", async () => { + const adapter = new DelegateSkillAdapter(createExpert()) + await adapter.connect() + const result = await adapter.callTool("expert", { query: "test" }) + expect(result).toEqual([]) + }) + }) + + describe("properties", () => { + it("has correct name and type", () => { + const adapter = new DelegateSkillAdapter(createExpert()) + expect(adapter.name).toBe("@test/expert") + expect(adapter.type).toBe("delegate") + }) + + it("exposes expert", () => { + const expert = createExpert() + const adapter = new DelegateSkillAdapter(expert) + expect(adapter.expert).toBe(expert) + }) + }) + + describe("lifecycle events", () => { + it("emits connecting and connected events", async () => { + const events: Array<{ type: string }> = [] + const adapter = new DelegateSkillAdapter(createExpert(), (event) => events.push(event)) + await adapter.connect() + expect(events.map((e) => e.type)).toEqual(["connecting", "connected"]) + }) + }) +}) diff --git a/packages/runtime/src/skill-manager/delegate.ts b/packages/skill-manager/src/adapters/delegate-adapter.ts similarity index 52% rename from packages/runtime/src/skill-manager/delegate.ts rename to packages/skill-manager/src/adapters/delegate-adapter.ts index a8ebffe0..dc054c2b 100644 --- a/packages/runtime/src/skill-manager/delegate.ts +++ b/packages/skill-manager/src/adapters/delegate-adapter.ts @@ -1,32 +1,19 @@ -import type { - Expert, - FileInlinePart, - ImageInlinePart, - RunEvent, - RuntimeEvent, - SkillType, - TextPart, -} from "@perstack/core" -import { BaseSkillManager } from "./base.js" +import type { Expert, SkillType } from "@perstack/core" +import { SkillAdapter } from "../skill-adapter.js" +import type { SkillAdapterLifecycleEvent, ToolCallResult } from "../types.js" -export class DelegateSkillManager extends BaseSkillManager { +export class DelegateSkillAdapter extends SkillAdapter { readonly name: string readonly type: SkillType = "delegate" - readonly lazyInit = false override readonly expert: Expert - constructor( - expert: Expert, - jobId: string, - runId: string, - eventListener?: (event: RunEvent | RuntimeEvent) => void, - ) { - super(jobId, runId, eventListener) + constructor(expert: Expert, onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void) { + super(onLifecycleEvent) this.name = expert.name this.expert = expert } - protected override async _doInit(): Promise { + protected override async _doConnect(): Promise { this._toolDefinitions = [ { skillName: this.expert.name, @@ -44,12 +31,12 @@ export class DelegateSkillManager extends BaseSkillManager { ] } - override async close(): Promise {} + protected override async _doDisconnect(): Promise {} override async callTool( _toolName: string, _input: Record, - ): Promise> { + ): Promise { return [] } } diff --git a/packages/skill-manager/src/adapters/in-memory-base-adapter.test.ts b/packages/skill-manager/src/adapters/in-memory-base-adapter.test.ts new file mode 100644 index 00000000..c5fcbcfd --- /dev/null +++ b/packages/skill-manager/src/adapters/in-memory-base-adapter.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from "vitest" +import type { TransportFactory } from "../transport-factory.js" +import { InMemoryBaseSkillAdapter } from "./in-memory-base-adapter.js" + +vi.mock("@modelcontextprotocol/sdk/client/index.js", () => { + const mockClient = vi.fn() + mockClient.prototype.connect = vi.fn().mockResolvedValue(undefined) + mockClient.prototype.listTools = vi.fn().mockResolvedValue({ + tools: [ + { + name: "read_file", + description: "Read a text file", + inputSchema: { type: "object" }, + }, + { + name: "write_file", + description: "Write a text file", + inputSchema: { type: "object" }, + }, + ], + }) + mockClient.prototype.callTool = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "file content" }], + }) + mockClient.prototype.close = vi.fn().mockResolvedValue(undefined) + return { Client: mockClient } +}) + +vi.mock("@perstack/base", () => ({ + BASE_SKILL_NAME: "@perstack/base", + BASE_SKILL_VERSION: "0.0.1", + createBaseServer: vi.fn().mockReturnValue({ + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }), +})) + +function createBaseSkill() { + return { + type: "mcpStdioSkill" as const, + name: "@perstack/base", + command: "npx", + packageName: "@perstack/base", + args: [], + requiredEnv: [], + pick: [], + omit: [], + lazyInit: false, + } +} + +function createMockTransportFactory(): TransportFactory { + const mockTransport = { + start: vi.fn(), + close: vi.fn(), + } + return { + createStdio: vi.fn(), + createSse: vi.fn(), + createInMemoryPair: vi.fn().mockReturnValue([mockTransport, mockTransport]), + } +} + +describe("@perstack/skill-manager: InMemoryBaseSkillAdapter", () => { + describe("state transitions", () => { + it("starts in idle state", () => { + const adapter = new InMemoryBaseSkillAdapter(createBaseSkill()) + expect(adapter.state).toBe("idle") + }) + + it("transitions to ready after connect", async () => { + const adapter = new InMemoryBaseSkillAdapter(createBaseSkill(), undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + expect(adapter.state).toBe("ready") + }) + + it("transitions to closed after disconnect", async () => { + const adapter = new InMemoryBaseSkillAdapter(createBaseSkill(), undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + await adapter.disconnect() + expect(adapter.state).toBe("closed") + }) + + it("throws on double connect", async () => { + const adapter = new InMemoryBaseSkillAdapter(createBaseSkill(), undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + await expect(adapter.connect()).rejects.toThrow("already connected") + }) + + it("disconnect is idempotent", async () => { + const adapter = new InMemoryBaseSkillAdapter(createBaseSkill(), undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + await adapter.disconnect() + await adapter.disconnect() + expect(adapter.state).toBe("closed") + }) + }) + + describe("tool definitions", () => { + it("throws getToolDefinitions when not ready", () => { + const adapter = new InMemoryBaseSkillAdapter(createBaseSkill()) + expect(() => adapter.getToolDefinitions()).toThrow("not ready") + }) + + it("returns tool definitions after connect", async () => { + const adapter = new InMemoryBaseSkillAdapter(createBaseSkill(), undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools).toHaveLength(2) + expect(tools[0].name).toBe("read_file") + expect(tools[0].skillName).toBe("@perstack/base") + }) + + it("applies pick filter", async () => { + const skill = { ...createBaseSkill(), pick: ["read_file"] } + const adapter = new InMemoryBaseSkillAdapter(skill, undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools).toHaveLength(1) + expect(tools[0].name).toBe("read_file") + }) + + it("applies omit filter", async () => { + const skill = { ...createBaseSkill(), omit: ["write_file"] } + const adapter = new InMemoryBaseSkillAdapter(skill, undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools).toHaveLength(1) + expect(tools[0].name).toBe("read_file") + }) + }) + + describe("callTool", () => { + it("calls tool and returns results", async () => { + const adapter = new InMemoryBaseSkillAdapter(createBaseSkill(), undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + const result = await adapter.callTool("read_file", { path: "test.txt" }) + expect(result).toHaveLength(1) + expect(result[0].type).toBe("textPart") + }) + + it("throws when not ready", async () => { + const adapter = new InMemoryBaseSkillAdapter(createBaseSkill()) + await expect(adapter.callTool("read_file", {})).rejects.toThrow("not ready") + }) + }) + + describe("properties", () => { + it("has correct name and type", () => { + const adapter = new InMemoryBaseSkillAdapter(createBaseSkill()) + expect(adapter.name).toBe("@perstack/base") + expect(adapter.type).toBe("mcp") + }) + + it("exposes skill", () => { + const skill = createBaseSkill() + const adapter = new InMemoryBaseSkillAdapter(skill) + expect(adapter.skill).toBe(skill) + }) + }) + + describe("lifecycle events", () => { + it("emits connecting and connected events", async () => { + const events: Array<{ type: string }> = [] + const adapter = new InMemoryBaseSkillAdapter( + createBaseSkill(), + (event) => events.push(event), + { transportFactory: createMockTransportFactory() }, + ) + await adapter.connect() + expect(events.map((e) => e.type)).toEqual(["connecting", "connected"]) + }) + + it("emits disconnected event", async () => { + const events: Array<{ type: string }> = [] + const adapter = new InMemoryBaseSkillAdapter( + createBaseSkill(), + (event) => events.push(event), + { transportFactory: createMockTransportFactory() }, + ) + await adapter.connect() + events.length = 0 + await adapter.disconnect() + expect(events.map((e) => e.type)).toEqual(["disconnected"]) + }) + }) +}) diff --git a/packages/runtime/src/skill-manager/in-memory-base.ts b/packages/skill-manager/src/adapters/in-memory-base-adapter.ts similarity index 51% rename from packages/runtime/src/skill-manager/in-memory-base.ts rename to packages/skill-manager/src/adapters/in-memory-base-adapter.ts index 1f395629..3265bf99 100644 --- a/packages/runtime/src/skill-manager/in-memory-base.ts +++ b/packages/skill-manager/src/adapters/in-memory-base-adapter.ts @@ -1,67 +1,62 @@ import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js" import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { type CallToolResult, McpError } from "@modelcontextprotocol/sdk/types.js" -import { BASE_SKILL_NAME, BASE_SKILL_VERSION, createBaseServer } from "@perstack/base" -import { - createRuntimeEvent, - type FileInlinePart, - type ImageInlinePart, - type McpStdioSkill, - type RunEvent, - type RuntimeEvent, - type SkillType, - type TextPart, - type ToolDefinition, -} from "@perstack/core" -import { BaseSkillManager } from "./base.js" -import { convertToolResult, handleToolError } from "./mcp-converters.js" -import { defaultTransportFactory, type TransportFactory } from "./transport-factory.js" +import { BASE_SKILL_NAME, createBaseServer, type SkillManagementCallbacks } from "@perstack/base" +import type { McpStdioSkill, SkillType, ToolDefinition } from "@perstack/core" +import { SkillAdapter } from "../skill-adapter.js" +import type { TransportFactory } from "../transport-factory.js" +import { defaultTransportFactory } from "../transport-factory.js" +import type { SkillAdapterLifecycleEvent, ToolCallResult } from "../types.js" +import { convertToolResult, handleToolError } from "../utils/mcp-converters.js" -export interface InMemoryBaseSkillManagerOptions { +export interface InMemoryBaseSkillAdapterOptions { transportFactory?: TransportFactory } /** - * Skill manager for bundled @perstack/base using InMemoryTransport. + * Skill adapter for bundled @perstack/base using InMemoryTransport. * Runs the base skill in-process for near-zero initialization latency. */ -export class InMemoryBaseSkillManager extends BaseSkillManager { +export class InMemoryBaseSkillAdapter extends SkillAdapter { readonly name = BASE_SKILL_NAME readonly type: SkillType = "mcp" - readonly lazyInit = false override readonly skill: McpStdioSkill private _mcpServer?: McpServer private _mcpClient?: McpClient private _transportFactory: TransportFactory + private _skillManagement: SkillManagementCallbacks = { + addSkill: () => { + throw new Error("Skill management not initialized") + }, + removeSkill: () => { + throw new Error("Skill management not initialized") + }, + addDelegate: () => { + throw new Error("Skill management not initialized") + }, + removeDelegate: () => { + throw new Error("Skill management not initialized") + }, + } constructor( skill: McpStdioSkill, - jobId: string, - runId: string, - eventListener?: (event: RunEvent | RuntimeEvent) => void, - options?: InMemoryBaseSkillManagerOptions, + onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void, + options?: InMemoryBaseSkillAdapterOptions, ) { - super(jobId, runId, eventListener) + super(onLifecycleEvent) this.skill = skill this._transportFactory = options?.transportFactory ?? defaultTransportFactory } - protected override _filterTools(tools: ToolDefinition[]): ToolDefinition[] { - const omit = this.skill.omit ?? [] - const pick = this.skill.pick ?? [] - return tools - .filter((tool) => (omit.length > 0 ? !omit.includes(tool.name) : true)) - .filter((tool) => (pick.length > 0 ? pick.includes(tool.name) : true)) - } - - protected override async _doInit(): Promise { - const startTime = Date.now() + protected override async _doConnect(): Promise { + this._spawnDurationMs = 0 // No process spawn for in-memory // Create linked transport pair const [clientTransport, serverTransport] = this._transportFactory.createInMemoryPair() // Create and connect the base server - this._mcpServer = createBaseServer() + this._mcpServer = createBaseServer({ skillManagement: this._skillManagement }) await this._mcpServer.connect(serverTransport) // Create and connect the client @@ -69,16 +64,10 @@ export class InMemoryBaseSkillManager extends BaseSkillManager { name: `${BASE_SKILL_NAME}-in-memory-client`, version: "1.0.0", }) - - const handshakeStartTime = Date.now() await this._mcpClient.connect(clientTransport) - const handshakeDurationMs = Date.now() - handshakeStartTime // Discover tools - const toolDiscoveryStartTime = Date.now() const { tools } = await this._mcpClient.listTools() - const toolDiscoveryDurationMs = Date.now() - toolDiscoveryStartTime - this._toolDefinitions = tools.map((tool) => ({ skillName: BASE_SKILL_NAME, name: tool.name, @@ -86,44 +75,35 @@ export class InMemoryBaseSkillManager extends BaseSkillManager { inputSchema: tool.inputSchema, interactive: false, })) - - // Emit connected event - if (this._eventListener) { - const totalDurationMs = Date.now() - startTime - const event = createRuntimeEvent("skillConnected", this._jobId, this._runId, { - skillName: BASE_SKILL_NAME, - serverInfo: { name: BASE_SKILL_NAME, version: BASE_SKILL_VERSION }, - spawnDurationMs: 0, // No process spawn for in-memory - handshakeDurationMs, - toolDiscoveryDurationMs, - connectDurationMs: handshakeDurationMs, - totalDurationMs, - }) - this._eventListener(event) - } } - override async close(): Promise { + protected override async _doDisconnect(): Promise { if (this._mcpClient) { await this._mcpClient.close() } if (this._mcpServer) { await this._mcpServer.close() } - if (this._eventListener && (this._mcpClient || this._mcpServer)) { - const event = createRuntimeEvent("skillDisconnected", this._jobId, this._runId, { - skillName: BASE_SKILL_NAME, - }) - this._eventListener(event) - } + } + + protected override _filterTools(tools: ToolDefinition[]): ToolDefinition[] { + const omit = this.skill.omit ?? [] + const pick = this.skill.pick ?? [] + return tools + .filter((tool) => (omit.length > 0 ? !omit.includes(tool.name) : true)) + .filter((tool) => (pick.length > 0 ? pick.includes(tool.name) : true)) + } + + bindSkillManagement(callbacks: SkillManagementCallbacks): void { + Object.assign(this._skillManagement, callbacks) } override async callTool( toolName: string, input: Record, - ): Promise> { - if (!this.isInitialized() || !this._mcpClient) { - throw new Error(`${this.name} is not initialized`) + ): Promise { + if (this.state !== "ready" || !this._mcpClient) { + throw new Error(`${this.name} is not ready`) } try { const result = (await this._mcpClient.callTool({ diff --git a/packages/skill-manager/src/adapters/interactive-adapter.test.ts b/packages/skill-manager/src/adapters/interactive-adapter.test.ts new file mode 100644 index 00000000..cea47ddb --- /dev/null +++ b/packages/skill-manager/src/adapters/interactive-adapter.test.ts @@ -0,0 +1,130 @@ +import type { InteractiveSkill } from "@perstack/core" +import { describe, expect, it } from "vitest" +import { InteractiveSkillAdapter } from "./interactive-adapter.js" + +function createInteractiveSkill(overrides: Partial = {}): InteractiveSkill { + return { + type: "interactiveSkill", + name: "test-interactive", + description: "Test interactive skill", + tools: { + "ask-user": { + name: "ask-user", + description: "Ask the user a question", + inputJsonSchema: JSON.stringify({ + type: "object", + properties: { question: { type: "string" } }, + required: ["question"], + }), + }, + "show-form": { + name: "show-form", + description: "Show a form to the user", + inputJsonSchema: JSON.stringify({ + type: "object", + properties: { fields: { type: "array" } }, + }), + }, + }, + ...overrides, + } +} + +describe("@perstack/skill-manager: InteractiveSkillAdapter", () => { + describe("state transitions", () => { + it("starts in idle state", () => { + const adapter = new InteractiveSkillAdapter(createInteractiveSkill()) + expect(adapter.state).toBe("idle") + }) + + it("transitions to ready after connect", async () => { + const adapter = new InteractiveSkillAdapter(createInteractiveSkill()) + await adapter.connect() + expect(adapter.state).toBe("ready") + }) + + it("transitions to closed after disconnect", async () => { + const adapter = new InteractiveSkillAdapter(createInteractiveSkill()) + await adapter.connect() + await adapter.disconnect() + expect(adapter.state).toBe("closed") + }) + + it("throws on double connect", async () => { + const adapter = new InteractiveSkillAdapter(createInteractiveSkill()) + await adapter.connect() + await expect(adapter.connect()).rejects.toThrow("already connected") + }) + + it("disconnect is idempotent", async () => { + const adapter = new InteractiveSkillAdapter(createInteractiveSkill()) + await adapter.connect() + await adapter.disconnect() + await adapter.disconnect() + expect(adapter.state).toBe("closed") + }) + }) + + describe("tool definitions", () => { + it("throws getToolDefinitions when not ready", () => { + const adapter = new InteractiveSkillAdapter(createInteractiveSkill()) + expect(() => adapter.getToolDefinitions()).toThrow("not ready") + }) + + it("converts interactive tools to tool definitions", async () => { + const adapter = new InteractiveSkillAdapter(createInteractiveSkill()) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools).toHaveLength(2) + expect(tools[0].name).toBe("ask-user") + expect(tools[0].interactive).toBe(true) + expect(tools[0].skillName).toBe("test-interactive") + expect(tools[1].name).toBe("show-form") + }) + + it("parses inputJsonSchema correctly", async () => { + const adapter = new InteractiveSkillAdapter(createInteractiveSkill()) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools[0].inputSchema).toEqual({ + type: "object", + properties: { question: { type: "string" } }, + required: ["question"], + }) + }) + }) + + describe("callTool", () => { + it("returns empty array", async () => { + const adapter = new InteractiveSkillAdapter(createInteractiveSkill()) + await adapter.connect() + const result = await adapter.callTool("ask-user", { question: "test?" }) + expect(result).toEqual([]) + }) + }) + + describe("properties", () => { + it("has correct name and type", () => { + const adapter = new InteractiveSkillAdapter(createInteractiveSkill()) + expect(adapter.name).toBe("test-interactive") + expect(adapter.type).toBe("interactive") + }) + + it("exposes interactiveSkill", () => { + const skill = createInteractiveSkill() + const adapter = new InteractiveSkillAdapter(skill) + expect(adapter.interactiveSkill).toBe(skill) + }) + }) + + describe("lifecycle events", () => { + it("emits connecting and connected events", async () => { + const events: Array<{ type: string }> = [] + const adapter = new InteractiveSkillAdapter(createInteractiveSkill(), (event) => + events.push(event), + ) + await adapter.connect() + expect(events.map((e) => e.type)).toEqual(["connecting", "connected"]) + }) + }) +}) diff --git a/packages/runtime/src/skill-manager/interactive.ts b/packages/skill-manager/src/adapters/interactive-adapter.ts similarity index 53% rename from packages/runtime/src/skill-manager/interactive.ts rename to packages/skill-manager/src/adapters/interactive-adapter.ts index e229c93c..0af58a30 100644 --- a/packages/runtime/src/skill-manager/interactive.ts +++ b/packages/skill-manager/src/adapters/interactive-adapter.ts @@ -1,32 +1,22 @@ -import type { - FileInlinePart, - ImageInlinePart, - InteractiveSkill, - RunEvent, - RuntimeEvent, - SkillType, - TextPart, -} from "@perstack/core" -import { BaseSkillManager } from "./base.js" +import type { InteractiveSkill, SkillType } from "@perstack/core" +import { SkillAdapter } from "../skill-adapter.js" +import type { SkillAdapterLifecycleEvent, ToolCallResult } from "../types.js" -export class InteractiveSkillManager extends BaseSkillManager { +export class InteractiveSkillAdapter extends SkillAdapter { readonly name: string readonly type: SkillType = "interactive" - readonly lazyInit = false override readonly interactiveSkill: InteractiveSkill constructor( interactiveSkill: InteractiveSkill, - jobId: string, - runId: string, - eventListener?: (event: RunEvent | RuntimeEvent) => void, + onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void, ) { - super(jobId, runId, eventListener) + super(onLifecycleEvent) this.name = interactiveSkill.name this.interactiveSkill = interactiveSkill } - protected override async _doInit(): Promise { + protected override async _doConnect(): Promise { this._toolDefinitions = Object.values(this.interactiveSkill.tools).map((tool) => ({ skillName: this.interactiveSkill.name, name: tool.name, @@ -36,12 +26,12 @@ export class InteractiveSkillManager extends BaseSkillManager { })) } - override async close(): Promise {} + protected override async _doDisconnect(): Promise {} override async callTool( _toolName: string, _input: Record, - ): Promise> { + ): Promise { return [] } } diff --git a/packages/skill-manager/src/adapters/lockfile-adapter.test.ts b/packages/skill-manager/src/adapters/lockfile-adapter.test.ts new file mode 100644 index 00000000..8254fce4 --- /dev/null +++ b/packages/skill-manager/src/adapters/lockfile-adapter.test.ts @@ -0,0 +1,282 @@ +import type { LockfileToolDefinition, McpSseSkill, McpStdioSkill } from "@perstack/core" +import { describe, expect, it, vi } from "vitest" +import type { SkillAdapter } from "../skill-adapter.js" +import type { SkillAdapterFactory } from "../skill-adapter-factory.js" +import { LockfileSkillAdapter } from "./lockfile-adapter.js" + +function createMockSkill(overrides: Partial = {}): McpStdioSkill { + return { + type: "mcpStdioSkill", + name: "test-skill", + command: "npx", + packageName: "@example/pkg", + args: [], + requiredEnv: [], + pick: [], + omit: [], + lazyInit: false, + ...overrides, + } as McpStdioSkill +} + +function createMockToolDefinitions(): LockfileToolDefinition[] { + return [ + { + skillName: "test-skill", + name: "tool-a", + description: "Tool A", + inputSchema: { type: "object" }, + }, + { + skillName: "test-skill", + name: "tool-b", + description: "Tool B", + inputSchema: { type: "object" }, + }, + ] +} + +function createMockAdapter( + tools: Array<{ name: string; skillName: string; description?: string }> = [], +): SkillAdapter { + return { + name: "mock-adapter", + type: "mcp", + state: "ready", + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + getToolDefinitions: vi.fn().mockReturnValue( + tools.map((t) => ({ + ...t, + inputSchema: { type: "object" }, + interactive: false, + })), + ), + callTool: vi.fn().mockResolvedValue([{ type: "textPart", text: "real result", id: "test-id" }]), + } as unknown as SkillAdapter +} + +function createMockFactory(adapter?: SkillAdapter): SkillAdapterFactory { + const mockAdapter = adapter ?? createMockAdapter() + return { + createMcp: vi.fn().mockReturnValue(mockAdapter), + createInMemoryBase: vi.fn().mockReturnValue(mockAdapter), + createInteractive: vi.fn().mockReturnValue(mockAdapter), + createDelegate: vi.fn().mockReturnValue(mockAdapter), + } +} + +describe("@perstack/skill-manager: LockfileSkillAdapter", () => { + describe("state transitions", () => { + it("starts in idle state", () => { + const adapter = new LockfileSkillAdapter({ + skill: createMockSkill(), + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory: createMockFactory(), + }) + expect(adapter.state).toBe("idle") + }) + + it("transitions to ready after connect", async () => { + const adapter = new LockfileSkillAdapter({ + skill: createMockSkill(), + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory: createMockFactory(), + }) + await adapter.connect() + expect(adapter.state).toBe("ready") + }) + }) + + describe("tool definitions", () => { + it("returns cached tool definitions after connect", async () => { + const adapter = new LockfileSkillAdapter({ + skill: createMockSkill(), + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory: createMockFactory(), + }) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools).toHaveLength(2) + expect(tools[0].name).toBe("tool-a") + expect(tools[1].name).toBe("tool-b") + }) + + it("applies pick filter", async () => { + const adapter = new LockfileSkillAdapter({ + skill: createMockSkill({ pick: ["tool-a"] }), + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory: createMockFactory(), + }) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools).toHaveLength(1) + expect(tools[0].name).toBe("tool-a") + }) + + it("applies omit filter", async () => { + const adapter = new LockfileSkillAdapter({ + skill: createMockSkill({ omit: ["tool-b"] }), + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory: createMockFactory(), + }) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools).toHaveLength(1) + expect(tools[0].name).toBe("tool-a") + }) + }) + + describe("callTool - lazy initialization", () => { + it("creates real adapter on first callTool", async () => { + const mockAdapter = createMockAdapter() + const factory = createMockFactory(mockAdapter) + const adapter = new LockfileSkillAdapter({ + skill: createMockSkill(), + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory, + }) + await adapter.connect() + await adapter.callTool("tool-a", { arg: "value" }) + expect(factory.createMcp).toHaveBeenCalledOnce() + expect(mockAdapter.connect).toHaveBeenCalledOnce() + expect(mockAdapter.callTool).toHaveBeenCalledWith("tool-a", { arg: "value" }) + }) + + it("reuses real adapter on subsequent callTool", async () => { + const mockAdapter = createMockAdapter() + const factory = createMockFactory(mockAdapter) + const adapter = new LockfileSkillAdapter({ + skill: createMockSkill(), + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory, + }) + await adapter.connect() + await adapter.callTool("tool-a", {}) + await adapter.callTool("tool-b", {}) + expect(factory.createMcp).toHaveBeenCalledOnce() + }) + + it("prevents race condition on concurrent callTool", async () => { + const mockAdapter = createMockAdapter() + const factory = createMockFactory(mockAdapter) + const adapter = new LockfileSkillAdapter({ + skill: createMockSkill(), + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory, + }) + await adapter.connect() + await Promise.all([adapter.callTool("tool-a", {}), adapter.callTool("tool-b", {})]) + expect(factory.createMcp).toHaveBeenCalledOnce() + }) + + it("uses InMemoryBase for base skill without version", async () => { + const skill = createMockSkill({ + name: "@perstack/base", + packageName: "@perstack/base", + }) + const mockAdapter = createMockAdapter() + const factory = createMockFactory(mockAdapter) + const adapter = new LockfileSkillAdapter({ + skill, + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory, + }) + await adapter.connect() + await adapter.callTool("tool-a", {}) + expect(factory.createInMemoryBase).toHaveBeenCalledOnce() + expect(factory.createMcp).not.toHaveBeenCalled() + }) + }) + + describe("disconnect", () => { + it("disconnects real adapter if created", async () => { + const mockAdapter = createMockAdapter() + const factory = createMockFactory(mockAdapter) + const adapter = new LockfileSkillAdapter({ + skill: createMockSkill(), + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory, + }) + await adapter.connect() + await adapter.callTool("tool-a", {}) + await adapter.disconnect() + expect(mockAdapter.disconnect).toHaveBeenCalledOnce() + }) + + it("does not error if no real adapter was created", async () => { + const adapter = new LockfileSkillAdapter({ + skill: createMockSkill(), + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory: createMockFactory(), + }) + await adapter.connect() + await adapter.disconnect() // should not throw + expect(adapter.state).toBe("closed") + }) + }) + + describe("with SSE skill", () => { + it("handles SSE skill type", async () => { + const sseSkill: McpSseSkill = { + type: "mcpSseSkill", + name: "sse-skill", + endpoint: "https://example.com/sse", + pick: [], + omit: [], + } + const adapter = new LockfileSkillAdapter({ + skill: sseSkill, + toolDefinitions: [ + { + skillName: "sse-skill", + name: "sseTool", + description: "An SSE tool", + inputSchema: { type: "object" }, + }, + ], + env: {}, + factory: createMockFactory(), + }) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools).toHaveLength(1) + expect(tools[0].name).toBe("sseTool") + }) + }) + + describe("properties", () => { + it("has correct name and type", () => { + const adapter = new LockfileSkillAdapter({ + skill: createMockSkill(), + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory: createMockFactory(), + }) + expect(adapter.name).toBe("test-skill") + expect(adapter.type).toBe("mcp") + }) + + it("exposes skill", () => { + const skill = createMockSkill() + const adapter = new LockfileSkillAdapter({ + skill, + toolDefinitions: createMockToolDefinitions(), + env: {}, + factory: createMockFactory(), + }) + expect(adapter.skill).toBe(skill) + }) + }) +}) diff --git a/packages/runtime/src/skill-manager/lockfile-skill-manager.ts b/packages/skill-manager/src/adapters/lockfile-adapter.ts similarity index 55% rename from packages/runtime/src/skill-manager/lockfile-skill-manager.ts rename to packages/skill-manager/src/adapters/lockfile-adapter.ts index 5ee85f6c..2673b610 100644 --- a/packages/runtime/src/skill-manager/lockfile-skill-manager.ts +++ b/packages/skill-manager/src/adapters/lockfile-adapter.ts @@ -1,47 +1,46 @@ import type { - FileInlinePart, - ImageInlinePart, LockfileToolDefinition, McpSseSkill, McpStdioSkill, - RunEvent, - RuntimeEvent, SkillType, - TextPart, ToolDefinition, } from "@perstack/core" -import { BaseSkillManager } from "./base.js" -import { isBaseSkill, shouldUseBundledBase } from "./helpers.js" -import { InMemoryBaseSkillManager } from "./in-memory-base.js" -import { McpSkillManager } from "./mcp.js" +import { SkillAdapter } from "../skill-adapter.js" +import type { SkillAdapterFactory, SkillAdapterFactoryContext } from "../skill-adapter-factory.js" +import type { SkillAdapterLifecycleEvent, ToolCallResult } from "../types.js" +import { isBaseSkill, shouldUseBundledBase } from "../utils/base-skill-helpers.js" -export interface LockfileSkillManagerOptions { +export interface LockfileSkillAdapterOptions { skill: McpStdioSkill | McpSseSkill toolDefinitions: LockfileToolDefinition[] env: Record - jobId: string - runId: string - eventListener?: (event: RunEvent | RuntimeEvent) => void perstackBaseSkillCommand?: string[] + factory: SkillAdapterFactory + onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void } -export class LockfileSkillManager extends BaseSkillManager { +/** + * Lazy adapter that caches tool definitions from lockfile, + * deferring actual skill connection until tools are called. + */ +export class LockfileSkillAdapter extends SkillAdapter { readonly name: string readonly type: SkillType = "mcp" - readonly lazyInit = true override readonly skill: McpStdioSkill | McpSseSkill private _cachedToolDefinitions: ToolDefinition[] - private _realManager?: BaseSkillManager - private _pendingInit?: Promise + private _realAdapter?: SkillAdapter + private _pendingConnect?: Promise private _env: Record private _perstackBaseSkillCommand?: string[] + private _factory: SkillAdapterFactory - constructor(options: LockfileSkillManagerOptions) { - super(options.jobId, options.runId, options.eventListener) + constructor(options: LockfileSkillAdapterOptions) { + super(options.onLifecycleEvent) this.name = options.skill.name this.skill = options.skill this._env = options.env this._perstackBaseSkillCommand = options.perstackBaseSkillCommand + this._factory = options.factory this._cachedToolDefinitions = options.toolDefinitions.map((def) => ({ skillName: def.skillName, name: def.name, @@ -51,11 +50,13 @@ export class LockfileSkillManager extends BaseSkillManager { })) } - protected override async _doInit(): Promise { + protected override async _doConnect(): Promise { // No-op: tool definitions are already cached from lockfile } - override async getToolDefinitions(): Promise { + override getToolDefinitions(): ToolDefinition[] { + // Override to return cached definitions without requiring ready state + // (LockfileSkillAdapter always has cached tools available after connect) return this._filterTools(this._cachedToolDefinitions) } @@ -67,51 +68,42 @@ export class LockfileSkillManager extends BaseSkillManager { .filter((tool) => (pick.length > 0 ? pick.includes(tool.name) : true)) } - private async _ensureRealManager(): Promise { - // Return existing manager if already initialized - if (this._realManager) { - return this._realManager + private async _ensureRealAdapter(): Promise { + if (this._realAdapter) { + return this._realAdapter } - // Wait for pending initialization to avoid race condition - if (this._pendingInit) { - return this._pendingInit + if (this._pendingConnect) { + return this._pendingConnect } - // Start initialization and track the pending promise - this._pendingInit = this._initRealManager() + this._pendingConnect = this._connectRealAdapter() try { - this._realManager = await this._pendingInit - return this._realManager + this._realAdapter = await this._pendingConnect + return this._realAdapter } finally { - this._pendingInit = undefined + this._pendingConnect = undefined } } - private async _initRealManager(): Promise { + private async _connectRealAdapter(): Promise { const useBundledBase = this.skill.type === "mcpStdioSkill" && isBaseSkill(this.skill) && shouldUseBundledBase(this.skill, this._perstackBaseSkillCommand) - let manager: BaseSkillManager + + const factoryCtx: SkillAdapterFactoryContext = { + env: this._env, + onLifecycleEvent: this._onLifecycleEvent, + } + + let adapter: SkillAdapter if (useBundledBase && this.skill.type === "mcpStdioSkill") { - manager = new InMemoryBaseSkillManager( - this.skill, - this._jobId, - this._runId, - this._eventListener, - ) + adapter = this._factory.createInMemoryBase(this.skill, factoryCtx) } else { - // Apply perstackBaseSkillCommand override if applicable const skillToUse = this._applyBaseSkillCommandOverride(this.skill) - manager = new McpSkillManager( - skillToUse, - this._env, - this._jobId, - this._runId, - this._eventListener, - ) + adapter = this._factory.createMcp(skillToUse, factoryCtx) } - await manager.init() - return manager + await adapter.connect() + return adapter } private _applyBaseSkillCommandOverride( @@ -142,14 +134,14 @@ export class LockfileSkillManager extends BaseSkillManager { override async callTool( toolName: string, input: Record, - ): Promise> { - const realManager = await this._ensureRealManager() - return realManager.callTool(toolName, input) + ): Promise { + const realAdapter = await this._ensureRealAdapter() + return realAdapter.callTool(toolName, input) } - override async close(): Promise { - if (this._realManager) { - await this._realManager.close() + protected override async _doDisconnect(): Promise { + if (this._realAdapter) { + await this._realAdapter.disconnect() } } } diff --git a/packages/skill-manager/src/adapters/mcp-adapter.test.ts b/packages/skill-manager/src/adapters/mcp-adapter.test.ts new file mode 100644 index 00000000..0b68c814 --- /dev/null +++ b/packages/skill-manager/src/adapters/mcp-adapter.test.ts @@ -0,0 +1,252 @@ +import type { McpSseSkill, McpStdioSkill } from "@perstack/core" +import { describe, expect, it, vi } from "vitest" +import type { TransportFactory } from "../transport-factory.js" +import { McpSkillAdapter } from "./mcp-adapter.js" + +vi.mock("@modelcontextprotocol/sdk/client/index.js", () => { + const mockClient = vi.fn() + mockClient.prototype.connect = vi.fn().mockResolvedValue(undefined) + mockClient.prototype.listTools = vi.fn().mockResolvedValue({ + tools: [ + { + name: "test-tool", + description: "A test tool", + inputSchema: { type: "object" }, + }, + ], + }) + mockClient.prototype.callTool = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "result" }], + }) + mockClient.prototype.close = vi.fn().mockResolvedValue(undefined) + mockClient.prototype.getServerVersion = vi + .fn() + .mockReturnValue({ name: "test-server", version: "1.0" }) + return { Client: mockClient } +}) + +function createStdioSkill(overrides: Partial = {}): McpStdioSkill { + return { + type: "mcpStdioSkill", + name: "test-mcp-skill", + command: "npx", + packageName: "@example/pkg", + args: [], + requiredEnv: [], + pick: [], + omit: [], + lazyInit: false, + ...overrides, + } as McpStdioSkill +} + +function createSseSkill(overrides: Partial = {}): McpSseSkill { + return { + type: "mcpSseSkill", + name: "test-sse-skill", + endpoint: "https://api.example.com/sse", + pick: [], + omit: [], + ...overrides, + } +} + +function createMockTransportFactory(): TransportFactory { + return { + createStdio: vi.fn().mockReturnValue({ + start: vi.fn(), + close: vi.fn(), + stderr: null, + }), + createSse: vi.fn().mockReturnValue({ + start: vi.fn(), + close: vi.fn(), + }), + createInMemoryPair: vi.fn(), + } +} + +describe("@perstack/skill-manager: McpSkillAdapter", () => { + describe("state transitions", () => { + it("starts in idle state", () => { + const adapter = new McpSkillAdapter(createStdioSkill(), {}) + expect(adapter.state).toBe("idle") + }) + + it("transitions to ready after connect", async () => { + const adapter = new McpSkillAdapter(createStdioSkill(), {}, undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + expect(adapter.state).toBe("ready") + }) + + it("transitions to closed after disconnect", async () => { + const adapter = new McpSkillAdapter(createStdioSkill(), {}, undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + await adapter.disconnect() + expect(adapter.state).toBe("closed") + }) + + it("throws on double connect", async () => { + const adapter = new McpSkillAdapter(createStdioSkill(), {}, undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + await expect(adapter.connect()).rejects.toThrow("already connected") + }) + + it("throws when connecting after closed", async () => { + const adapter = new McpSkillAdapter(createStdioSkill(), {}, undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + await adapter.disconnect() + await expect(adapter.connect()).rejects.toThrow("closed") + }) + + it("disconnect is idempotent", async () => { + const adapter = new McpSkillAdapter(createStdioSkill(), {}, undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + await adapter.disconnect() + await adapter.disconnect() // should not throw + expect(adapter.state).toBe("closed") + }) + }) + + describe("tool definitions", () => { + it("getToolDefinitions throws when not ready", () => { + const adapter = new McpSkillAdapter(createStdioSkill(), {}) + expect(() => adapter.getToolDefinitions()).toThrow("not ready") + }) + + it("returns tool definitions after connect", async () => { + const adapter = new McpSkillAdapter(createStdioSkill(), {}, undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools).toHaveLength(1) + expect(tools[0].name).toBe("test-tool") + expect(tools[0].skillName).toBe("test-mcp-skill") + }) + + it("applies pick filter", async () => { + const skill = createStdioSkill({ pick: ["other-tool"] }) + const adapter = new McpSkillAdapter(skill, {}, undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools).toHaveLength(0) + }) + + it("applies omit filter", async () => { + const skill = createStdioSkill({ omit: ["test-tool"] }) + const adapter = new McpSkillAdapter(skill, {}, undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + const tools = adapter.getToolDefinitions() + expect(tools).toHaveLength(0) + }) + }) + + describe("callTool", () => { + it("calls tool and returns results", async () => { + const adapter = new McpSkillAdapter(createStdioSkill(), {}, undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + const result = await adapter.callTool("test-tool", { arg: "value" }) + expect(result).toHaveLength(1) + expect(result[0].type).toBe("textPart") + }) + + it("throws when not ready", async () => { + const adapter = new McpSkillAdapter(createStdioSkill(), {}) + await expect(adapter.callTool("test-tool", {})).rejects.toThrow("not ready") + }) + }) + + describe("SSE validation", () => { + it("rejects non-HTTPS endpoints", async () => { + const skill = createSseSkill({ endpoint: "http://example.com/sse" }) + const adapter = new McpSkillAdapter(skill, {}) + await expect(adapter.connect()).rejects.toThrow("HTTPS") + }) + + it("rejects private IP endpoints", async () => { + const skill = createSseSkill({ endpoint: "https://192.168.1.1/sse" }) + const adapter = new McpSkillAdapter(skill, {}) + await expect(adapter.connect()).rejects.toThrow("private/local IP") + }) + }) + + describe("lifecycle events", () => { + it("emits connecting and connected events", async () => { + const events: Array<{ type: string }> = [] + const adapter = new McpSkillAdapter(createStdioSkill(), {}, (event) => events.push(event), { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + expect(events.map((e) => e.type)).toEqual(["connecting", "connected"]) + }) + + it("emits error event on connect failure", async () => { + const events: Array<{ type: string }> = [] + const skill = createSseSkill({ endpoint: "http://example.com/sse" }) + const adapter = new McpSkillAdapter(skill, {}, (event) => events.push(event)) + await adapter.connect().catch(() => {}) + expect(events.map((e) => e.type)).toEqual(["connecting", "error"]) + }) + + it("emits disconnected event", async () => { + const events: Array<{ type: string }> = [] + const adapter = new McpSkillAdapter(createStdioSkill(), {}, (event) => events.push(event), { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + events.length = 0 + await adapter.disconnect() + expect(events.map((e) => e.type)).toEqual(["disconnected"]) + }) + }) + + describe("properties", () => { + it("has correct name and type", () => { + const adapter = new McpSkillAdapter(createStdioSkill(), {}) + expect(adapter.name).toBe("test-mcp-skill") + expect(adapter.type).toBe("mcp") + }) + + it("exposes skill", () => { + const skill = createStdioSkill() + const adapter = new McpSkillAdapter(skill, {}) + expect(adapter.skill).toBe(skill) + }) + }) + + describe("required env validation", () => { + it("throws when required env is missing", async () => { + const skill = createStdioSkill({ requiredEnv: ["API_KEY"] }) + const adapter = new McpSkillAdapter(skill, {}, undefined, { + transportFactory: createMockTransportFactory(), + }) + await expect(adapter.connect()).rejects.toThrow("requires environment variable API_KEY") + }) + + it("does not throw when required env is present", async () => { + const skill = createStdioSkill({ requiredEnv: ["API_KEY"] }) + const adapter = new McpSkillAdapter(skill, { API_KEY: "secret" }, undefined, { + transportFactory: createMockTransportFactory(), + }) + await adapter.connect() + expect(adapter.state).toBe("ready") + }) + }) +}) diff --git a/packages/runtime/src/skill-manager/mcp.ts b/packages/skill-manager/src/adapters/mcp-adapter.ts similarity index 50% rename from packages/runtime/src/skill-manager/mcp.ts rename to packages/skill-manager/src/adapters/mcp-adapter.ts index 1a451f0c..66d27cf7 100644 --- a/packages/runtime/src/skill-manager/mcp.ts +++ b/packages/skill-manager/src/adapters/mcp-adapter.ts @@ -2,40 +2,28 @@ import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js" import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { type CallToolResult, McpError } from "@modelcontextprotocol/sdk/types.js" import { - createRuntimeEvent, - type FileInlinePart, getFilteredEnv, - type ImageInlinePart, type McpSseSkill, type McpStdioSkill, PerstackError, - type RunEvent, - type RuntimeEvent, type SkillType, - type TextPart, type ToolDefinition, } from "@perstack/core" -import { BaseSkillManager } from "./base.js" -import { getCommandArgs } from "./command-args.js" -import { isPrivateOrLocalIP } from "./ip-validator.js" -import { convertToolResult, handleToolError } from "./mcp-converters.js" -import { defaultTransportFactory, type TransportFactory } from "./transport-factory.js" +import { SkillAdapter } from "../skill-adapter.js" +import type { TransportFactory } from "../transport-factory.js" +import { defaultTransportFactory } from "../transport-factory.js" +import type { SkillAdapterLifecycleEvent, ToolCallResult } from "../types.js" +import { getCommandArgs } from "../utils/command-args.js" +import { isPrivateOrLocalIP } from "../utils/ip-validator.js" +import { convertToolResult, handleToolError } from "../utils/mcp-converters.js" -interface InitTimingInfo { - startTime: number - spawnDurationMs: number - handshakeDurationMs: number - serverInfo?: { name: string; version: string } -} - -export interface McpSkillManagerOptions { +export interface McpSkillAdapterOptions { transportFactory?: TransportFactory } -export class McpSkillManager extends BaseSkillManager { +export class McpSkillAdapter extends SkillAdapter { readonly name: string readonly type: SkillType = "mcp" - readonly lazyInit: boolean override readonly skill: McpStdioSkill | McpSseSkill private _mcpClient?: McpClient private _env: Record @@ -44,34 +32,27 @@ export class McpSkillManager extends BaseSkillManager { constructor( skill: McpStdioSkill | McpSseSkill, env: Record, - jobId: string, - runId: string, - eventListener?: (event: RunEvent | RuntimeEvent) => void, - options?: McpSkillManagerOptions, + onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void, + options?: McpSkillAdapterOptions, ) { - super(jobId, runId, eventListener) + super(onLifecycleEvent) this.name = skill.name this.skill = skill this._env = env this._transportFactory = options?.transportFactory ?? defaultTransportFactory - this.lazyInit = - skill.type === "mcpStdioSkill" && skill.lazyInit && skill.name !== "@perstack/base" } - protected override async _doInit(): Promise { + protected override async _doConnect(): Promise { this._mcpClient = new McpClient({ name: `${this.skill.name}-mcp-client`, version: "1.0.0", }) - let timingInfo: InitTimingInfo | undefined if (this.skill.type === "mcpStdioSkill") { - timingInfo = await this._initStdio(this.skill) + await this._connectStdio(this.skill) } else { - await this._initSse(this.skill) + await this._connectSse(this.skill) } - const toolDiscoveryStartTime = Date.now() const { tools } = await this._mcpClient.listTools() - const toolDiscoveryDurationMs = Date.now() - toolDiscoveryStartTime this._toolDefinitions = tools.map((tool) => ({ skillName: this.skill.name, name: tool.name, @@ -79,22 +60,9 @@ export class McpSkillManager extends BaseSkillManager { inputSchema: tool.inputSchema, interactive: false, })) - if (this._eventListener && timingInfo) { - const totalDurationMs = Date.now() - timingInfo.startTime - const event = createRuntimeEvent("skillConnected", this._jobId, this._runId, { - skillName: this.skill.name, - serverInfo: timingInfo.serverInfo, - spawnDurationMs: timingInfo.spawnDurationMs, - handshakeDurationMs: timingInfo.handshakeDurationMs, - toolDiscoveryDurationMs, - connectDurationMs: timingInfo.spawnDurationMs + timingInfo.handshakeDurationMs, - totalDurationMs, - }) - this._eventListener(event) - } } - private async _initStdio(skill: McpStdioSkill): Promise { + private async _connectStdio(skill: McpStdioSkill): Promise { if (!skill.command) { throw new PerstackError(`Skill ${skill.name} has no command`) } @@ -106,44 +74,22 @@ export class McpSkillManager extends BaseSkillManager { requiredEnv[envName] = this._env[envName] } const env = getFilteredEnv(requiredEnv) - const startTime = Date.now() const { command, args } = getCommandArgs(skill) - if (this._eventListener) { - const event = createRuntimeEvent("skillStarting", this._jobId, this._runId, { - skillName: skill.name, - command, - args, - }) - this._eventListener(event) - } + const spawnStart = Date.now() const transport = this._transportFactory.createStdio({ command, args, env, stderr: "pipe" }) - const spawnDurationMs = Date.now() - startTime + this._spawnDurationMs = Date.now() - spawnStart if ((transport as StdioClientTransport).stderr) { ;(transport as StdioClientTransport).stderr!.on("data", (chunk: Buffer) => { - if (this._eventListener) { - const event = createRuntimeEvent("skillStderr", this._jobId, this._runId, { - skillName: skill.name, - message: chunk.toString().trim(), - }) - this._eventListener(event) - } + this._emitLifecycleEvent("stderr", { + skillName: skill.name, + message: chunk.toString().trim(), + }) }) } - const connectStartTime = Date.now() await this._mcpClient!.connect(transport) - const handshakeDurationMs = Date.now() - connectStartTime - const serverVersion = this._mcpClient!.getServerVersion() - return { - startTime, - spawnDurationMs, - handshakeDurationMs, - serverInfo: serverVersion - ? { name: serverVersion.name, version: serverVersion.version } - : undefined, - } } - private async _initSse(skill: McpSseSkill): Promise { + private async _connectSse(skill: McpSseSkill): Promise { if (!skill.endpoint) { throw new PerstackError(`Skill ${skill.name} has no endpoint`) } @@ -160,15 +106,9 @@ export class McpSkillManager extends BaseSkillManager { await this._mcpClient!.connect(transport) } - override async close(): Promise { + protected override async _doDisconnect(): Promise { if (this._mcpClient) { await this._mcpClient.close() - if (this._eventListener && this.skill) { - const event = createRuntimeEvent("skillDisconnected", this._jobId, this._runId, { - skillName: this.skill.name, - }) - this._eventListener(event) - } } } @@ -183,9 +123,9 @@ export class McpSkillManager extends BaseSkillManager { override async callTool( toolName: string, input: Record, - ): Promise> { - if (!this.isInitialized() || !this._mcpClient) { - throw new Error(`${this.name} is not initialized`) + ): Promise { + if (this.state !== "ready" || !this._mcpClient) { + throw new Error(`${this.name} is not ready`) } try { const result = (await this._mcpClient.callTool({ diff --git a/packages/skill-manager/src/index.ts b/packages/skill-manager/src/index.ts new file mode 100644 index 00000000..2e13db36 --- /dev/null +++ b/packages/skill-manager/src/index.ts @@ -0,0 +1,3 @@ +export type { SkillAdapter } from "./skill-adapter.js" +export { SkillManager } from "./skill-manager.js" +export type { CollectedToolDefinition, LockfileInitOptions, SkillManagerOptions } from "./types.js" diff --git a/packages/skill-manager/src/skill-adapter-factory.ts b/packages/skill-manager/src/skill-adapter-factory.ts new file mode 100644 index 00000000..e55ab11e --- /dev/null +++ b/packages/skill-manager/src/skill-adapter-factory.ts @@ -0,0 +1,49 @@ +import type { Expert, InteractiveSkill, McpSseSkill, McpStdioSkill } from "@perstack/core" +import { DelegateSkillAdapter } from "./adapters/delegate-adapter.js" +import { InMemoryBaseSkillAdapter } from "./adapters/in-memory-base-adapter.js" +import { InteractiveSkillAdapter } from "./adapters/interactive-adapter.js" +import { McpSkillAdapter } from "./adapters/mcp-adapter.js" +import type { SkillAdapter } from "./skill-adapter.js" +import type { TransportFactory } from "./transport-factory.js" +import type { SkillAdapterLifecycleEvent } from "./types.js" + +/** Context passed to the factory when creating adapters */ +export interface SkillAdapterFactoryContext { + env: Record + onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void + transportFactory?: TransportFactory +} + +/** Factory interface for creating skill adapters */ +export interface SkillAdapterFactory { + createMcp(skill: McpStdioSkill | McpSseSkill, ctx: SkillAdapterFactoryContext): SkillAdapter + createInMemoryBase(skill: McpStdioSkill, ctx: SkillAdapterFactoryContext): SkillAdapter + createInteractive(skill: InteractiveSkill, ctx: SkillAdapterFactoryContext): SkillAdapter + createDelegate(expert: Expert, ctx: SkillAdapterFactoryContext): SkillAdapter +} + +/** Default implementation of SkillAdapterFactory */ +export class DefaultSkillAdapterFactory implements SkillAdapterFactory { + createMcp(skill: McpStdioSkill | McpSseSkill, ctx: SkillAdapterFactoryContext): SkillAdapter { + return new McpSkillAdapter(skill, ctx.env, ctx.onLifecycleEvent, { + transportFactory: ctx.transportFactory, + }) + } + + createInMemoryBase(skill: McpStdioSkill, ctx: SkillAdapterFactoryContext): SkillAdapter { + return new InMemoryBaseSkillAdapter(skill, ctx.onLifecycleEvent, { + transportFactory: ctx.transportFactory, + }) + } + + createInteractive(skill: InteractiveSkill, ctx: SkillAdapterFactoryContext): SkillAdapter { + return new InteractiveSkillAdapter(skill, ctx.onLifecycleEvent) + } + + createDelegate(expert: Expert, ctx: SkillAdapterFactoryContext): SkillAdapter { + return new DelegateSkillAdapter(expert, ctx.onLifecycleEvent) + } +} + +/** Default adapter factory instance */ +export const defaultSkillAdapterFactory = new DefaultSkillAdapterFactory() diff --git a/packages/skill-manager/src/skill-adapter.ts b/packages/skill-manager/src/skill-adapter.ts new file mode 100644 index 00000000..0caf4bf0 --- /dev/null +++ b/packages/skill-manager/src/skill-adapter.ts @@ -0,0 +1,116 @@ +import type { + Expert, + InteractiveSkill, + McpSseSkill, + McpStdioSkill, + SkillType, + ToolDefinition, +} from "@perstack/core" +import type { SkillAdapterLifecycleEvent, SkillAdapterState, ToolCallResult } from "./types.js" + +/** + * Abstract base class for skill adapters. + * Adapts different skill sources (MCP stdio/SSE, in-memory, interactive, delegate) + * to a unified interface. + */ +export abstract class SkillAdapter { + abstract readonly name: string + abstract readonly type: SkillType + readonly skill?: McpStdioSkill | McpSseSkill + readonly interactiveSkill?: InteractiveSkill + readonly expert?: Expert + + protected _toolDefinitions: ToolDefinition[] = [] + private _state: SkillAdapterState = "idle" + private _connectPromise?: Promise + private _connectDurationMs?: number + protected _spawnDurationMs?: number + protected _onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void + + constructor(onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void) { + this._onLifecycleEvent = onLifecycleEvent + } + + get state(): SkillAdapterState { + return this._state + } + + /** Duration in ms of the last connect() call, or undefined if not yet connected. */ + get connectDurationMs(): number | undefined { + return this._connectDurationMs + } + + /** Duration in ms of process spawn, or undefined if not applicable. */ + get spawnDurationMs(): number | undefined { + return this._spawnDurationMs + } + + async connect(): Promise { + if (this._state === "ready") { + throw new Error(`Adapter ${this.name} is already connected`) + } + if (this._state === "initializing") { + throw new Error(`Adapter ${this.name} is already connecting`) + } + if (this._state === "closed") { + throw new Error(`Adapter ${this.name} is closed`) + } + this._state = "initializing" + this._emitLifecycleEvent("connecting") + const startTime = Date.now() + this._connectPromise = this._doConnect() + try { + await this._connectPromise + this._connectDurationMs = Date.now() - startTime + this._state = "ready" + this._emitLifecycleEvent("connected") + } catch (error) { + this._state = "error" + this._emitLifecycleEvent("error", { + error: error instanceof Error ? error.message : String(error), + }) + throw error + } finally { + this._connectPromise = undefined + } + } + + async disconnect(): Promise { + if (this._state === "closed") { + return // idempotent + } + try { + await this._doDisconnect() + } finally { + this._state = "closed" + this._emitLifecycleEvent("disconnected") + } + } + + getToolDefinitions(): ToolDefinition[] { + if (this._state !== "ready") { + throw new Error(`Adapter ${this.name} is not ready (state: ${this._state})`) + } + return this._filterTools(this._toolDefinitions) + } + + abstract callTool(toolName: string, input: Record): Promise + + protected abstract _doConnect(): Promise + protected abstract _doDisconnect(): Promise + + protected _filterTools(tools: ToolDefinition[]): ToolDefinition[] { + return tools + } + + protected _emitLifecycleEvent( + type: SkillAdapterLifecycleEvent["type"], + data?: Record, + ): void { + this._onLifecycleEvent?.({ + type, + adapterName: this.name, + data, + }) + } +} diff --git a/packages/skill-manager/src/skill-manager.test.ts b/packages/skill-manager/src/skill-manager.test.ts new file mode 100644 index 00000000..c7ead79e --- /dev/null +++ b/packages/skill-manager/src/skill-manager.test.ts @@ -0,0 +1,755 @@ +import type { Expert, McpSseSkill, McpStdioSkill, ToolDefinition } from "@perstack/core" +import { describe, expect, it, vi } from "vitest" +import { InMemoryBaseSkillAdapter } from "./adapters/in-memory-base-adapter.js" +import type { SkillAdapter } from "./skill-adapter.js" +import type { SkillAdapterFactory } from "./skill-adapter-factory.js" +import { buildSkillFromInput, SkillManager } from "./skill-manager.js" +import type { ToolCallResult } from "./types.js" + +// Helper to create mock adapters +function createMockAdapter( + name: string, + tools: ToolDefinition[] = [], + type: "mcp" | "interactive" | "delegate" = "mcp", +): SkillAdapter { + const adapter = { + name, + type, + state: "idle" as "idle" | "ready" | "closed", + skill: undefined, + expert: undefined, + interactiveSkill: undefined, + connect: vi.fn().mockImplementation(async () => { + adapter.state = "ready" + }), + disconnect: vi.fn().mockImplementation(async () => { + adapter.state = "closed" + }), + getToolDefinitions: vi.fn().mockReturnValue(tools), + callTool: vi + .fn() + .mockResolvedValue([ + { type: "textPart", text: "mock result", id: "test-id" }, + ] as ToolCallResult), + } + return adapter as unknown as SkillAdapter +} + +function createMockFactory(adapters?: Record): SkillAdapterFactory { + const _defaultAdapter = createMockAdapter("default", []) + return { + createMcp: vi.fn().mockImplementation((skill: McpStdioSkill | McpSseSkill) => { + return ( + adapters?.[skill.name] ?? + createMockAdapter(skill.name, [ + { + skillName: skill.name, + name: `${skill.name}-tool`, + description: "A tool", + inputSchema: { type: "object" }, + interactive: false, + }, + ]) + ) + }), + createInMemoryBase: vi.fn().mockImplementation((_skill: McpStdioSkill) => { + return ( + adapters?.["@perstack/base"] ?? + createMockAdapter("@perstack/base", [ + { + skillName: "@perstack/base", + name: "read_file", + description: "Read file", + inputSchema: { type: "object" }, + interactive: false, + }, + ]) + ) + }), + createInteractive: vi.fn().mockImplementation((skill) => { + return ( + adapters?.[skill.name] ?? + createMockAdapter( + skill.name, + [ + { + skillName: skill.name, + name: `${skill.name}-tool`, + description: "Interactive tool", + inputSchema: { type: "object" }, + interactive: true, + }, + ], + "interactive", + ) + ) + }), + createDelegate: vi.fn().mockImplementation((expert: Expert) => { + return ( + adapters?.[expert.name] ?? + createMockAdapter( + expert.name, + [ + { + skillName: expert.name, + name: expert.name.split("/").pop() ?? expert.name, + description: expert.description, + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + interactive: false, + }, + ], + "delegate", + ) + ) + }), + } +} + +function createBaseSkill(overrides: Partial = {}): McpStdioSkill { + return { + type: "mcpStdioSkill", + name: "@perstack/base", + command: "npx", + packageName: "@perstack/base", + args: [], + requiredEnv: [], + pick: [], + omit: [], + lazyInit: false, + ...overrides, + } as McpStdioSkill +} + +function createExpert(overrides: Partial = {}): Expert { + return { + key: "test-expert", + name: "@test/expert", + version: "1.0.0", + description: "Test expert", + instruction: "Test instruction", + skills: { + "@perstack/base": createBaseSkill(), + }, + delegates: [], + tags: [], + minRuntimeVersion: "v1.0", + ...overrides, + } +} + +function createDelegateExpert(overrides: Partial = {}): Expert { + return { + key: "delegate-expert", + name: "@test/delegate", + version: "1.0.0", + description: "Delegate expert", + instruction: "Delegate instruction", + skills: { + "@perstack/base": createBaseSkill(), + }, + delegates: [], + tags: [], + minRuntimeVersion: "v1.0", + ...overrides, + } +} + +describe("@perstack/skill-manager: SkillManager", () => { + describe("fromExpert", () => { + it("creates skill manager with base skill", async () => { + const factory = createMockFactory() + const expert = createExpert() + const manager = await SkillManager.fromExpert( + expert, + {}, + { + env: {}, + factory, + }, + ) + expect(manager.isClosed).toBe(false) + expect(factory.createInMemoryBase).toHaveBeenCalledOnce() + await manager.close() + }) + + it("throws when base skill is missing", async () => { + const expert = createExpert({ skills: {} }) + await expect( + SkillManager.fromExpert(expert, {}, { env: {}, factory: createMockFactory() }), + ).rejects.toThrow("Base skill is not defined") + }) + + it("creates MCP adapters for non-base skills", async () => { + const factory = createMockFactory() + const expert = createExpert({ + skills: { + "@perstack/base": createBaseSkill(), + "other-skill": { + type: "mcpStdioSkill", + name: "other-skill", + command: "npx", + packageName: "@other/pkg", + args: [], + requiredEnv: [], + pick: [], + omit: [], + lazyInit: false, + } as McpStdioSkill, + }, + }) + const manager = await SkillManager.fromExpert(expert, {}, { env: {}, factory }) + expect(factory.createMcp).toHaveBeenCalledOnce() + expect(factory.createInMemoryBase).toHaveBeenCalledOnce() + await manager.close() + }) + + it("creates interactive adapters", async () => { + const factory = createMockFactory() + const expert = createExpert({ + skills: { + "@perstack/base": createBaseSkill(), + "my-interactive": { + type: "interactiveSkill", + name: "my-interactive", + description: "Interactive skill", + tools: { + "ask-user": { + name: "ask-user", + description: "Ask user", + inputJsonSchema: "{}", + }, + }, + }, + }, + }) + const manager = await SkillManager.fromExpert(expert, {}, { env: {}, factory }) + expect(factory.createInteractive).toHaveBeenCalledOnce() + await manager.close() + }) + + it("skips interactive adapters for delegated runs", async () => { + const factory = createMockFactory() + const expert = createExpert({ + skills: { + "@perstack/base": createBaseSkill(), + "my-interactive": { + type: "interactiveSkill", + name: "my-interactive", + description: "Interactive skill", + tools: {}, + }, + }, + }) + const manager = await SkillManager.fromExpert( + expert, + {}, + { + env: {}, + factory, + isDelegatedRun: true, + }, + ) + expect(factory.createInteractive).not.toHaveBeenCalled() + await manager.close() + }) + + it("creates delegate adapters", async () => { + const factory = createMockFactory() + const delegateExpert = createDelegateExpert() + const expert = createExpert({ delegates: ["delegate-expert"] }) + const experts = { "delegate-expert": delegateExpert } + const manager = await SkillManager.fromExpert(expert, experts, { env: {}, factory }) + expect(factory.createDelegate).toHaveBeenCalledOnce() + await manager.close() + }) + + it("throws when delegate expert not found", async () => { + const factory = createMockFactory() + const expert = createExpert({ delegates: ["missing-expert"] }) + await expect(SkillManager.fromExpert(expert, {}, { env: {}, factory })).rejects.toThrow( + 'Delegate expert "missing-expert" not found', + ) + }) + + it("cleans up all adapters on failure", async () => { + const failingAdapter = createMockAdapter("fail-skill") + ;(failingAdapter.connect as ReturnType).mockRejectedValue( + new Error("connect failed"), + ) + const factory = createMockFactory({ "fail-skill": failingAdapter }) + const expert = createExpert({ + skills: { + "@perstack/base": createBaseSkill(), + "fail-skill": { + type: "mcpStdioSkill", + name: "fail-skill", + command: "npx", + args: ["@fail/pkg"], + requiredEnv: [], + pick: [], + omit: [], + lazyInit: false, + } as McpStdioSkill, + }, + }) + await expect(SkillManager.fromExpert(expert, {}, { env: {}, factory })).rejects.toThrow( + "connect failed", + ) + }) + + it("uses MCP for base skill with explicit version", async () => { + const factory = createMockFactory() + const expert = createExpert({ + skills: { + "@perstack/base": createBaseSkill({ packageName: "@perstack/base@0.0.34" }), + }, + }) + const manager = await SkillManager.fromExpert(expert, {}, { env: {}, factory }) + expect(factory.createInMemoryBase).not.toHaveBeenCalled() + expect(factory.createMcp).toHaveBeenCalledOnce() + await manager.close() + }) + }) + + describe("fromLockfile", () => { + it("creates lockfile adapters for MCP skills", async () => { + const factory = createMockFactory() + const expert = createExpert() + const manager = await SkillManager.fromLockfile( + expert, + {}, + { + env: {}, + factory, + lockfileToolDefinitions: { + "@perstack/base": [ + { skillName: "@perstack/base", name: "read_file", inputSchema: { type: "object" } }, + ], + }, + }, + ) + expect(manager.isClosed).toBe(false) + await manager.close() + }) + + it("creates delegate adapters from lockfile", async () => { + const factory = createMockFactory() + const delegateExpert = createDelegateExpert() + const expert = createExpert({ delegates: ["delegate-expert"] }) + const manager = await SkillManager.fromLockfile( + expert, + { "delegate-expert": delegateExpert }, + { + env: {}, + factory, + lockfileToolDefinitions: { + "@perstack/base": [], + }, + }, + ) + expect(factory.createDelegate).toHaveBeenCalledOnce() + await manager.close() + }) + }) + + describe("collectToolDefinitions", () => { + it("collects all tool definitions from adapters", async () => { + const factory = createMockFactory() + const expert = createExpert() + const tools = await SkillManager.collectToolDefinitions(expert, { env: {}, factory }) + expect(tools.length).toBeGreaterThan(0) + expect(tools[0].skillName).toBe("@perstack/base") + }) + + it("closes all adapters after collection", async () => { + const baseAdapter = createMockAdapter("@perstack/base", [ + { + skillName: "@perstack/base", + name: "read_file", + description: "Read file", + inputSchema: { type: "object" }, + interactive: false, + }, + ]) + const factory = createMockFactory({ "@perstack/base": baseAdapter }) + const expert = createExpert() + await SkillManager.collectToolDefinitions(expert, { env: {}, factory }) + expect(baseAdapter.disconnect).toHaveBeenCalled() + }) + }) + + describe("getToolDefinitions", () => { + it("returns all tool definitions from all adapters", async () => { + const factory = createMockFactory() + const expert = createExpert({ + skills: { + "@perstack/base": createBaseSkill(), + "other-skill": { + type: "mcpStdioSkill", + name: "other-skill", + command: "npx", + args: ["@other/pkg"], + requiredEnv: [], + pick: [], + omit: [], + lazyInit: false, + } as McpStdioSkill, + }, + }) + const manager = await SkillManager.fromExpert(expert, {}, { env: {}, factory }) + const tools = manager.getToolDefinitions() + expect(tools.length).toBeGreaterThanOrEqual(2) + await manager.close() + }) + + it("throws when closed", async () => { + const manager = await SkillManager.fromExpert( + createExpert(), + {}, + { + env: {}, + factory: createMockFactory(), + }, + ) + await manager.close() + expect(() => manager.getToolDefinitions()).toThrow("closed") + }) + }) + + describe("getAdapterByToolName", () => { + it("returns correct adapter for tool", async () => { + const factory = createMockFactory() + const expert = createExpert() + const manager = await SkillManager.fromExpert(expert, {}, { env: {}, factory }) + const adapter = manager.getAdapterByToolName("read_file") + expect(adapter.name).toBe("@perstack/base") + await manager.close() + }) + + it("throws when tool not found", async () => { + const manager = await SkillManager.fromExpert( + createExpert(), + {}, + { + env: {}, + factory: createMockFactory(), + }, + ) + expect(() => manager.getAdapterByToolName("nonexistent")).toThrow( + 'Tool "nonexistent" not found', + ) + await manager.close() + }) + }) + + describe("callTool", () => { + it("delegates to correct adapter", async () => { + const baseAdapter = createMockAdapter("@perstack/base", [ + { + skillName: "@perstack/base", + name: "read_file", + description: "Read file", + inputSchema: { type: "object" }, + interactive: false, + }, + ]) + const factory = createMockFactory({ "@perstack/base": baseAdapter }) + const manager = await SkillManager.fromExpert(createExpert(), {}, { env: {}, factory }) + const result = await manager.callTool("read_file", { path: "test.txt" }) + expect(baseAdapter.callTool).toHaveBeenCalledWith("read_file", { path: "test.txt" }) + expect(result).toHaveLength(1) + await manager.close() + }) + }) + + describe("getAdapters", () => { + it("returns readonly map of adapters", async () => { + const manager = await SkillManager.fromExpert( + createExpert(), + {}, + { + env: {}, + factory: createMockFactory(), + }, + ) + const adapters = manager.getAdapters() + expect(adapters.size).toBeGreaterThanOrEqual(1) + expect(adapters.has("@perstack/base")).toBe(true) + await manager.close() + }) + }) + + describe("dynamic management", () => { + it("addSkill adds a new adapter", async () => { + const manager = await SkillManager.fromExpert( + createExpert(), + {}, + { + env: {}, + factory: createMockFactory(), + }, + ) + const skill: McpStdioSkill = { + type: "mcpStdioSkill", + name: "new-skill", + command: "npx", + args: ["@new/pkg"], + requiredEnv: [], + pick: [], + omit: [], + lazyInit: false, + } as McpStdioSkill + await manager.addSkill(skill) + expect(manager.getAdapters().has("new-skill")).toBe(true) + await manager.close() + }) + + it("addSkill throws for duplicate name", async () => { + const manager = await SkillManager.fromExpert( + createExpert(), + {}, + { + env: {}, + factory: createMockFactory(), + }, + ) + const skill = createBaseSkill() + await expect(manager.addSkill(skill)).rejects.toThrow("already exists") + await manager.close() + }) + + it("removeSkill removes an adapter", async () => { + const manager = await SkillManager.fromExpert( + createExpert(), + {}, + { + env: {}, + factory: createMockFactory(), + }, + ) + await manager.removeSkill("@perstack/base") + expect(manager.getAdapters().has("@perstack/base")).toBe(false) + await manager.close() + }) + + it("removeSkill throws for missing adapter", async () => { + const manager = await SkillManager.fromExpert( + createExpert(), + {}, + { + env: {}, + factory: createMockFactory(), + }, + ) + await expect(manager.removeSkill("nonexistent")).rejects.toThrow("not found") + await manager.close() + }) + + it("addDelegate adds a delegate adapter", async () => { + const manager = await SkillManager.fromExpert( + createExpert(), + {}, + { + env: {}, + factory: createMockFactory(), + }, + ) + await manager.addDelegate(createDelegateExpert()) + expect(manager.getAdapters().has("@test/delegate")).toBe(true) + await manager.close() + }) + + it("removeDelegate removes a delegate adapter", async () => { + const delegateExpert = createDelegateExpert() + const expert = createExpert({ delegates: ["delegate-expert"] }) + const manager = await SkillManager.fromExpert( + expert, + { "delegate-expert": delegateExpert }, + { env: {}, factory: createMockFactory() }, + ) + await manager.removeDelegate("@test/delegate") + expect(manager.getAdapters().has("@test/delegate")).toBe(false) + await manager.close() + }) + }) + + describe("close", () => { + it("disconnects all adapters", async () => { + const baseAdapter = createMockAdapter("@perstack/base", []) + const factory = createMockFactory({ "@perstack/base": baseAdapter }) + const manager = await SkillManager.fromExpert(createExpert(), {}, { env: {}, factory }) + await manager.close() + expect(baseAdapter.disconnect).toHaveBeenCalled() + expect(manager.isClosed).toBe(true) + }) + + it("is idempotent", async () => { + const manager = await SkillManager.fromExpert( + createExpert(), + {}, + { + env: {}, + factory: createMockFactory(), + }, + ) + await manager.close() + await manager.close() // should not throw + expect(manager.isClosed).toBe(true) + }) + + it("rejects operations after close", async () => { + const manager = await SkillManager.fromExpert( + createExpert(), + {}, + { + env: {}, + factory: createMockFactory(), + }, + ) + await manager.close() + expect(() => manager.getToolDefinitions()).toThrow("closed") + expect(() => manager.getAdapterByToolName("read_file")).toThrow("closed") + await expect(manager.callTool("read_file", {})).rejects.toThrow("closed") + await expect(manager.addSkill(createBaseSkill({ name: "new" }))).rejects.toThrow("closed") + await expect(manager.removeSkill("base")).rejects.toThrow("closed") + await expect(manager.addDelegate(createDelegateExpert())).rejects.toThrow("closed") + await expect(manager.removeDelegate("delegate")).rejects.toThrow("closed") + }) + }) +}) + +describe("buildSkillFromInput", () => { + it("builds mcpStdioSkill with defaults", () => { + const result = buildSkillFromInput({ + name: "my-skill", + type: "mcpStdioSkill", + command: "npx", + packageName: "@my/pkg", + }) + expect(result).toStrictEqual({ + type: "mcpStdioSkill", + name: "my-skill", + description: undefined, + rule: undefined, + pick: [], + omit: [], + command: "npx", + packageName: "@my/pkg", + args: [], + requiredEnv: [], + lazyInit: false, + }) + }) + + it("builds mcpStdioSkill with all fields", () => { + const result = buildSkillFromInput({ + name: "my-skill", + type: "mcpStdioSkill", + command: "node", + packageName: "@my/pkg", + args: ["--flag"], + requiredEnv: ["API_KEY"], + description: "My skill", + rule: "Use carefully", + pick: ["toolA"], + omit: ["toolB"], + }) + expect(result).toStrictEqual({ + type: "mcpStdioSkill", + name: "my-skill", + description: "My skill", + rule: "Use carefully", + pick: ["toolA"], + omit: ["toolB"], + command: "node", + packageName: "@my/pkg", + args: ["--flag"], + requiredEnv: ["API_KEY"], + lazyInit: false, + }) + }) + + it("defaults command to npx for stdio skills", () => { + const result = buildSkillFromInput({ + name: "my-skill", + type: "mcpStdioSkill", + }) + expect(result.type).toBe("mcpStdioSkill") + expect((result as McpStdioSkill).command).toBe("npx") + }) + + it("builds mcpSseSkill", () => { + const result = buildSkillFromInput({ + name: "sse-skill", + type: "mcpSseSkill", + endpoint: "http://localhost:3000/sse", + description: "SSE skill", + }) + expect(result).toStrictEqual({ + type: "mcpSseSkill", + name: "sse-skill", + description: "SSE skill", + rule: undefined, + pick: [], + omit: [], + endpoint: "http://localhost:3000/sse", + }) + }) + + it("throws when endpoint missing for mcpSseSkill", () => { + expect(() => + buildSkillFromInput({ + name: "sse-skill", + type: "mcpSseSkill", + }), + ).toThrow("endpoint is required for mcpSseSkill") + }) +}) + +describe("InMemoryBaseSkillAdapter binding", () => { + it("bindSkillManagement replaces placeholder methods", () => { + const skill = createBaseSkill() + const adapter = new InMemoryBaseSkillAdapter(skill) + const addSkill = vi.fn().mockResolvedValue({ tools: [] }) + const removeSkill = vi.fn().mockResolvedValue(undefined) + const addDelegate = vi.fn().mockResolvedValue({ delegateToolName: "d" }) + const removeDelegate = vi.fn().mockResolvedValue(undefined) + adapter.bindSkillManagement({ addSkill, removeSkill, addDelegate, removeDelegate }) + // No error means the binding succeeded — real verification happens through integration + }) + + it("fromExpert binds skill management to in-memory base adapter", async () => { + const baseAdapter = createMockAdapter("@perstack/base", [ + { + skillName: "@perstack/base", + name: "addSkill", + description: "Add skill", + inputSchema: { type: "object" }, + interactive: false, + }, + ]) + // Add bindSkillManagement to mock and make it instanceof InMemoryBaseSkillAdapter-like + const bindFn = vi.fn() + ;(baseAdapter as unknown as { bindSkillManagement: typeof bindFn }).bindSkillManagement = bindFn + // Override prototype check + Object.setPrototypeOf(baseAdapter, InMemoryBaseSkillAdapter.prototype) + + const factory = createMockFactory({ "@perstack/base": baseAdapter }) + const expert = createExpert() + const manager = await SkillManager.fromExpert(expert, {}, { env: {}, factory }) + expect(bindFn).toHaveBeenCalledOnce() + expect(bindFn).toHaveBeenCalledWith( + expect.objectContaining({ + addSkill: expect.any(Function), + removeSkill: expect.any(Function), + addDelegate: expect.any(Function), + removeDelegate: expect.any(Function), + }), + ) + await manager.close() + }) +}) diff --git a/packages/skill-manager/src/skill-manager.ts b/packages/skill-manager/src/skill-manager.ts new file mode 100644 index 00000000..eb968499 --- /dev/null +++ b/packages/skill-manager/src/skill-manager.ts @@ -0,0 +1,505 @@ +import type { + Expert, + InteractiveSkill, + McpSseSkill, + McpStdioSkill, + ToolDefinition, +} from "@perstack/core" +import { InMemoryBaseSkillAdapter } from "./adapters/in-memory-base-adapter.js" +import { LockfileSkillAdapter } from "./adapters/lockfile-adapter.js" +import type { SkillAdapter } from "./skill-adapter.js" +import type { SkillAdapterFactory, SkillAdapterFactoryContext } from "./skill-adapter-factory.js" +import { defaultSkillAdapterFactory } from "./skill-adapter-factory.js" +import type { + CollectedToolDefinition, + LockfileInitOptions, + SkillManagerOptions, + ToolCallResult, +} from "./types.js" +import { isBaseSkill, shouldUseBundledBase } from "./utils/base-skill-helpers.js" + +/** + * Initialize adapters and cleanup all on failure. + */ +async function connectAdaptersWithCleanup( + adapters: SkillAdapter[], + allAdapters: SkillAdapter[], +): Promise { + const results = await Promise.allSettled(adapters.map((a) => a.connect())) + const firstRejected = results.find((r) => r.status === "rejected") + if (firstRejected) { + await Promise.all(allAdapters.map((a) => a.disconnect().catch(() => {}))) + throw (firstRejected as PromiseRejectedResult).reason + } +} + +/** + * Apply perstackBaseSkillCommand override to a skill if applicable. + */ +function applyBaseSkillCommandOverride( + skill: McpStdioSkill | McpSseSkill, + perstackBaseSkillCommand?: string[], +): McpStdioSkill | McpSseSkill { + if (!perstackBaseSkillCommand || skill.type !== "mcpStdioSkill") { + return skill + } + 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) { + return skill + } + return { + ...skill, + command: overrideCommand, + packageName: undefined, + args: overrideArgs, + lazyInit: false, + } as McpStdioSkill + } + return skill +} + +/** + * Check if all required environment variables are present for a skill. + * Skills with missing env vars are skipped (not available for this run). + */ +function hasRequiredEnv(skill: McpStdioSkill | McpSseSkill, env: Record): boolean { + if (skill.type !== "mcpStdioSkill") return true + return skill.requiredEnv.every((envName) => !!env[envName]) +} + +/** + * Convert addSkill tool input into a McpStdioSkill or McpSseSkill. + */ +export function buildSkillFromInput(input: { + name: string + type: "mcpStdioSkill" | "mcpSseSkill" + command?: string + packageName?: string + args?: string[] + requiredEnv?: string[] + endpoint?: string + description?: string + rule?: string + pick?: string[] + omit?: string[] +}): McpStdioSkill | McpSseSkill { + if (input.type === "mcpSseSkill") { + if (!input.endpoint) { + throw new Error("endpoint is required for mcpSseSkill") + } + return { + type: "mcpSseSkill", + name: input.name, + description: input.description, + rule: input.rule, + pick: input.pick ?? [], + omit: input.omit ?? [], + endpoint: input.endpoint, + } as McpSseSkill + } + return { + type: "mcpStdioSkill", + name: input.name, + description: input.description, + rule: input.rule, + pick: input.pick ?? [], + omit: input.omit ?? [], + command: input.command ?? "npx", + packageName: input.packageName, + args: input.args ?? [], + requiredEnv: input.requiredEnv ?? [], + lazyInit: false, + } as McpStdioSkill +} + +/** + * SkillManager manages all adapters for a single Expert. + * Provides static factory methods for creation and dynamic management of skills/delegates. + */ +export class SkillManager { + private _adapters: Map + private _closed = false + private _factory: SkillAdapterFactory + private _env: Record + + private constructor( + adapters: Map, + factory: SkillAdapterFactory, + env: Record, + ) { + this._adapters = adapters + this._factory = factory + this._env = env + } + + // ---- Static factories ---- + + static async fromExpert( + expert: Expert, + experts: Record, + options: SkillManagerOptions, + ): Promise { + const { + env, + perstackBaseSkillCommand, + isDelegatedRun, + factory = defaultSkillAdapterFactory, + } = options + const { skills } = expert + + if (!skills["@perstack/base"]) { + throw new Error("Base skill is not defined") + } + + const factoryCtx: SkillAdapterFactoryContext = { + env, + } + + const allAdapters: SkillAdapter[] = [] + const baseSkill = skills["@perstack/base"] + + // Determine if we should use bundled in-memory base + const useBundledBase = + (baseSkill.type === "mcpStdioSkill" || baseSkill.type === "mcpSseSkill") && + shouldUseBundledBase(baseSkill, perstackBaseSkillCommand) + + // Process base skill first + if (useBundledBase && baseSkill.type === "mcpStdioSkill") { + if (baseSkill.requiredEnv.length > 0) { + console.warn( + `[perstack] requiredEnv is ignored for bundled @perstack/base. Pin a version to enable it.`, + ) + } + const baseAdapter = factory.createInMemoryBase(baseSkill, factoryCtx) + allAdapters.push(baseAdapter) + await connectAdaptersWithCleanup([baseAdapter], allAdapters) + } + + // Process MCP skills (excluding base if using bundled, and skills with missing env vars) + const mcpSkills = Object.values(skills) + .filter( + (skill): skill is McpStdioSkill | McpSseSkill => + skill.type === "mcpStdioSkill" || skill.type === "mcpSseSkill", + ) + .filter((skill) => !(useBundledBase && isBaseSkill(skill))) + .filter((skill) => hasRequiredEnv(skill, env)) + .map((skill) => applyBaseSkillCommandOverride(skill, perstackBaseSkillCommand)) + + const mcpAdapters = mcpSkills.map((skill) => { + const adapter = factory.createMcp(skill, factoryCtx) + allAdapters.push(adapter) + return adapter + }) + await connectAdaptersWithCleanup(mcpAdapters, allAdapters) + + // Process interactive skills (not for delegated runs) + if (!isDelegatedRun) { + const interactiveSkills = Object.values(skills).filter( + (skill): skill is InteractiveSkill => skill.type === "interactiveSkill", + ) + const interactiveAdapters = interactiveSkills.map((skill) => { + const adapter = factory.createInteractive(skill, factoryCtx) + allAdapters.push(adapter) + return adapter + }) + await connectAdaptersWithCleanup(interactiveAdapters, allAdapters) + } + + // Process delegate experts + const delegateAdapters: SkillAdapter[] = [] + for (const delegateExpertName of expert.delegates) { + const delegate = experts[delegateExpertName] + if (!delegate) { + await Promise.all(allAdapters.map((a) => a.disconnect().catch(() => {}))) + throw new Error(`Delegate expert "${delegateExpertName}" not found in experts`) + } + const adapter = factory.createDelegate(delegate, factoryCtx) + allAdapters.push(adapter) + delegateAdapters.push(adapter) + } + await connectAdaptersWithCleanup(delegateAdapters, allAdapters) + + const adapterMap = new Map() + for (const adapter of allAdapters) { + adapterMap.set(adapter.name, adapter) + } + const sm = new SkillManager(adapterMap, factory, env) + + // Bind skill management callbacks to the in-memory base adapter + for (const adapter of allAdapters) { + if (adapter instanceof InMemoryBaseSkillAdapter) { + adapter.bindSkillManagement({ + addSkill: async (input) => { + const skill = buildSkillFromInput(input) + await sm.addSkill(skill) + const added = sm.getAdapters().get(skill.name) + return { tools: added?.getToolDefinitions().map((t) => t.name) ?? [] } + }, + removeSkill: (name) => sm.removeSkill(name), + addDelegate: async (key) => { + const delegateExpert = experts[key] + if (!delegateExpert) throw new Error(`Expert "${key}" not found`) + await sm.addDelegate(delegateExpert) + const added = sm.getAdapters().get(delegateExpert.name) + const toolName = added?.getToolDefinitions()[0]?.name ?? delegateExpert.name + return { delegateToolName: toolName } + }, + removeDelegate: (name) => sm.removeDelegate(name), + }) + break + } + } + + return sm + } + + static async fromLockfile( + expert: Expert, + experts: Record, + options: LockfileInitOptions, + ): Promise { + const { + env, + perstackBaseSkillCommand, + isDelegatedRun, + lockfileToolDefinitions, + factory = defaultSkillAdapterFactory, + } = options + const { skills } = expert + + if (!skills["@perstack/base"]) { + throw new Error("Base skill is not defined") + } + + const factoryCtx: SkillAdapterFactoryContext = { + env, + } + + const allAdapters: SkillAdapter[] = [] + + // Create lockfile adapters for MCP skills (skip skills with missing env vars) + const mcpSkills = Object.values(skills) + .filter( + (skill): skill is McpStdioSkill | McpSseSkill => + skill.type === "mcpStdioSkill" || skill.type === "mcpSseSkill", + ) + .filter((skill) => hasRequiredEnv(skill, env)) + for (const skill of mcpSkills) { + const skillToolDefs = lockfileToolDefinitions[skill.name] ?? [] + const adapter = new LockfileSkillAdapter({ + skill, + toolDefinitions: skillToolDefs, + env, + perstackBaseSkillCommand, + factory, + }) + await adapter.connect() + allAdapters.push(adapter) + } + + // Process interactive skills (not for delegated runs) + if (!isDelegatedRun) { + const interactiveSkills = Object.values(skills).filter( + (skill): skill is InteractiveSkill => skill.type === "interactiveSkill", + ) + const interactiveAdapters = interactiveSkills.map((skill) => { + const adapter = factory.createInteractive(skill, factoryCtx) + allAdapters.push(adapter) + return adapter + }) + await connectAdaptersWithCleanup(interactiveAdapters, allAdapters) + } + + // Process delegate experts + const delegateAdapters: SkillAdapter[] = [] + for (const delegateExpertName of expert.delegates) { + const delegate = experts[delegateExpertName] + if (!delegate) { + await Promise.all(allAdapters.map((a) => a.disconnect().catch(() => {}))) + throw new Error(`Delegate expert "${delegateExpertName}" not found in experts`) + } + const adapter = factory.createDelegate(delegate, factoryCtx) + allAdapters.push(adapter) + delegateAdapters.push(adapter) + } + await connectAdaptersWithCleanup(delegateAdapters, allAdapters) + + const adapterMap = new Map() + for (const adapter of allAdapters) { + adapterMap.set(adapter.name, adapter) + } + return new SkillManager(adapterMap, factory, env) + } + + static async collectToolDefinitions( + expert: Expert, + options: { + env: Record + perstackBaseSkillCommand?: string[] + factory?: SkillAdapterFactory + }, + ): Promise { + const { env, perstackBaseSkillCommand, factory = defaultSkillAdapterFactory } = options + const { skills } = expert + + if (!skills["@perstack/base"]) { + throw new Error("Base skill is not defined") + } + + const factoryCtx: SkillAdapterFactoryContext = { env } + const allAdapters: SkillAdapter[] = [] + const baseSkill = skills["@perstack/base"] + const useBundledBase = + (baseSkill.type === "mcpStdioSkill" || baseSkill.type === "mcpSseSkill") && + shouldUseBundledBase(baseSkill, perstackBaseSkillCommand) + + try { + if (useBundledBase && baseSkill.type === "mcpStdioSkill") { + const baseAdapter = factory.createInMemoryBase(baseSkill, factoryCtx) + allAdapters.push(baseAdapter) + await connectAdaptersWithCleanup([baseAdapter], allAdapters) + } + + const mcpSkills = Object.values(skills) + .filter( + (skill): skill is McpStdioSkill | McpSseSkill => + skill.type === "mcpStdioSkill" || skill.type === "mcpSseSkill", + ) + .filter((skill) => !(useBundledBase && isBaseSkill(skill))) + .filter((skill) => hasRequiredEnv(skill, env)) + .map((skill) => applyBaseSkillCommandOverride(skill, perstackBaseSkillCommand)) + + const mcpAdapters = mcpSkills.map((skill) => { + const adapter = factory.createMcp(skill, factoryCtx) + allAdapters.push(adapter) + return adapter + }) + await connectAdaptersWithCleanup(mcpAdapters, allAdapters) + + const interactiveSkills = Object.values(skills).filter( + (skill): skill is InteractiveSkill => skill.type === "interactiveSkill", + ) + const interactiveAdapters = interactiveSkills.map((skill) => { + const adapter = factory.createInteractive(skill, factoryCtx) + allAdapters.push(adapter) + return adapter + }) + await connectAdaptersWithCleanup(interactiveAdapters, allAdapters) + + const toolDefinitions: CollectedToolDefinition[] = [] + for (const adapter of allAdapters) { + const definitions = adapter.getToolDefinitions() + for (const def of definitions) { + toolDefinitions.push({ + skillName: def.skillName, + name: def.name, + description: def.description, + inputSchema: def.inputSchema, + }) + } + } + return toolDefinitions + } finally { + await Promise.all(allAdapters.map((a) => a.disconnect().catch(() => {}))) + } + } + + // ---- Dynamic management ---- + + async addSkill(skill: McpStdioSkill | McpSseSkill): Promise { + this._ensureNotClosed() + if (this._adapters.has(skill.name)) { + throw new Error(`Adapter "${skill.name}" already exists`) + } + const adapter = this._factory.createMcp(skill, { env: this._env }) + await adapter.connect() + this._adapters.set(adapter.name, adapter) + } + + async removeSkill(skillName: string): Promise { + this._ensureNotClosed() + const adapter = this._adapters.get(skillName) + if (!adapter) { + throw new Error(`Adapter "${skillName}" not found`) + } + await adapter.disconnect() + this._adapters.delete(skillName) + } + + async addDelegate(expert: Expert): Promise { + this._ensureNotClosed() + if (this._adapters.has(expert.name)) { + throw new Error(`Adapter "${expert.name}" already exists`) + } + const adapter = this._factory.createDelegate(expert, { env: this._env }) + await adapter.connect() + this._adapters.set(adapter.name, adapter) + } + + async removeDelegate(expertName: string): Promise { + this._ensureNotClosed() + const adapter = this._adapters.get(expertName) + if (!adapter || adapter.type !== "delegate") { + throw new Error(`Delegate adapter "${expertName}" not found`) + } + await adapter.disconnect() + this._adapters.delete(expertName) + } + + // ---- Tool access ---- + + getToolDefinitions(): ToolDefinition[] { + this._ensureNotClosed() + const allTools: ToolDefinition[] = [] + for (const adapter of this._adapters.values()) { + allTools.push(...adapter.getToolDefinitions()) + } + return allTools + } + + getAdapterByToolName(toolName: string): SkillAdapter { + this._ensureNotClosed() + for (const adapter of this._adapters.values()) { + const tools = adapter.getToolDefinitions() + if (tools.some((t) => t.name === toolName)) { + return adapter + } + } + throw new Error(`Tool "${toolName}" not found`) + } + + async callTool(toolName: string, input: Record): Promise { + const adapter = this.getAdapterByToolName(toolName) + return adapter.callTool(toolName, input) + } + + getAdapters(): ReadonlyMap { + return this._adapters + } + + // ---- Lifecycle ---- + + async close(): Promise { + if (this._closed) { + return + } + this._closed = true + await Promise.all( + Array.from(this._adapters.values()).map((a) => a.disconnect().catch(() => {})), + ) + } + + get isClosed(): boolean { + return this._closed + } + + private _ensureNotClosed(): void { + if (this._closed) { + throw new Error("SkillManager is closed") + } + } +} diff --git a/packages/runtime/src/skill-manager/transport-factory.ts b/packages/skill-manager/src/transport-factory.ts similarity index 88% rename from packages/runtime/src/skill-manager/transport-factory.ts rename to packages/skill-manager/src/transport-factory.ts index 4a2153a4..781ef5b4 100644 --- a/packages/runtime/src/skill-manager/transport-factory.ts +++ b/packages/skill-manager/src/transport-factory.ts @@ -21,10 +21,6 @@ export interface SseTransportOptions { export interface TransportFactory { createStdio(options: StdioTransportOptions): StdioClientTransport createSse(options: SseTransportOptions): Transport - /** - * Create a linked pair of in-memory transports for in-process MCP communication. - * Returns [clientTransport, serverTransport]. - */ createInMemoryPair(): [Transport, Transport] } @@ -50,7 +46,5 @@ export class DefaultTransportFactory implements TransportFactory { } } -/** - * Default transport factory instance. - */ +/** Default transport factory instance */ export const defaultTransportFactory = new DefaultTransportFactory() diff --git a/packages/skill-manager/src/types.ts b/packages/skill-manager/src/types.ts new file mode 100644 index 00000000..4d20e577 --- /dev/null +++ b/packages/skill-manager/src/types.ts @@ -0,0 +1,44 @@ +import type { + FileInlinePart, + ImageInlinePart, + LockfileToolDefinition, + RunEvent, + RuntimeEvent, + TextPart, +} from "@perstack/core" +import type { SkillAdapterFactory } from "./skill-adapter-factory.js" + +/** State of a SkillAdapter in its lifecycle */ +export type SkillAdapterState = "idle" | "initializing" | "ready" | "error" | "closed" + +/** Lifecycle event emitted by a SkillAdapter */ +export interface SkillAdapterLifecycleEvent { + type: "connecting" | "connected" | "error" | "disconnected" | "stderr" + adapterName: string + data?: Record +} + +/** Result of a tool call */ +export type ToolCallResult = Array + +/** Options for SkillManager.fromExpert() */ +export interface SkillManagerOptions { + env: Record + perstackBaseSkillCommand?: string[] + isDelegatedRun?: boolean + factory?: SkillAdapterFactory + runtimeEventListener?: (event: RunEvent | RuntimeEvent) => void +} + +/** Options for SkillManager.fromLockfile() */ +export interface LockfileInitOptions extends SkillManagerOptions { + lockfileToolDefinitions: Record +} + +/** Collected tool definition for lockfile generation */ +export interface CollectedToolDefinition { + skillName: string + name: string + description?: string + inputSchema: Record +} diff --git a/packages/skill-manager/src/utils/base-skill-helpers.test.ts b/packages/skill-manager/src/utils/base-skill-helpers.test.ts new file mode 100644 index 00000000..78caf3c0 --- /dev/null +++ b/packages/skill-manager/src/utils/base-skill-helpers.test.ts @@ -0,0 +1,127 @@ +import type { McpSseSkill, McpStdioSkill } from "@perstack/core" +import { describe, expect, it } from "vitest" +import { hasExplicitBaseVersion, isBaseSkill, shouldUseBundledBase } from "./base-skill-helpers.js" + +const createMcpStdioSkill = (overrides: Partial = {}): McpStdioSkill => ({ + name: "@perstack/base", + type: "mcpStdioSkill", + command: "npx", + packageName: "@perstack/base", + args: [], + pick: [], + omit: [], + requiredEnv: [], + lazyInit: false, + ...overrides, +}) + +const createMcpSseSkill = (overrides: Partial = {}): McpSseSkill => ({ + name: "other-skill", + type: "mcpSseSkill", + endpoint: "https://example.com/sse", + pick: [], + omit: [], + ...overrides, +}) + +describe("@perstack/skill-manager: hasExplicitBaseVersion", () => { + it("returns true for packageName with version", () => { + const skill = createMcpStdioSkill({ packageName: "@perstack/base@0.0.34" }) + expect(hasExplicitBaseVersion(skill)).toBe(true) + }) + + it("returns true for args with version", () => { + const skill = createMcpStdioSkill({ + packageName: undefined, + args: ["@perstack/base@1.2.3"], + }) + expect(hasExplicitBaseVersion(skill)).toBe(true) + }) + + it("returns false for packageName without version", () => { + const skill = createMcpStdioSkill({ packageName: "@perstack/base" }) + expect(hasExplicitBaseVersion(skill)).toBe(false) + }) + + it("returns false for args without version", () => { + const skill = createMcpStdioSkill({ + packageName: undefined, + args: ["@perstack/base"], + }) + expect(hasExplicitBaseVersion(skill)).toBe(false) + }) + + it("returns false when no packageName or args", () => { + const skill = createMcpStdioSkill({ + command: "node", + packageName: undefined, + args: [], + }) + expect(hasExplicitBaseVersion(skill)).toBe(false) + }) +}) + +describe("@perstack/skill-manager: isBaseSkill", () => { + it("returns true for skill named @perstack/base", () => { + const skill = createMcpStdioSkill({ name: "@perstack/base" }) + expect(isBaseSkill(skill)).toBe(true) + }) + + it("returns true for skill with packageName starting with @perstack/base", () => { + const skill = createMcpStdioSkill({ + name: "some-skill", + packageName: "@perstack/base@1.0.0", + }) + expect(isBaseSkill(skill)).toBe(true) + }) + + it("returns true for skill with args containing @perstack/base", () => { + const skill = createMcpStdioSkill({ + name: "some-skill", + packageName: undefined, + args: ["-y", "@perstack/base"], + }) + expect(isBaseSkill(skill)).toBe(true) + }) + + it("returns false for non-base skill", () => { + const skill = createMcpStdioSkill({ + name: "other-skill", + packageName: "@perstack/other", + }) + expect(isBaseSkill(skill)).toBe(false) + }) + + it("returns false for SSE skill without base name", () => { + const skill = createMcpSseSkill({ name: "other-skill" }) + expect(isBaseSkill(skill)).toBe(false) + }) +}) + +describe("@perstack/skill-manager: shouldUseBundledBase", () => { + it("returns true for base skill without explicit version and no custom command", () => { + const skill = createMcpStdioSkill({ packageName: "@perstack/base" }) + expect(shouldUseBundledBase(skill)).toBe(true) + }) + + it("returns false when custom command is provided", () => { + const skill = createMcpStdioSkill({ packageName: "@perstack/base" }) + expect(shouldUseBundledBase(skill, ["node", "custom.js"])).toBe(false) + }) + + it("returns false for SSE skills", () => { + const skill = createMcpSseSkill({ name: "@perstack/base" }) + expect(shouldUseBundledBase(skill)).toBe(false) + }) + + it("returns false for skill with explicit version", () => { + const skill = createMcpStdioSkill({ packageName: "@perstack/base@0.0.34" }) + expect(shouldUseBundledBase(skill)).toBe(false) + }) + + it("returns true when perstackBaseSkillCommand is empty array", () => { + const skill = createMcpStdioSkill({ packageName: "@perstack/base" }) + // Empty array is still falsy for the check + expect(shouldUseBundledBase(skill, [])).toBe(true) + }) +}) diff --git a/packages/skill-manager/src/utils/base-skill-helpers.ts b/packages/skill-manager/src/utils/base-skill-helpers.ts new file mode 100644 index 00000000..4dd9e177 --- /dev/null +++ b/packages/skill-manager/src/utils/base-skill-helpers.ts @@ -0,0 +1,80 @@ +import type { McpSseSkill, McpStdioSkill } from "@perstack/core" + +/** + * Check if a base skill has an explicit version specified. + * Examples of versioned packages: + * - packageName: "@perstack/base@0.0.34" + * - args: ["@perstack/base@0.0.34"] + */ +export function hasExplicitBaseVersion(skill: McpStdioSkill): boolean { + // Check packageName for version: @perstack/base@1.2.3 + // The @ symbol appears twice: once at the start of the scope, once before the version + if (skill.packageName) { + const atSignIndex = skill.packageName.indexOf("@", 1) // Skip the leading @ in @perstack + if (atSignIndex > 0) { + return true + } + } + + // Check args for versioned package + if (skill.args) { + for (const arg of skill.args) { + if (arg.startsWith("@perstack/base@")) { + // Check if there's a version after @perstack/base@ + const versionStart = "@perstack/base@".length + if (arg.length > versionStart) { + return true + } + } + } + } + + return false +} + +/** + * Check if a skill is the @perstack/base skill (by name or package). + */ +export function isBaseSkill(skill: McpStdioSkill | McpSseSkill): boolean { + if (skill.name === "@perstack/base") { + return true + } + if (skill.type === "mcpStdioSkill") { + const stdioSkill = skill as McpStdioSkill + if (stdioSkill.packageName?.startsWith("@perstack/base")) { + return true + } + if (stdioSkill.args?.some((arg) => arg.startsWith("@perstack/base"))) { + return true + } + } + return false +} + +/** + * Determine if the bundled in-memory base should be used. + * Returns true if: + * - No perstackBaseSkillCommand override is set + * - The base skill doesn't have an explicit version pinned + */ +export function shouldUseBundledBase( + baseSkill: McpStdioSkill | McpSseSkill, + perstackBaseSkillCommand?: string[], +): boolean { + // If a custom command is specified, don't use bundled base + if (perstackBaseSkillCommand && perstackBaseSkillCommand.length > 0) { + return false + } + + // SSE skills can't use bundled base + if (baseSkill.type === "mcpSseSkill") { + return false + } + + // If explicit version is specified, use npx instead of bundled + if (hasExplicitBaseVersion(baseSkill)) { + return false + } + + return true +} diff --git a/packages/runtime/src/skill-manager/command-args.test.ts b/packages/skill-manager/src/utils/command-args.test.ts similarity index 98% rename from packages/runtime/src/skill-manager/command-args.test.ts rename to packages/skill-manager/src/utils/command-args.test.ts index 1640f075..745eac1d 100644 --- a/packages/runtime/src/skill-manager/command-args.test.ts +++ b/packages/skill-manager/src/utils/command-args.test.ts @@ -14,7 +14,7 @@ function createSkill(overrides: Partial = {}): McpStdioSkill { } as McpStdioSkill } -describe("@perstack/runtime: getCommandArgs", () => { +describe("@perstack/skill-manager: getCommandArgs", () => { describe("packageName handling", () => { it("uses packageName when provided", () => { const skill = createSkill({ packageName: "@example/pkg" }) diff --git a/packages/runtime/src/skill-manager/command-args.ts b/packages/skill-manager/src/utils/command-args.ts similarity index 100% rename from packages/runtime/src/skill-manager/command-args.ts rename to packages/skill-manager/src/utils/command-args.ts diff --git a/packages/runtime/src/skill-manager/ip-validator.test.ts b/packages/skill-manager/src/utils/ip-validator.test.ts similarity index 97% rename from packages/runtime/src/skill-manager/ip-validator.test.ts rename to packages/skill-manager/src/utils/ip-validator.test.ts index a3e847e7..31acc387 100644 --- a/packages/runtime/src/skill-manager/ip-validator.test.ts +++ b/packages/skill-manager/src/utils/ip-validator.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import { isPrivateOrLocalIP } from "./ip-validator.js" -describe("@perstack/runtime: isPrivateOrLocalIP", () => { +describe("@perstack/skill-manager: isPrivateOrLocalIP", () => { describe("local hostnames", () => { it("returns true for localhost", () => { expect(isPrivateOrLocalIP("localhost")).toBe(true) diff --git a/packages/runtime/src/skill-manager/ip-validator.ts b/packages/skill-manager/src/utils/ip-validator.ts similarity index 100% rename from packages/runtime/src/skill-manager/ip-validator.ts rename to packages/skill-manager/src/utils/ip-validator.ts diff --git a/packages/runtime/src/skill-manager/mcp-converters.test.ts b/packages/skill-manager/src/utils/mcp-converters.test.ts similarity index 96% rename from packages/runtime/src/skill-manager/mcp-converters.test.ts rename to packages/skill-manager/src/utils/mcp-converters.test.ts index 1cc70a9f..2e5d6614 100644 --- a/packages/runtime/src/skill-manager/mcp-converters.test.ts +++ b/packages/skill-manager/src/utils/mcp-converters.test.ts @@ -15,7 +15,7 @@ class MockMcpError extends Error { } } -describe("@perstack/runtime: handleToolError", () => { +describe("@perstack/skill-manager: handleToolError", () => { it("converts McpError to TextPart array", () => { const error = new MockMcpError("Tool execution failed") const result = handleToolError(error, "test-tool", MockMcpError as never) @@ -39,7 +39,7 @@ describe("@perstack/runtime: handleToolError", () => { }) }) -describe("@perstack/runtime: convertToolResult", () => { +describe("@perstack/skill-manager: convertToolResult", () => { it("returns empty result message when content is empty", () => { const result: CallToolResult = { content: [] } const converted = convertToolResult(result, "test-tool", { arg: "value" }) @@ -80,7 +80,7 @@ describe("@perstack/runtime: convertToolResult", () => { }) }) -describe("@perstack/runtime: convertPart", () => { +describe("@perstack/skill-manager: convertPart", () => { describe("text parts", () => { it("converts text part correctly", () => { const part = { type: "text" as const, text: "Hello" } @@ -145,7 +145,7 @@ describe("@perstack/runtime: convertPart", () => { }) }) -describe("@perstack/runtime: convertResource", () => { +describe("@perstack/skill-manager: convertResource", () => { it("converts text resource to textPart", () => { const resource = { uri: "file://test", mimeType: "text/plain", text: "Hello" } const result = convertResource(resource) diff --git a/packages/runtime/src/skill-manager/mcp-converters.ts b/packages/skill-manager/src/utils/mcp-converters.ts similarity index 100% rename from packages/runtime/src/skill-manager/mcp-converters.ts rename to packages/skill-manager/src/utils/mcp-converters.ts diff --git a/packages/skill-manager/tsconfig.json b/packages/skill-manager/tsconfig.json new file mode 100644 index 00000000..ed443cff --- /dev/null +++ b/packages/skill-manager/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75ca863b..5db2a352 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -254,9 +254,9 @@ importers: '@perstack/perstack-toml': specifier: workspace:* version: link:../perstack-toml - '@perstack/runtime': + '@perstack/skill-manager': specifier: workspace:* - version: link:../runtime + version: link:../skill-manager devDependencies: '@perstack/tui': specifier: workspace:* @@ -666,21 +666,18 @@ importers: '@ai-sdk/openai': specifier: ^3.0.29 version: 3.0.29(zod@4.3.6) - '@modelcontextprotocol/sdk': - specifier: ^1.26.0 - version: 1.26.0(zod@4.3.6) '@paralleldrive/cuid2': specifier: ^3.3.0 version: 3.3.0 '@perstack/api-client': specifier: ^0.0.55 version: 0.0.55(@perstack/core@packages+core)(zod@4.3.6) - '@perstack/base': - specifier: workspace:* - version: link:../../apps/base '@perstack/core': specifier: workspace:* version: link:../core + '@perstack/skill-manager': + specifier: workspace:* + version: link:../skill-manager ai: specifier: ^6.0.86 version: 6.0.86(zod@4.3.6) @@ -740,6 +737,37 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/skill-manager: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.26.0 + version: 1.26.0(zod@4.3.6) + '@paralleldrive/cuid2': + specifier: ^3.3.0 + version: 3.3.0 + '@perstack/base': + specifier: workspace:* + version: link:../../apps/base + '@perstack/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@tsconfig/node22': + specifier: ^22.0.5 + version: 22.0.5 + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/tui: dependencies: '@paralleldrive/cuid2':