From 81c5a2cbf0204d5eb319a4b2dbbbdd5d1a164fde Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Mon, 16 Feb 2026 14:26:52 +0000 Subject: [PATCH 1/6] refactor: introduce @perstack/skill-manager package and migrate runtime - Create packages/skill-manager with 3-layer architecture: - SkillAdapter (abstract): unified interface for MCP/delegate/interactive skills - SkillManager: 1-Expert-1-SkillManager lifecycle manager with dynamic add/remove - Utilities: command-args, ip-validator, mcp-converters, base-skill-helpers - Migrate runtime state machine from Record to SkillManager - Update CoordinatorExecutor to use SkillManager.fromExpert/fromLockfile - Refactor tool-execution layer (executor-factory, mcp-executor, tool-classifier) - Add comprehensive tests for all adapters and SkillManager Co-Authored-By: Claude Opus 4.6 --- .changeset/skill-manager-package.md | 6 + packages/installer/package.json | 3 +- packages/installer/src/handler.ts | 4 +- packages/runtime/package.json | 1 + packages/runtime/src/helpers/tool-set.ts | 13 + packages/runtime/src/index.ts | 20 +- .../coordinator-executor.test.ts | 26 +- .../src/orchestration/coordinator-executor.ts | 24 +- .../src/state-machine/actor-factory.test.ts | 3 +- .../src/state-machine/coordinator.test.ts | 49 +- .../runtime/src/state-machine/coordinator.ts | 22 +- .../runtime/src/state-machine/executor.ts | 4 +- packages/runtime/src/state-machine/machine.ts | 8 +- .../states/calling-delegates.test.ts | 217 +++--- .../state-machine/states/calling-delegates.ts | 53 +- .../states/calling-interactive-tools.test.ts | 17 +- .../states/calling-mcp-tools.test.ts | 281 ++++---- .../state-machine/states/calling-mcp-tools.ts | 12 +- .../states/finishing-step.test.ts | 13 +- .../states/generating-run-result.test.ts | 23 +- .../states/generating-tool-call.test.ts | 114 ++-- .../states/generating-tool-call.ts | 46 +- .../src/state-machine/states/init.test.ts | 25 +- .../states/preparing-for-step.test.ts | 9 +- .../states/resolving-tool-result.test.ts | 13 +- .../states/resuming-from-stop.test.ts | 17 +- .../tool-execution/executor-factory.test.ts | 43 +- .../src/tool-execution/executor-factory.ts | 6 +- .../src/tool-execution/mcp-executor.test.ts | 77 +-- .../src/tool-execution/mcp-executor.ts | 20 +- .../tool-execution/tool-classifier.test.ts | 159 +++-- .../src/tool-execution/tool-classifier.ts | 32 +- .../src/tool-execution/tool-executor.ts | 4 +- packages/runtime/test/run-params.ts | 72 +- packages/skill-manager/package.json | 44 ++ .../src/adapters/delegate-adapter.test.ts | 130 ++++ .../src/adapters/delegate-adapter.ts | 42 ++ .../adapters/in-memory-base-adapter.test.ts | 203 ++++++ .../src/adapters/in-memory-base-adapter.ts | 98 +++ .../src/adapters/interactive-adapter.test.ts | 130 ++++ .../src/adapters/interactive-adapter.ts | 37 ++ .../src/adapters/lockfile-adapter.test.ts | 282 ++++++++ .../src/adapters/lockfile-adapter.ts | 147 +++++ .../src/adapters/mcp-adapter.test.ts | 252 +++++++ .../skill-manager/src/adapters/mcp-adapter.ts | 138 ++++ .../skill-manager/src/delegate-registry.ts | 11 + packages/skill-manager/src/index.ts | 46 ++ .../src/skill-adapter-factory.ts | 49 ++ packages/skill-manager/src/skill-adapter.ts | 102 +++ .../skill-manager/src/skill-manager.test.ts | 622 ++++++++++++++++++ packages/skill-manager/src/skill-manager.ts | 419 ++++++++++++ .../skill-manager/src/transport-factory.ts | 50 ++ packages/skill-manager/src/types.ts | 44 ++ .../src/utils/base-skill-helpers.test.ts | 127 ++++ .../src/utils/base-skill-helpers.ts | 80 +++ .../src/utils/command-args.test.ts | 90 +++ .../skill-manager/src/utils/command-args.ts | 34 + .../src/utils/ip-validator.test.ts | 93 +++ .../skill-manager/src/utils/ip-validator.ts | 49 ++ .../src/utils/mcp-converters.test.ts | 185 ++++++ .../skill-manager/src/utils/mcp-converters.ts | 109 +++ packages/skill-manager/tsconfig.json | 5 + pnpm-lock.yaml | 37 ++ 63 files changed, 4397 insertions(+), 694 deletions(-) create mode 100644 .changeset/skill-manager-package.md create mode 100644 packages/runtime/src/helpers/tool-set.ts create mode 100644 packages/skill-manager/package.json create mode 100644 packages/skill-manager/src/adapters/delegate-adapter.test.ts create mode 100644 packages/skill-manager/src/adapters/delegate-adapter.ts create mode 100644 packages/skill-manager/src/adapters/in-memory-base-adapter.test.ts create mode 100644 packages/skill-manager/src/adapters/in-memory-base-adapter.ts create mode 100644 packages/skill-manager/src/adapters/interactive-adapter.test.ts create mode 100644 packages/skill-manager/src/adapters/interactive-adapter.ts create mode 100644 packages/skill-manager/src/adapters/lockfile-adapter.test.ts create mode 100644 packages/skill-manager/src/adapters/lockfile-adapter.ts create mode 100644 packages/skill-manager/src/adapters/mcp-adapter.test.ts create mode 100644 packages/skill-manager/src/adapters/mcp-adapter.ts create mode 100644 packages/skill-manager/src/delegate-registry.ts create mode 100644 packages/skill-manager/src/index.ts create mode 100644 packages/skill-manager/src/skill-adapter-factory.ts create mode 100644 packages/skill-manager/src/skill-adapter.ts create mode 100644 packages/skill-manager/src/skill-manager.test.ts create mode 100644 packages/skill-manager/src/skill-manager.ts create mode 100644 packages/skill-manager/src/transport-factory.ts create mode 100644 packages/skill-manager/src/types.ts create mode 100644 packages/skill-manager/src/utils/base-skill-helpers.test.ts create mode 100644 packages/skill-manager/src/utils/base-skill-helpers.ts create mode 100644 packages/skill-manager/src/utils/command-args.test.ts create mode 100644 packages/skill-manager/src/utils/command-args.ts create mode 100644 packages/skill-manager/src/utils/ip-validator.test.ts create mode 100644 packages/skill-manager/src/utils/ip-validator.ts create mode 100644 packages/skill-manager/src/utils/mcp-converters.test.ts create mode 100644 packages/skill-manager/src/utils/mcp-converters.ts create mode 100644 packages/skill-manager/tsconfig.json 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/packages/installer/package.json b/packages/installer/package.json index 29ddc3b6..99abf70a 100644 --- a/packages/installer/package.json +++ b/packages/installer/package.json @@ -30,7 +30,8 @@ "@perstack/api-client": "^0.0.55", "@perstack/core": "workspace:*", "@perstack/perstack-toml": "workspace:*", - "@perstack/runtime": "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..c16a1f9a 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -38,6 +38,7 @@ "@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..4082a466 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,16 +65,16 @@ 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, }) @@ -96,7 +96,7 @@ export class CoordinatorExecutor { setting: { ...setting, experts }, initialCheckpoint, eventListener, - skillManagers, + skillManager, llmExecutor, eventEmitter, storeCheckpoint: this.options.storeCheckpoint ?? (async () => {}), 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/skill-manager/src/adapters/delegate-adapter.ts b/packages/skill-manager/src/adapters/delegate-adapter.ts new file mode 100644 index 00000000..dc054c2b --- /dev/null +++ b/packages/skill-manager/src/adapters/delegate-adapter.ts @@ -0,0 +1,42 @@ +import type { Expert, SkillType } from "@perstack/core" +import { SkillAdapter } from "../skill-adapter.js" +import type { SkillAdapterLifecycleEvent, ToolCallResult } from "../types.js" + +export class DelegateSkillAdapter extends SkillAdapter { + readonly name: string + readonly type: SkillType = "delegate" + override readonly expert: Expert + + constructor(expert: Expert, onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void) { + super(onLifecycleEvent) + this.name = expert.name + this.expert = expert + } + + protected override async _doConnect(): Promise { + this._toolDefinitions = [ + { + skillName: this.expert.name, + name: this.expert.name.split("/").pop() ?? this.expert.name, + description: this.expert.description, + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + interactive: false, + }, + ] + } + + protected override async _doDisconnect(): Promise {} + + override async callTool( + _toolName: string, + _input: Record, + ): 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/skill-manager/src/adapters/in-memory-base-adapter.ts b/packages/skill-manager/src/adapters/in-memory-base-adapter.ts new file mode 100644 index 00000000..e67be9e9 --- /dev/null +++ b/packages/skill-manager/src/adapters/in-memory-base-adapter.ts @@ -0,0 +1,98 @@ +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, createBaseServer } 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 InMemoryBaseSkillAdapterOptions { + transportFactory?: TransportFactory +} + +/** + * Skill adapter for bundled @perstack/base using InMemoryTransport. + * Runs the base skill in-process for near-zero initialization latency. + */ +export class InMemoryBaseSkillAdapter extends SkillAdapter { + readonly name = BASE_SKILL_NAME + readonly type: SkillType = "mcp" + override readonly skill: McpStdioSkill + private _mcpServer?: McpServer + private _mcpClient?: McpClient + private _transportFactory: TransportFactory + + constructor( + skill: McpStdioSkill, + onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void, + options?: InMemoryBaseSkillAdapterOptions, + ) { + super(onLifecycleEvent) + this.skill = skill + this._transportFactory = options?.transportFactory ?? defaultTransportFactory + } + + protected override async _doConnect(): Promise { + // Create linked transport pair + const [clientTransport, serverTransport] = this._transportFactory.createInMemoryPair() + + // Create and connect the base server + this._mcpServer = createBaseServer() + await this._mcpServer.connect(serverTransport) + + // Create and connect the client + this._mcpClient = new McpClient({ + name: `${BASE_SKILL_NAME}-in-memory-client`, + version: "1.0.0", + }) + await this._mcpClient.connect(clientTransport) + + // Discover tools + const { tools } = await this._mcpClient.listTools() + this._toolDefinitions = tools.map((tool) => ({ + skillName: BASE_SKILL_NAME, + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + interactive: false, + })) + } + + protected override async _doDisconnect(): Promise { + if (this._mcpClient) { + await this._mcpClient.close() + } + if (this._mcpServer) { + await this._mcpServer.close() + } + } + + 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)) + } + + override async callTool( + toolName: string, + input: Record, + ): Promise { + if (this.state !== "ready" || !this._mcpClient) { + throw new Error(`${this.name} is not ready`) + } + try { + const result = (await this._mcpClient.callTool({ + name: toolName, + arguments: input, + })) as CallToolResult + return convertToolResult(result, toolName, input) + } catch (error) { + return handleToolError(error, toolName, McpError) + } + } +} 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/skill-manager/src/adapters/interactive-adapter.ts b/packages/skill-manager/src/adapters/interactive-adapter.ts new file mode 100644 index 00000000..0af58a30 --- /dev/null +++ b/packages/skill-manager/src/adapters/interactive-adapter.ts @@ -0,0 +1,37 @@ +import type { InteractiveSkill, SkillType } from "@perstack/core" +import { SkillAdapter } from "../skill-adapter.js" +import type { SkillAdapterLifecycleEvent, ToolCallResult } from "../types.js" + +export class InteractiveSkillAdapter extends SkillAdapter { + readonly name: string + readonly type: SkillType = "interactive" + override readonly interactiveSkill: InteractiveSkill + + constructor( + interactiveSkill: InteractiveSkill, + onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void, + ) { + super(onLifecycleEvent) + this.name = interactiveSkill.name + this.interactiveSkill = interactiveSkill + } + + protected override async _doConnect(): Promise { + this._toolDefinitions = Object.values(this.interactiveSkill.tools).map((tool) => ({ + skillName: this.interactiveSkill.name, + name: tool.name, + description: tool.description, + inputSchema: JSON.parse(tool.inputJsonSchema), + interactive: true, + })) + } + + protected override async _doDisconnect(): Promise {} + + override async callTool( + _toolName: string, + _input: Record, + ): 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/skill-manager/src/adapters/lockfile-adapter.ts b/packages/skill-manager/src/adapters/lockfile-adapter.ts new file mode 100644 index 00000000..2673b610 --- /dev/null +++ b/packages/skill-manager/src/adapters/lockfile-adapter.ts @@ -0,0 +1,147 @@ +import type { + LockfileToolDefinition, + McpSseSkill, + McpStdioSkill, + SkillType, + ToolDefinition, +} from "@perstack/core" +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 LockfileSkillAdapterOptions { + skill: McpStdioSkill | McpSseSkill + toolDefinitions: LockfileToolDefinition[] + env: Record + perstackBaseSkillCommand?: string[] + factory: SkillAdapterFactory + onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void +} + +/** + * 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" + override readonly skill: McpStdioSkill | McpSseSkill + private _cachedToolDefinitions: ToolDefinition[] + private _realAdapter?: SkillAdapter + private _pendingConnect?: Promise + private _env: Record + private _perstackBaseSkillCommand?: string[] + private _factory: SkillAdapterFactory + + 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, + description: def.description, + inputSchema: def.inputSchema, + interactive: false, + })) + } + + protected override async _doConnect(): Promise { + // No-op: tool definitions are already cached from lockfile + } + + 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) + } + + 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)) + } + + private async _ensureRealAdapter(): Promise { + if (this._realAdapter) { + return this._realAdapter + } + if (this._pendingConnect) { + return this._pendingConnect + } + this._pendingConnect = this._connectRealAdapter() + try { + this._realAdapter = await this._pendingConnect + return this._realAdapter + } finally { + this._pendingConnect = undefined + } + } + + private async _connectRealAdapter(): Promise { + const useBundledBase = + this.skill.type === "mcpStdioSkill" && + isBaseSkill(this.skill) && + shouldUseBundledBase(this.skill, this._perstackBaseSkillCommand) + + const factoryCtx: SkillAdapterFactoryContext = { + env: this._env, + onLifecycleEvent: this._onLifecycleEvent, + } + + let adapter: SkillAdapter + if (useBundledBase && this.skill.type === "mcpStdioSkill") { + adapter = this._factory.createInMemoryBase(this.skill, factoryCtx) + } else { + const skillToUse = this._applyBaseSkillCommandOverride(this.skill) + adapter = this._factory.createMcp(skillToUse, factoryCtx) + } + await adapter.connect() + return adapter + } + + private _applyBaseSkillCommandOverride( + skill: McpStdioSkill | McpSseSkill, + ): McpStdioSkill | McpSseSkill { + if (!this._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] = this._perstackBaseSkillCommand + if (!overrideCommand) { + return skill + } + return { + ...skill, + command: overrideCommand, + packageName: undefined, + args: overrideArgs, + lazyInit: false, + } as McpStdioSkill + } + return skill + } + + override async callTool( + toolName: string, + input: Record, + ): Promise { + const realAdapter = await this._ensureRealAdapter() + return realAdapter.callTool(toolName, input) + } + + 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/skill-manager/src/adapters/mcp-adapter.ts b/packages/skill-manager/src/adapters/mcp-adapter.ts new file mode 100644 index 00000000..408e3132 --- /dev/null +++ b/packages/skill-manager/src/adapters/mcp-adapter.ts @@ -0,0 +1,138 @@ +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 { + getFilteredEnv, + type McpSseSkill, + type McpStdioSkill, + PerstackError, + type SkillType, + type 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 { getCommandArgs } from "../utils/command-args.js" +import { isPrivateOrLocalIP } from "../utils/ip-validator.js" +import { convertToolResult, handleToolError } from "../utils/mcp-converters.js" + +export interface McpSkillAdapterOptions { + transportFactory?: TransportFactory +} + +export class McpSkillAdapter extends SkillAdapter { + readonly name: string + readonly type: SkillType = "mcp" + override readonly skill: McpStdioSkill | McpSseSkill + private _mcpClient?: McpClient + private _env: Record + private _transportFactory: TransportFactory + + constructor( + skill: McpStdioSkill | McpSseSkill, + env: Record, + onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void, + options?: McpSkillAdapterOptions, + ) { + super(onLifecycleEvent) + this.name = skill.name + this.skill = skill + this._env = env + this._transportFactory = options?.transportFactory ?? defaultTransportFactory + } + + protected override async _doConnect(): Promise { + this._mcpClient = new McpClient({ + name: `${this.skill.name}-mcp-client`, + version: "1.0.0", + }) + if (this.skill.type === "mcpStdioSkill") { + await this._connectStdio(this.skill) + } else { + await this._connectSse(this.skill) + } + const { tools } = await this._mcpClient.listTools() + this._toolDefinitions = tools.map((tool) => ({ + skillName: this.skill.name, + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + interactive: false, + })) + } + + private async _connectStdio(skill: McpStdioSkill): Promise { + if (!skill.command) { + throw new PerstackError(`Skill ${skill.name} has no command`) + } + const requiredEnv: Record = {} + for (const envName of skill.requiredEnv) { + if (!this._env[envName]) { + throw new PerstackError(`Skill ${skill.name} requires environment variable ${envName}`) + } + requiredEnv[envName] = this._env[envName] + } + const env = getFilteredEnv(requiredEnv) + const { command, args } = getCommandArgs(skill) + const transport = this._transportFactory.createStdio({ command, args, env, stderr: "pipe" }) + if ((transport as StdioClientTransport).stderr) { + ;(transport as StdioClientTransport).stderr!.on("data", (chunk: Buffer) => { + this._emitLifecycleEvent("stderr", { + skillName: skill.name, + message: chunk.toString().trim(), + }) + }) + } + await this._mcpClient!.connect(transport) + } + + private async _connectSse(skill: McpSseSkill): Promise { + if (!skill.endpoint) { + throw new PerstackError(`Skill ${skill.name} has no endpoint`) + } + const url = new URL(skill.endpoint) + if (url.protocol !== "https:") { + throw new PerstackError(`Skill ${skill.name} SSE endpoint must use HTTPS: ${skill.endpoint}`) + } + if (isPrivateOrLocalIP(url.hostname)) { + throw new PerstackError( + `Skill ${skill.name} SSE endpoint cannot use private/local IP: ${skill.endpoint}`, + ) + } + const transport = this._transportFactory.createSse({ url }) + await this._mcpClient!.connect(transport) + } + + protected override async _doDisconnect(): Promise { + if (this._mcpClient) { + await this._mcpClient.close() + } + } + + 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)) + } + + override async callTool( + toolName: string, + input: Record, + ): Promise { + if (this.state !== "ready" || !this._mcpClient) { + throw new Error(`${this.name} is not ready`) + } + try { + const result = (await this._mcpClient.callTool({ + name: toolName, + arguments: input, + })) as CallToolResult + return convertToolResult(result, toolName, input) + } catch (error) { + return handleToolError(error, toolName, McpError) + } + } +} diff --git a/packages/skill-manager/src/delegate-registry.ts b/packages/skill-manager/src/delegate-registry.ts new file mode 100644 index 00000000..2bd738cb --- /dev/null +++ b/packages/skill-manager/src/delegate-registry.ts @@ -0,0 +1,11 @@ +import type { Expert } from "@perstack/core" +import type { SkillAdapter } from "./skill-adapter.js" + +/** Registry for managing delegate adapters */ +export interface DelegateRegistry { + register(expert: Expert): SkillAdapter + unregister(expertKey: string): boolean + get(expertKey: string): SkillAdapter | undefined + getAll(): ReadonlyMap + has(expertKey: string): boolean +} diff --git a/packages/skill-manager/src/index.ts b/packages/skill-manager/src/index.ts new file mode 100644 index 00000000..401f53b0 --- /dev/null +++ b/packages/skill-manager/src/index.ts @@ -0,0 +1,46 @@ +// Types + +export { DelegateSkillAdapter } from "./adapters/delegate-adapter.js" +export { InMemoryBaseSkillAdapter } from "./adapters/in-memory-base-adapter.js" +export { InteractiveSkillAdapter } from "./adapters/interactive-adapter.js" +export { LockfileSkillAdapter } from "./adapters/lockfile-adapter.js" +// Concrete adapters +export { McpSkillAdapter } from "./adapters/mcp-adapter.js" +// Delegate registry +export type { DelegateRegistry } from "./delegate-registry.js" +// Abstract base class +export { SkillAdapter } from "./skill-adapter.js" +// Factory +export type { SkillAdapterFactory, SkillAdapterFactoryContext } from "./skill-adapter-factory.js" +export { DefaultSkillAdapterFactory, defaultSkillAdapterFactory } from "./skill-adapter-factory.js" +// Skill manager +export { SkillManager } from "./skill-manager.js" +// Transport factory +export type { + SseTransportOptions, + StdioTransportOptions, + TransportFactory, +} from "./transport-factory.js" +export { DefaultTransportFactory, defaultTransportFactory } from "./transport-factory.js" +export type { + CollectedToolDefinition, + LockfileInitOptions, + SkillAdapterLifecycleEvent, + SkillAdapterState, + SkillManagerOptions, + ToolCallResult, +} from "./types.js" +export { + hasExplicitBaseVersion, + isBaseSkill, + shouldUseBundledBase, +} from "./utils/base-skill-helpers.js" +// Utilities +export { type CommandArgs, getCommandArgs } from "./utils/command-args.js" +export { isPrivateOrLocalIP } from "./utils/ip-validator.js" +export { + convertPart, + convertResource, + convertToolResult, + handleToolError, +} from "./utils/mcp-converters.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..b71a50d9 --- /dev/null +++ b/packages/skill-manager/src/skill-adapter.ts @@ -0,0 +1,102 @@ +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 + protected _onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void + + constructor(onLifecycleEvent?: (event: SkillAdapterLifecycleEvent) => void) { + this._onLifecycleEvent = onLifecycleEvent + } + + get state(): SkillAdapterState { + return this._state + } + + 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") + this._connectPromise = this._doConnect() + try { + await this._connectPromise + 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..032dc477 --- /dev/null +++ b/packages/skill-manager/src/skill-manager.test.ts @@ -0,0 +1,622 @@ +import type { Expert, McpSseSkill, McpStdioSkill, ToolDefinition } 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 { 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") + }) + }) +}) diff --git a/packages/skill-manager/src/skill-manager.ts b/packages/skill-manager/src/skill-manager.ts new file mode 100644 index 00000000..5f4dbd78 --- /dev/null +++ b/packages/skill-manager/src/skill-manager.ts @@ -0,0 +1,419 @@ +import type { + Expert, + InteractiveSkill, + McpSseSkill, + McpStdioSkill, + ToolDefinition, +} from "@perstack/core" +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 +} + +/** + * 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) + const mcpSkills = Object.values(skills) + .filter( + (skill): skill is McpStdioSkill | McpSseSkill => + skill.type === "mcpStdioSkill" || skill.type === "mcpSseSkill", + ) + .filter((skill) => !(useBundledBase && isBaseSkill(skill))) + .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) + } + return new SkillManager(adapterMap, factory, env) + } + + 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 + 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 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))) + .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/skill-manager/src/transport-factory.ts b/packages/skill-manager/src/transport-factory.ts new file mode 100644 index 00000000..781ef5b4 --- /dev/null +++ b/packages/skill-manager/src/transport-factory.ts @@ -0,0 +1,50 @@ +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js" + +export interface StdioTransportOptions { + command: string + args: string[] + env: Record + stderr?: "pipe" | "inherit" | "ignore" +} + +export interface SseTransportOptions { + url: URL +} + +/** + * Factory interface for creating MCP transports. + * Allows for dependency injection and easier testing. + */ +export interface TransportFactory { + createStdio(options: StdioTransportOptions): StdioClientTransport + createSse(options: SseTransportOptions): Transport + createInMemoryPair(): [Transport, Transport] +} + +/** + * Default implementation of TransportFactory using real MCP SDK transports. + */ +export class DefaultTransportFactory implements TransportFactory { + createStdio(options: StdioTransportOptions): StdioClientTransport { + return new StdioClientTransport({ + command: options.command, + args: options.args, + env: options.env, + stderr: options.stderr, + }) + } + + createSse(options: SseTransportOptions): Transport { + return new SSEClientTransport(options.url) + } + + createInMemoryPair(): [Transport, Transport] { + return InMemoryTransport.createLinkedPair() + } +} + +/** 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/skill-manager/src/utils/command-args.test.ts b/packages/skill-manager/src/utils/command-args.test.ts new file mode 100644 index 00000000..745eac1d --- /dev/null +++ b/packages/skill-manager/src/utils/command-args.test.ts @@ -0,0 +1,90 @@ +import type { McpStdioSkill } from "@perstack/core" +import { describe, expect, it } from "vitest" +import { getCommandArgs } from "./command-args.js" + +function createSkill(overrides: Partial = {}): McpStdioSkill { + return { + type: "mcpStdioSkill", + name: "test-skill", + command: "npx", + requiredEnv: [], + pick: [], + omit: [], + ...overrides, + } as McpStdioSkill +} + +describe("@perstack/skill-manager: getCommandArgs", () => { + describe("packageName handling", () => { + it("uses packageName when provided", () => { + const skill = createSkill({ packageName: "@example/pkg" }) + const result = getCommandArgs(skill) + expect(result.args).toContain("@example/pkg") + }) + + it("adds -y flag for npx with packageName", () => { + const skill = createSkill({ packageName: "@example/pkg" }) + const result = getCommandArgs(skill) + expect(result.args).toEqual(["-y", "@example/pkg"]) + }) + }) + + describe("args handling", () => { + it("uses args when provided", () => { + const skill = createSkill({ args: ["--config", "test.json"] }) + const result = getCommandArgs(skill) + expect(result.args).toContain("--config") + expect(result.args).toContain("test.json") + }) + + it("adds -y flag for npx with args", () => { + const skill = createSkill({ args: ["@example/pkg"] }) + const result = getCommandArgs(skill) + expect(result.args[0]).toBe("-y") + }) + + it("does not duplicate -y flag if already present", () => { + const skill = createSkill({ args: ["-y", "@example/pkg"] }) + const result = getCommandArgs(skill) + expect(result.args.filter((a) => a === "-y")).toHaveLength(1) + }) + }) + + describe("non-npx commands", () => { + it("does not add -y flag for non-npx commands", () => { + const skill = createSkill({ command: "node", args: ["script.js"] }) + const result = getCommandArgs(skill) + expect(result.args).not.toContain("-y") + expect(result.args).toEqual(["script.js"]) + }) + + it("returns correct command", () => { + const skill = createSkill({ command: "python", args: ["script.py"] }) + const result = getCommandArgs(skill) + expect(result.command).toBe("python") + }) + }) + + describe("error cases", () => { + it("throws when neither packageName nor args provided", () => { + const skill = createSkill({ packageName: undefined, args: undefined }) + expect(() => getCommandArgs(skill)).toThrow( + "Skill test-skill has no packageName or args. Please provide one of them.", + ) + }) + + it("throws when both packageName and args provided", () => { + const skill = createSkill({ packageName: "@example/pkg", args: ["extra"] }) + expect(() => getCommandArgs(skill)).toThrow( + "Skill test-skill has both packageName and args. Please provide only one of them.", + ) + }) + + it("throws when args is empty array and no packageName", () => { + const skill = createSkill({ packageName: undefined, args: [] }) + expect(() => getCommandArgs(skill)).toThrow( + "Skill test-skill has no packageName or args. Please provide one of them.", + ) + }) + }) +}) diff --git a/packages/skill-manager/src/utils/command-args.ts b/packages/skill-manager/src/utils/command-args.ts new file mode 100644 index 00000000..79143778 --- /dev/null +++ b/packages/skill-manager/src/utils/command-args.ts @@ -0,0 +1,34 @@ +import { type McpStdioSkill, PerstackError } from "@perstack/core" + +export interface CommandArgs { + command: string + args: string[] +} + +/** + * Parse and validate command arguments from a McpStdioSkill. + * Ensures either packageName or args is provided (not both, not neither). + * Adds -y flag for npx commands if not present. + */ +export function getCommandArgs(skill: McpStdioSkill): CommandArgs { + const { name, command, packageName, args } = skill + + if (!packageName && (!args || args.length === 0)) { + throw new PerstackError(`Skill ${name} has no packageName or args. Please provide one of them.`) + } + + if (packageName && args && args.length > 0) { + throw new PerstackError( + `Skill ${name} has both packageName and args. Please provide only one of them.`, + ) + } + + let newArgs = args && args.length > 0 ? args : [packageName!] + + // Add -y flag for npx to auto-confirm package installation + if (command === "npx" && !newArgs.includes("-y")) { + newArgs = ["-y", ...newArgs] + } + + return { command, args: newArgs } +} diff --git a/packages/skill-manager/src/utils/ip-validator.test.ts b/packages/skill-manager/src/utils/ip-validator.test.ts new file mode 100644 index 00000000..31acc387 --- /dev/null +++ b/packages/skill-manager/src/utils/ip-validator.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest" +import { isPrivateOrLocalIP } from "./ip-validator.js" + +describe("@perstack/skill-manager: isPrivateOrLocalIP", () => { + describe("local hostnames", () => { + it("returns true for localhost", () => { + expect(isPrivateOrLocalIP("localhost")).toBe(true) + }) + + it("returns true for 127.0.0.1", () => { + expect(isPrivateOrLocalIP("127.0.0.1")).toBe(true) + }) + + it("returns true for ::1 (IPv6 loopback)", () => { + expect(isPrivateOrLocalIP("::1")).toBe(true) + }) + + it("returns true for 0.0.0.0", () => { + expect(isPrivateOrLocalIP("0.0.0.0")).toBe(true) + }) + }) + + describe("IPv4 private ranges", () => { + it("returns true for 10.x.x.x (class A private)", () => { + expect(isPrivateOrLocalIP("10.0.0.1")).toBe(true) + expect(isPrivateOrLocalIP("10.255.255.255")).toBe(true) + }) + + it("returns true for 172.16-31.x.x (class B private)", () => { + expect(isPrivateOrLocalIP("172.16.0.1")).toBe(true) + expect(isPrivateOrLocalIP("172.31.255.255")).toBe(true) + }) + + it("returns false for 172.15.x.x and 172.32.x.x", () => { + expect(isPrivateOrLocalIP("172.15.0.1")).toBe(false) + expect(isPrivateOrLocalIP("172.32.0.1")).toBe(false) + }) + + it("returns true for 192.168.x.x (class C private)", () => { + expect(isPrivateOrLocalIP("192.168.0.1")).toBe(true) + expect(isPrivateOrLocalIP("192.168.255.255")).toBe(true) + }) + + it("returns true for 169.254.x.x (link-local)", () => { + expect(isPrivateOrLocalIP("169.254.0.1")).toBe(true) + expect(isPrivateOrLocalIP("169.254.255.255")).toBe(true) + }) + + it("returns true for 127.x.x.x (loopback range)", () => { + expect(isPrivateOrLocalIP("127.0.0.1")).toBe(true) + expect(isPrivateOrLocalIP("127.255.255.255")).toBe(true) + }) + }) + + describe("IPv6 private ranges", () => { + it("returns true for fe80:: (link-local)", () => { + expect(isPrivateOrLocalIP("fe80::1")).toBe(true) + expect(isPrivateOrLocalIP("fe80:0000:0000:0000:0000:0000:0000:0001")).toBe(true) + }) + + it("returns true for fc00::/7 (unique local)", () => { + expect(isPrivateOrLocalIP("fc00::1")).toBe(true) + expect(isPrivateOrLocalIP("fd00::1")).toBe(true) + }) + }) + + describe("IPv4-mapped IPv6 addresses", () => { + it("returns true for ::ffff:127.0.0.1", () => { + expect(isPrivateOrLocalIP("::ffff:127.0.0.1")).toBe(true) + }) + + it("returns true for ::ffff:192.168.1.1", () => { + expect(isPrivateOrLocalIP("::ffff:192.168.1.1")).toBe(true) + }) + + it("returns false for ::ffff:8.8.8.8", () => { + expect(isPrivateOrLocalIP("::ffff:8.8.8.8")).toBe(false) + }) + }) + + describe("public addresses", () => { + it("returns false for public IPv4 addresses", () => { + expect(isPrivateOrLocalIP("8.8.8.8")).toBe(false) + expect(isPrivateOrLocalIP("1.1.1.1")).toBe(false) + expect(isPrivateOrLocalIP("203.0.113.1")).toBe(false) + }) + + it("returns false for public hostnames", () => { + expect(isPrivateOrLocalIP("example.com")).toBe(false) + expect(isPrivateOrLocalIP("api.perstack.ai")).toBe(false) + }) + }) +}) diff --git a/packages/skill-manager/src/utils/ip-validator.ts b/packages/skill-manager/src/utils/ip-validator.ts new file mode 100644 index 00000000..2f0cf48d --- /dev/null +++ b/packages/skill-manager/src/utils/ip-validator.ts @@ -0,0 +1,49 @@ +/** + * Check if a hostname is a private or local IP address. + * Used to validate SSE endpoints - private/local IPs are not allowed. + */ +export function isPrivateOrLocalIP(hostname: string): boolean { + // Check common local hostnames + if ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname === "0.0.0.0" + ) { + return true + } + + // Check IPv4 private ranges + const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/) + if (ipv4Match) { + const [, a, b] = ipv4Match.map(Number) + // 10.0.0.0/8 + if (a === 10) return true + // 172.16.0.0/12 + if (a === 172 && b >= 16 && b <= 31) return true + // 192.168.0.0/16 + if (a === 192 && b === 168) return true + // 169.254.0.0/16 (link-local) + if (a === 169 && b === 254) return true + // 127.0.0.0/8 (loopback) + if (a === 127) return true + } + + // Check IPv6 private ranges + if (hostname.includes(":")) { + // fe80::/10 (link-local) + if (hostname.startsWith("fe80:")) return true + // fc00::/7 (unique local) + if (hostname.startsWith("fc") || hostname.startsWith("fd")) return true + } + + // Check IPv4-mapped IPv6 addresses + if (hostname.startsWith("::ffff:")) { + const ipv4Part = hostname.slice(7) + if (isPrivateOrLocalIP(ipv4Part)) { + return true + } + } + + return false +} diff --git a/packages/skill-manager/src/utils/mcp-converters.test.ts b/packages/skill-manager/src/utils/mcp-converters.test.ts new file mode 100644 index 00000000..2e5d6614 --- /dev/null +++ b/packages/skill-manager/src/utils/mcp-converters.test.ts @@ -0,0 +1,185 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" +import { describe, expect, it } from "vitest" +import { + convertPart, + convertResource, + convertToolResult, + handleToolError, +} from "./mcp-converters.js" + +// Mock McpError class for testing +class MockMcpError extends Error { + constructor(message: string) { + super(message) + this.name = "McpError" + } +} + +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) + expect(result).toHaveLength(1) + expect(result[0].type).toBe("textPart") + expect(result[0].text).toContain("Error calling tool test-tool") + expect(result[0].text).toContain("Tool execution failed") + }) + + it("re-throws non-McpError errors", () => { + const error = new Error("Regular error") + expect(() => handleToolError(error, "test-tool", MockMcpError as never)).toThrow( + "Regular error", + ) + }) + + it("includes tool name in error message", () => { + const error = new MockMcpError("Failed") + const result = handleToolError(error, "my-special-tool", MockMcpError as never) + expect(result[0].text).toContain("my-special-tool") + }) +}) + +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" }) + expect(converted).toHaveLength(1) + expect(converted[0].type).toBe("textPart") + expect((converted[0] as { text: string }).text).toContain("Tool test-tool returned nothing") + expect((converted[0] as { text: string }).text).toContain('"arg":"value"') + }) + + it("returns empty result message when content is undefined", () => { + const result = {} as CallToolResult + const converted = convertToolResult(result, "test-tool", {}) + expect(converted).toHaveLength(1) + expect((converted[0] as { text: string }).text).toContain("returned nothing") + }) + + it("converts text content parts", () => { + const result: CallToolResult = { + content: [{ type: "text", text: "Hello world" }], + } + const converted = convertToolResult(result, "test-tool", {}) + expect(converted).toHaveLength(1) + expect(converted[0].type).toBe("textPart") + expect((converted[0] as { text: string }).text).toBe("Hello world") + }) + + it("filters out audio and resource_link types", () => { + const result = { + content: [ + { type: "text", text: "Keep this" }, + { type: "audio", data: "audio-data" }, + { type: "resource_link", uri: "some-uri", name: "link" }, + ], + } as unknown as CallToolResult + const converted = convertToolResult(result, "test-tool", {}) + expect(converted).toHaveLength(1) + expect((converted[0] as { text: string }).text).toBe("Keep this") + }) +}) + +describe("@perstack/skill-manager: convertPart", () => { + describe("text parts", () => { + it("converts text part correctly", () => { + const part = { type: "text" as const, text: "Hello" } + const result = convertPart(part) + expect(result.type).toBe("textPart") + expect((result as { text: string }).text).toBe("Hello") + }) + + it("returns error message for empty text", () => { + const part = { type: "text" as const, text: "" } + const result = convertPart(part) + expect(result.type).toBe("textPart") + expect((result as { text: string }).text).toBe("Error: No content") + }) + + it("returns error message for undefined text", () => { + const part = { type: "text" as const, text: undefined as unknown as string } + const result = convertPart(part) + expect((result as { text: string }).text).toBe("Error: No content") + }) + }) + + describe("image parts", () => { + it("converts image part correctly", () => { + const part = { type: "image" as const, data: "base64data", mimeType: "image/png" } + const result = convertPart(part) + expect(result.type).toBe("imageInlinePart") + expect((result as { encodedData: string }).encodedData).toBe("base64data") + expect((result as { mimeType: string }).mimeType).toBe("image/png") + }) + + it("throws when image data is missing", () => { + const part = { type: "image" as const, mimeType: "image/png" } + expect(() => convertPart(part as never)).toThrow( + "Image part must have both data and mimeType", + ) + }) + + it("throws when image mimeType is missing", () => { + const part = { type: "image" as const, data: "base64data" } + expect(() => convertPart(part as never)).toThrow( + "Image part must have both data and mimeType", + ) + }) + }) + + describe("resource parts", () => { + it("converts resource part with text", () => { + const part = { + type: "resource" as const, + resource: { uri: "file://test", mimeType: "text/plain", text: "content" }, + } + const result = convertPart(part) + expect(result.type).toBe("textPart") + expect((result as { text: string }).text).toBe("content") + }) + + it("throws when resource is missing", () => { + const part = { type: "resource" as const } + expect(() => convertPart(part as never)).toThrow("Resource part must have resource content") + }) + }) +}) + +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) + expect(result.type).toBe("textPart") + expect((result as { text: string }).text).toBe("Hello") + }) + + it("converts blob resource to fileInlinePart", () => { + const resource = { uri: "file://test", mimeType: "application/pdf", blob: "base64data" } + const result = convertResource(resource) + expect(result.type).toBe("fileInlinePart") + expect((result as { encodedData: string }).encodedData).toBe("base64data") + expect((result as { mimeType: string }).mimeType).toBe("application/pdf") + }) + + it("throws when mimeType is missing", () => { + const resource = { uri: "file://test", text: "content" } + expect(() => convertResource(resource as never)).toThrow("has no mimeType") + }) + + it("throws for unsupported resource type", () => { + const resource = { uri: "file://test", mimeType: "text/plain" } + expect(() => convertResource(resource)).toThrow("Unsupported resource type") + }) + + it("prioritizes text over blob when both present", () => { + const resource = { + uri: "file://test", + mimeType: "text/plain", + text: "text content", + blob: "blob data", + } + const result = convertResource(resource) + expect(result.type).toBe("textPart") + expect((result as { text: string }).text).toBe("text content") + }) +}) diff --git a/packages/skill-manager/src/utils/mcp-converters.ts b/packages/skill-manager/src/utils/mcp-converters.ts new file mode 100644 index 00000000..1e953a50 --- /dev/null +++ b/packages/skill-manager/src/utils/mcp-converters.ts @@ -0,0 +1,109 @@ +import type { CallToolResult, McpError } from "@modelcontextprotocol/sdk/types.js" +import { createId } from "@paralleldrive/cuid2" +import type { + CallToolResultContent, + FileInlinePart, + ImageInlinePart, + Resource, + TextPart, +} from "@perstack/core" + +/** + * Handle MCP tool errors and convert them to TextPart responses. + * McpError instances are converted to error messages, other errors are re-thrown. + */ +export function handleToolError( + error: unknown, + toolName: string, + McpErrorClass: typeof McpError, +): Array { + if (error instanceof McpErrorClass) { + return [ + { + type: "textPart", + text: `Error calling tool ${toolName}: ${error.message}`, + id: createId(), + }, + ] + } + throw error +} + +/** + * Convert MCP CallToolResult to internal part types. + */ +export function convertToolResult( + result: CallToolResult, + toolName: string, + input: Record, +): Array { + if (!result.content || result.content.length === 0) { + return [ + { + type: "textPart", + text: `Tool ${toolName} returned nothing with arguments: ${JSON.stringify(input)}`, + id: createId(), + }, + ] + } + + return result.content + .filter((part) => part.type !== "audio" && part.type !== "resource_link") + .map((part) => convertPart(part as CallToolResultContent)) +} + +/** + * Convert a single MCP content part to internal part type. + */ +export function convertPart( + part: CallToolResultContent, +): TextPart | ImageInlinePart | FileInlinePart { + switch (part.type) { + case "text": + if (!part.text || part.text === "") { + return { type: "textPart", text: "Error: No content", id: createId() } + } + return { type: "textPart", text: part.text, id: createId() } + + case "image": + if (!part.data || !part.mimeType) { + throw new Error("Image part must have both data and mimeType") + } + return { + type: "imageInlinePart", + encodedData: part.data, + mimeType: part.mimeType, + id: createId(), + } + + case "resource": + if (!part.resource) { + throw new Error("Resource part must have resource content") + } + return convertResource(part.resource) + } +} + +/** + * Convert MCP resource to internal part type. + */ +export function convertResource(resource: Resource): TextPart | FileInlinePart { + if (!resource.mimeType) { + throw new Error(`Resource ${JSON.stringify(resource)} has no mimeType`) + } + + if (resource.text && typeof resource.text === "string") { + return { type: "textPart", text: resource.text, id: createId() } + } + + if (resource.blob && typeof resource.blob === "string") { + return { + type: "fileInlinePart", + encodedData: resource.blob, + mimeType: resource.mimeType, + id: createId(), + } + } + + throw new Error(`Unsupported resource type: ${JSON.stringify(resource)}`) +} 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..300b0fbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,6 +257,9 @@ importers: '@perstack/runtime': specifier: workspace:* version: link:../runtime + '@perstack/skill-manager': + specifier: workspace:* + version: link:../skill-manager devDependencies: '@perstack/tui': specifier: workspace:* @@ -681,6 +684,9 @@ importers: '@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 +746,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.0.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': From 0928ee66dae0a673702e835675d16b6908e12135 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Mon, 16 Feb 2026 14:30:41 +0000 Subject: [PATCH 2/6] refactor: delete old runtime skill-manager module and remove unused deps The old packages/runtime/src/skill-manager/ is fully replaced by @perstack/skill-manager. Remove @modelcontextprotocol/sdk and @perstack/base from runtime deps, and @perstack/runtime from installer deps, since they are no longer needed. Co-Authored-By: Claude Opus 4.6 --- packages/installer/package.json | 1 - packages/runtime/package.json | 2 - packages/runtime/src/skill-manager/base.ts | 104 --- .../src/skill-manager/command-args.test.ts | 90 --- .../runtime/src/skill-manager/command-args.ts | 34 - .../src/skill-manager/delegate.test.ts | 81 --- .../runtime/src/skill-manager/delegate.ts | 55 -- .../runtime/src/skill-manager/helpers.test.ts | 625 ------------------ packages/runtime/src/skill-manager/helpers.ts | 458 ------------- .../src/skill-manager/in-memory-base.test.ts | 174 ----- .../src/skill-manager/in-memory-base.ts | 138 ---- packages/runtime/src/skill-manager/index.ts | 49 -- .../src/skill-manager/interactive.test.ts | 85 --- .../runtime/src/skill-manager/interactive.ts | 47 -- .../src/skill-manager/ip-validator.test.ts | 93 --- .../runtime/src/skill-manager/ip-validator.ts | 49 -- .../lockfile-skill-manager.test.ts | 230 ------- .../skill-manager/lockfile-skill-manager.ts | 155 ----- .../src/skill-manager/mcp-converters.test.ts | 185 ------ .../src/skill-manager/mcp-converters.ts | 109 --- .../runtime/src/skill-manager/mcp.test.ts | 319 --------- packages/runtime/src/skill-manager/mcp.ts | 200 ------ .../skill-manager-factory.test.ts | 153 ----- .../skill-manager/skill-manager-factory.ts | 78 --- .../skill-manager/transport-factory.test.ts | 85 --- .../src/skill-manager/transport-factory.ts | 56 -- pnpm-lock.yaml | 9 - 27 files changed, 3664 deletions(-) delete mode 100644 packages/runtime/src/skill-manager/base.ts delete mode 100644 packages/runtime/src/skill-manager/command-args.test.ts delete mode 100644 packages/runtime/src/skill-manager/command-args.ts delete mode 100644 packages/runtime/src/skill-manager/delegate.test.ts delete mode 100644 packages/runtime/src/skill-manager/delegate.ts delete mode 100644 packages/runtime/src/skill-manager/helpers.test.ts delete mode 100644 packages/runtime/src/skill-manager/helpers.ts delete mode 100644 packages/runtime/src/skill-manager/in-memory-base.test.ts delete mode 100644 packages/runtime/src/skill-manager/in-memory-base.ts delete mode 100644 packages/runtime/src/skill-manager/index.ts delete mode 100644 packages/runtime/src/skill-manager/interactive.test.ts delete mode 100644 packages/runtime/src/skill-manager/interactive.ts delete mode 100644 packages/runtime/src/skill-manager/ip-validator.test.ts delete mode 100644 packages/runtime/src/skill-manager/ip-validator.ts delete mode 100644 packages/runtime/src/skill-manager/lockfile-skill-manager.test.ts delete mode 100644 packages/runtime/src/skill-manager/lockfile-skill-manager.ts delete mode 100644 packages/runtime/src/skill-manager/mcp-converters.test.ts delete mode 100644 packages/runtime/src/skill-manager/mcp-converters.ts delete mode 100644 packages/runtime/src/skill-manager/mcp.test.ts delete mode 100644 packages/runtime/src/skill-manager/mcp.ts delete mode 100644 packages/runtime/src/skill-manager/skill-manager-factory.test.ts delete mode 100644 packages/runtime/src/skill-manager/skill-manager-factory.ts delete mode 100644 packages/runtime/src/skill-manager/transport-factory.test.ts delete mode 100644 packages/runtime/src/skill-manager/transport-factory.ts diff --git a/packages/installer/package.json b/packages/installer/package.json index 99abf70a..052d1525 100644 --- a/packages/installer/package.json +++ b/packages/installer/package.json @@ -30,7 +30,6 @@ "@perstack/api-client": "^0.0.55", "@perstack/core": "workspace:*", "@perstack/perstack-toml": "workspace:*", - "@perstack/runtime": "workspace:*", "@perstack/skill-manager": "workspace:*" }, "devDependencies": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index c16a1f9a..e8ee04a8 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -33,10 +33,8 @@ "@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", 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/command-args.test.ts b/packages/runtime/src/skill-manager/command-args.test.ts deleted file mode 100644 index 1640f075..00000000 --- a/packages/runtime/src/skill-manager/command-args.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { McpStdioSkill } from "@perstack/core" -import { describe, expect, it } from "vitest" -import { getCommandArgs } from "./command-args.js" - -function createSkill(overrides: Partial = {}): McpStdioSkill { - return { - type: "mcpStdioSkill", - name: "test-skill", - command: "npx", - requiredEnv: [], - pick: [], - omit: [], - ...overrides, - } as McpStdioSkill -} - -describe("@perstack/runtime: getCommandArgs", () => { - describe("packageName handling", () => { - it("uses packageName when provided", () => { - const skill = createSkill({ packageName: "@example/pkg" }) - const result = getCommandArgs(skill) - expect(result.args).toContain("@example/pkg") - }) - - it("adds -y flag for npx with packageName", () => { - const skill = createSkill({ packageName: "@example/pkg" }) - const result = getCommandArgs(skill) - expect(result.args).toEqual(["-y", "@example/pkg"]) - }) - }) - - describe("args handling", () => { - it("uses args when provided", () => { - const skill = createSkill({ args: ["--config", "test.json"] }) - const result = getCommandArgs(skill) - expect(result.args).toContain("--config") - expect(result.args).toContain("test.json") - }) - - it("adds -y flag for npx with args", () => { - const skill = createSkill({ args: ["@example/pkg"] }) - const result = getCommandArgs(skill) - expect(result.args[0]).toBe("-y") - }) - - it("does not duplicate -y flag if already present", () => { - const skill = createSkill({ args: ["-y", "@example/pkg"] }) - const result = getCommandArgs(skill) - expect(result.args.filter((a) => a === "-y")).toHaveLength(1) - }) - }) - - describe("non-npx commands", () => { - it("does not add -y flag for non-npx commands", () => { - const skill = createSkill({ command: "node", args: ["script.js"] }) - const result = getCommandArgs(skill) - expect(result.args).not.toContain("-y") - expect(result.args).toEqual(["script.js"]) - }) - - it("returns correct command", () => { - const skill = createSkill({ command: "python", args: ["script.py"] }) - const result = getCommandArgs(skill) - expect(result.command).toBe("python") - }) - }) - - describe("error cases", () => { - it("throws when neither packageName nor args provided", () => { - const skill = createSkill({ packageName: undefined, args: undefined }) - expect(() => getCommandArgs(skill)).toThrow( - "Skill test-skill has no packageName or args. Please provide one of them.", - ) - }) - - it("throws when both packageName and args provided", () => { - const skill = createSkill({ packageName: "@example/pkg", args: ["extra"] }) - expect(() => getCommandArgs(skill)).toThrow( - "Skill test-skill has both packageName and args. Please provide only one of them.", - ) - }) - - it("throws when args is empty array and no packageName", () => { - const skill = createSkill({ packageName: undefined, args: [] }) - expect(() => getCommandArgs(skill)).toThrow( - "Skill test-skill has no packageName or args. Please provide one of them.", - ) - }) - }) -}) diff --git a/packages/runtime/src/skill-manager/command-args.ts b/packages/runtime/src/skill-manager/command-args.ts deleted file mode 100644 index 79143778..00000000 --- a/packages/runtime/src/skill-manager/command-args.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type McpStdioSkill, PerstackError } from "@perstack/core" - -export interface CommandArgs { - command: string - args: string[] -} - -/** - * Parse and validate command arguments from a McpStdioSkill. - * Ensures either packageName or args is provided (not both, not neither). - * Adds -y flag for npx commands if not present. - */ -export function getCommandArgs(skill: McpStdioSkill): CommandArgs { - const { name, command, packageName, args } = skill - - if (!packageName && (!args || args.length === 0)) { - throw new PerstackError(`Skill ${name} has no packageName or args. Please provide one of them.`) - } - - if (packageName && args && args.length > 0) { - throw new PerstackError( - `Skill ${name} has both packageName and args. Please provide only one of them.`, - ) - } - - let newArgs = args && args.length > 0 ? args : [packageName!] - - // Add -y flag for npx to auto-confirm package installation - if (command === "npx" && !newArgs.includes("-y")) { - newArgs = ["-y", ...newArgs] - } - - return { command, args: newArgs } -} 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/delegate.ts b/packages/runtime/src/skill-manager/delegate.ts deleted file mode 100644 index a8ebffe0..00000000 --- a/packages/runtime/src/skill-manager/delegate.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { - Expert, - FileInlinePart, - ImageInlinePart, - RunEvent, - RuntimeEvent, - SkillType, - TextPart, -} from "@perstack/core" -import { BaseSkillManager } from "./base.js" - -export class DelegateSkillManager extends BaseSkillManager { - 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) - this.name = expert.name - this.expert = expert - } - - protected override async _doInit(): Promise { - this._toolDefinitions = [ - { - skillName: this.expert.name, - name: this.expert.name.split("/").pop() ?? this.expert.name, - description: this.expert.description, - inputSchema: { - type: "object", - properties: { - query: { type: "string" }, - }, - required: ["query"], - }, - interactive: false, - }, - ] - } - - override async close(): Promise {} - - override async callTool( - _toolName: string, - _input: Record, - ): Promise> { - return [] - } -} 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/in-memory-base.ts b/packages/runtime/src/skill-manager/in-memory-base.ts deleted file mode 100644 index 1f395629..00000000 --- a/packages/runtime/src/skill-manager/in-memory-base.ts +++ /dev/null @@ -1,138 +0,0 @@ -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" - -export interface InMemoryBaseSkillManagerOptions { - transportFactory?: TransportFactory -} - -/** - * Skill manager for bundled @perstack/base using InMemoryTransport. - * Runs the base skill in-process for near-zero initialization latency. - */ -export class InMemoryBaseSkillManager extends BaseSkillManager { - 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 - - constructor( - skill: McpStdioSkill, - jobId: string, - runId: string, - eventListener?: (event: RunEvent | RuntimeEvent) => void, - options?: InMemoryBaseSkillManagerOptions, - ) { - super(jobId, runId, eventListener) - 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() - - // Create linked transport pair - const [clientTransport, serverTransport] = this._transportFactory.createInMemoryPair() - - // Create and connect the base server - this._mcpServer = createBaseServer() - await this._mcpServer.connect(serverTransport) - - // Create and connect the client - this._mcpClient = new McpClient({ - 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, - description: tool.description, - 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 { - 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) - } - } - - override async callTool( - toolName: string, - input: Record, - ): Promise> { - if (!this.isInitialized() || !this._mcpClient) { - throw new Error(`${this.name} is not initialized`) - } - try { - const result = (await this._mcpClient.callTool({ - name: toolName, - arguments: input, - })) as CallToolResult - return convertToolResult(result, toolName, input) - } catch (error) { - return handleToolError(error, toolName, McpError) - } - } -} 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/interactive.ts b/packages/runtime/src/skill-manager/interactive.ts deleted file mode 100644 index e229c93c..00000000 --- a/packages/runtime/src/skill-manager/interactive.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { - FileInlinePart, - ImageInlinePart, - InteractiveSkill, - RunEvent, - RuntimeEvent, - SkillType, - TextPart, -} from "@perstack/core" -import { BaseSkillManager } from "./base.js" - -export class InteractiveSkillManager extends BaseSkillManager { - 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, - ) { - super(jobId, runId, eventListener) - this.name = interactiveSkill.name - this.interactiveSkill = interactiveSkill - } - - protected override async _doInit(): Promise { - this._toolDefinitions = Object.values(this.interactiveSkill.tools).map((tool) => ({ - skillName: this.interactiveSkill.name, - name: tool.name, - description: tool.description, - inputSchema: JSON.parse(tool.inputJsonSchema), - interactive: true, - })) - } - - override async close(): Promise {} - - override async callTool( - _toolName: string, - _input: Record, - ): Promise> { - return [] - } -} diff --git a/packages/runtime/src/skill-manager/ip-validator.test.ts b/packages/runtime/src/skill-manager/ip-validator.test.ts deleted file mode 100644 index a3e847e7..00000000 --- a/packages/runtime/src/skill-manager/ip-validator.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest" -import { isPrivateOrLocalIP } from "./ip-validator.js" - -describe("@perstack/runtime: isPrivateOrLocalIP", () => { - describe("local hostnames", () => { - it("returns true for localhost", () => { - expect(isPrivateOrLocalIP("localhost")).toBe(true) - }) - - it("returns true for 127.0.0.1", () => { - expect(isPrivateOrLocalIP("127.0.0.1")).toBe(true) - }) - - it("returns true for ::1 (IPv6 loopback)", () => { - expect(isPrivateOrLocalIP("::1")).toBe(true) - }) - - it("returns true for 0.0.0.0", () => { - expect(isPrivateOrLocalIP("0.0.0.0")).toBe(true) - }) - }) - - describe("IPv4 private ranges", () => { - it("returns true for 10.x.x.x (class A private)", () => { - expect(isPrivateOrLocalIP("10.0.0.1")).toBe(true) - expect(isPrivateOrLocalIP("10.255.255.255")).toBe(true) - }) - - it("returns true for 172.16-31.x.x (class B private)", () => { - expect(isPrivateOrLocalIP("172.16.0.1")).toBe(true) - expect(isPrivateOrLocalIP("172.31.255.255")).toBe(true) - }) - - it("returns false for 172.15.x.x and 172.32.x.x", () => { - expect(isPrivateOrLocalIP("172.15.0.1")).toBe(false) - expect(isPrivateOrLocalIP("172.32.0.1")).toBe(false) - }) - - it("returns true for 192.168.x.x (class C private)", () => { - expect(isPrivateOrLocalIP("192.168.0.1")).toBe(true) - expect(isPrivateOrLocalIP("192.168.255.255")).toBe(true) - }) - - it("returns true for 169.254.x.x (link-local)", () => { - expect(isPrivateOrLocalIP("169.254.0.1")).toBe(true) - expect(isPrivateOrLocalIP("169.254.255.255")).toBe(true) - }) - - it("returns true for 127.x.x.x (loopback range)", () => { - expect(isPrivateOrLocalIP("127.0.0.1")).toBe(true) - expect(isPrivateOrLocalIP("127.255.255.255")).toBe(true) - }) - }) - - describe("IPv6 private ranges", () => { - it("returns true for fe80:: (link-local)", () => { - expect(isPrivateOrLocalIP("fe80::1")).toBe(true) - expect(isPrivateOrLocalIP("fe80:0000:0000:0000:0000:0000:0000:0001")).toBe(true) - }) - - it("returns true for fc00::/7 (unique local)", () => { - expect(isPrivateOrLocalIP("fc00::1")).toBe(true) - expect(isPrivateOrLocalIP("fd00::1")).toBe(true) - }) - }) - - describe("IPv4-mapped IPv6 addresses", () => { - it("returns true for ::ffff:127.0.0.1", () => { - expect(isPrivateOrLocalIP("::ffff:127.0.0.1")).toBe(true) - }) - - it("returns true for ::ffff:192.168.1.1", () => { - expect(isPrivateOrLocalIP("::ffff:192.168.1.1")).toBe(true) - }) - - it("returns false for ::ffff:8.8.8.8", () => { - expect(isPrivateOrLocalIP("::ffff:8.8.8.8")).toBe(false) - }) - }) - - describe("public addresses", () => { - it("returns false for public IPv4 addresses", () => { - expect(isPrivateOrLocalIP("8.8.8.8")).toBe(false) - expect(isPrivateOrLocalIP("1.1.1.1")).toBe(false) - expect(isPrivateOrLocalIP("203.0.113.1")).toBe(false) - }) - - it("returns false for public hostnames", () => { - expect(isPrivateOrLocalIP("example.com")).toBe(false) - expect(isPrivateOrLocalIP("api.perstack.ai")).toBe(false) - }) - }) -}) diff --git a/packages/runtime/src/skill-manager/ip-validator.ts b/packages/runtime/src/skill-manager/ip-validator.ts deleted file mode 100644 index 2f0cf48d..00000000 --- a/packages/runtime/src/skill-manager/ip-validator.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Check if a hostname is a private or local IP address. - * Used to validate SSE endpoints - private/local IPs are not allowed. - */ -export function isPrivateOrLocalIP(hostname: string): boolean { - // Check common local hostnames - if ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname === "0.0.0.0" - ) { - return true - } - - // Check IPv4 private ranges - const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/) - if (ipv4Match) { - const [, a, b] = ipv4Match.map(Number) - // 10.0.0.0/8 - if (a === 10) return true - // 172.16.0.0/12 - if (a === 172 && b >= 16 && b <= 31) return true - // 192.168.0.0/16 - if (a === 192 && b === 168) return true - // 169.254.0.0/16 (link-local) - if (a === 169 && b === 254) return true - // 127.0.0.0/8 (loopback) - if (a === 127) return true - } - - // Check IPv6 private ranges - if (hostname.includes(":")) { - // fe80::/10 (link-local) - if (hostname.startsWith("fe80:")) return true - // fc00::/7 (unique local) - if (hostname.startsWith("fc") || hostname.startsWith("fd")) return true - } - - // Check IPv4-mapped IPv6 addresses - if (hostname.startsWith("::ffff:")) { - const ipv4Part = hostname.slice(7) - if (isPrivateOrLocalIP(ipv4Part)) { - return true - } - } - - return false -} 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/lockfile-skill-manager.ts b/packages/runtime/src/skill-manager/lockfile-skill-manager.ts deleted file mode 100644 index 5ee85f6c..00000000 --- a/packages/runtime/src/skill-manager/lockfile-skill-manager.ts +++ /dev/null @@ -1,155 +0,0 @@ -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" - -export interface LockfileSkillManagerOptions { - skill: McpStdioSkill | McpSseSkill - toolDefinitions: LockfileToolDefinition[] - env: Record - jobId: string - runId: string - eventListener?: (event: RunEvent | RuntimeEvent) => void - perstackBaseSkillCommand?: string[] -} - -export class LockfileSkillManager extends BaseSkillManager { - 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 _env: Record - private _perstackBaseSkillCommand?: string[] - - constructor(options: LockfileSkillManagerOptions) { - super(options.jobId, options.runId, options.eventListener) - this.name = options.skill.name - this.skill = options.skill - this._env = options.env - this._perstackBaseSkillCommand = options.perstackBaseSkillCommand - this._cachedToolDefinitions = options.toolDefinitions.map((def) => ({ - skillName: def.skillName, - name: def.name, - description: def.description, - inputSchema: def.inputSchema, - interactive: false, - })) - } - - protected override async _doInit(): Promise { - // No-op: tool definitions are already cached from lockfile - } - - override async getToolDefinitions(): Promise { - return this._filterTools(this._cachedToolDefinitions) - } - - 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)) - } - - private async _ensureRealManager(): Promise { - // Return existing manager if already initialized - if (this._realManager) { - return this._realManager - } - // Wait for pending initialization to avoid race condition - if (this._pendingInit) { - return this._pendingInit - } - // Start initialization and track the pending promise - this._pendingInit = this._initRealManager() - try { - this._realManager = await this._pendingInit - return this._realManager - } finally { - this._pendingInit = undefined - } - } - - private async _initRealManager(): Promise { - const useBundledBase = - this.skill.type === "mcpStdioSkill" && - isBaseSkill(this.skill) && - shouldUseBundledBase(this.skill, this._perstackBaseSkillCommand) - let manager: BaseSkillManager - if (useBundledBase && this.skill.type === "mcpStdioSkill") { - manager = new InMemoryBaseSkillManager( - this.skill, - this._jobId, - this._runId, - this._eventListener, - ) - } else { - // Apply perstackBaseSkillCommand override if applicable - const skillToUse = this._applyBaseSkillCommandOverride(this.skill) - manager = new McpSkillManager( - skillToUse, - this._env, - this._jobId, - this._runId, - this._eventListener, - ) - } - await manager.init() - return manager - } - - private _applyBaseSkillCommandOverride( - skill: McpStdioSkill | McpSseSkill, - ): McpStdioSkill | McpSseSkill { - if (!this._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] = this._perstackBaseSkillCommand - if (!overrideCommand) { - return skill - } - return { - ...skill, - command: overrideCommand, - packageName: undefined, - args: overrideArgs, - lazyInit: false, - } as McpStdioSkill - } - return skill - } - - override async callTool( - toolName: string, - input: Record, - ): Promise> { - const realManager = await this._ensureRealManager() - return realManager.callTool(toolName, input) - } - - override async close(): Promise { - if (this._realManager) { - await this._realManager.close() - } - } -} diff --git a/packages/runtime/src/skill-manager/mcp-converters.test.ts b/packages/runtime/src/skill-manager/mcp-converters.test.ts deleted file mode 100644 index 1cc70a9f..00000000 --- a/packages/runtime/src/skill-manager/mcp-converters.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" -import { describe, expect, it } from "vitest" -import { - convertPart, - convertResource, - convertToolResult, - handleToolError, -} from "./mcp-converters.js" - -// Mock McpError class for testing -class MockMcpError extends Error { - constructor(message: string) { - super(message) - this.name = "McpError" - } -} - -describe("@perstack/runtime: handleToolError", () => { - it("converts McpError to TextPart array", () => { - const error = new MockMcpError("Tool execution failed") - const result = handleToolError(error, "test-tool", MockMcpError as never) - expect(result).toHaveLength(1) - expect(result[0].type).toBe("textPart") - expect(result[0].text).toContain("Error calling tool test-tool") - expect(result[0].text).toContain("Tool execution failed") - }) - - it("re-throws non-McpError errors", () => { - const error = new Error("Regular error") - expect(() => handleToolError(error, "test-tool", MockMcpError as never)).toThrow( - "Regular error", - ) - }) - - it("includes tool name in error message", () => { - const error = new MockMcpError("Failed") - const result = handleToolError(error, "my-special-tool", MockMcpError as never) - expect(result[0].text).toContain("my-special-tool") - }) -}) - -describe("@perstack/runtime: convertToolResult", () => { - it("returns empty result message when content is empty", () => { - const result: CallToolResult = { content: [] } - const converted = convertToolResult(result, "test-tool", { arg: "value" }) - expect(converted).toHaveLength(1) - expect(converted[0].type).toBe("textPart") - expect((converted[0] as { text: string }).text).toContain("Tool test-tool returned nothing") - expect((converted[0] as { text: string }).text).toContain('"arg":"value"') - }) - - it("returns empty result message when content is undefined", () => { - const result = {} as CallToolResult - const converted = convertToolResult(result, "test-tool", {}) - expect(converted).toHaveLength(1) - expect((converted[0] as { text: string }).text).toContain("returned nothing") - }) - - it("converts text content parts", () => { - const result: CallToolResult = { - content: [{ type: "text", text: "Hello world" }], - } - const converted = convertToolResult(result, "test-tool", {}) - expect(converted).toHaveLength(1) - expect(converted[0].type).toBe("textPart") - expect((converted[0] as { text: string }).text).toBe("Hello world") - }) - - it("filters out audio and resource_link types", () => { - const result = { - content: [ - { type: "text", text: "Keep this" }, - { type: "audio", data: "audio-data" }, - { type: "resource_link", uri: "some-uri", name: "link" }, - ], - } as unknown as CallToolResult - const converted = convertToolResult(result, "test-tool", {}) - expect(converted).toHaveLength(1) - expect((converted[0] as { text: string }).text).toBe("Keep this") - }) -}) - -describe("@perstack/runtime: convertPart", () => { - describe("text parts", () => { - it("converts text part correctly", () => { - const part = { type: "text" as const, text: "Hello" } - const result = convertPart(part) - expect(result.type).toBe("textPart") - expect((result as { text: string }).text).toBe("Hello") - }) - - it("returns error message for empty text", () => { - const part = { type: "text" as const, text: "" } - const result = convertPart(part) - expect(result.type).toBe("textPart") - expect((result as { text: string }).text).toBe("Error: No content") - }) - - it("returns error message for undefined text", () => { - const part = { type: "text" as const, text: undefined as unknown as string } - const result = convertPart(part) - expect((result as { text: string }).text).toBe("Error: No content") - }) - }) - - describe("image parts", () => { - it("converts image part correctly", () => { - const part = { type: "image" as const, data: "base64data", mimeType: "image/png" } - const result = convertPart(part) - expect(result.type).toBe("imageInlinePart") - expect((result as { encodedData: string }).encodedData).toBe("base64data") - expect((result as { mimeType: string }).mimeType).toBe("image/png") - }) - - it("throws when image data is missing", () => { - const part = { type: "image" as const, mimeType: "image/png" } - expect(() => convertPart(part as never)).toThrow( - "Image part must have both data and mimeType", - ) - }) - - it("throws when image mimeType is missing", () => { - const part = { type: "image" as const, data: "base64data" } - expect(() => convertPart(part as never)).toThrow( - "Image part must have both data and mimeType", - ) - }) - }) - - describe("resource parts", () => { - it("converts resource part with text", () => { - const part = { - type: "resource" as const, - resource: { uri: "file://test", mimeType: "text/plain", text: "content" }, - } - const result = convertPart(part) - expect(result.type).toBe("textPart") - expect((result as { text: string }).text).toBe("content") - }) - - it("throws when resource is missing", () => { - const part = { type: "resource" as const } - expect(() => convertPart(part as never)).toThrow("Resource part must have resource content") - }) - }) -}) - -describe("@perstack/runtime: convertResource", () => { - it("converts text resource to textPart", () => { - const resource = { uri: "file://test", mimeType: "text/plain", text: "Hello" } - const result = convertResource(resource) - expect(result.type).toBe("textPart") - expect((result as { text: string }).text).toBe("Hello") - }) - - it("converts blob resource to fileInlinePart", () => { - const resource = { uri: "file://test", mimeType: "application/pdf", blob: "base64data" } - const result = convertResource(resource) - expect(result.type).toBe("fileInlinePart") - expect((result as { encodedData: string }).encodedData).toBe("base64data") - expect((result as { mimeType: string }).mimeType).toBe("application/pdf") - }) - - it("throws when mimeType is missing", () => { - const resource = { uri: "file://test", text: "content" } - expect(() => convertResource(resource as never)).toThrow("has no mimeType") - }) - - it("throws for unsupported resource type", () => { - const resource = { uri: "file://test", mimeType: "text/plain" } - expect(() => convertResource(resource)).toThrow("Unsupported resource type") - }) - - it("prioritizes text over blob when both present", () => { - const resource = { - uri: "file://test", - mimeType: "text/plain", - text: "text content", - blob: "blob data", - } - const result = convertResource(resource) - expect(result.type).toBe("textPart") - expect((result as { text: string }).text).toBe("text content") - }) -}) diff --git a/packages/runtime/src/skill-manager/mcp-converters.ts b/packages/runtime/src/skill-manager/mcp-converters.ts deleted file mode 100644 index 1e953a50..00000000 --- a/packages/runtime/src/skill-manager/mcp-converters.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { CallToolResult, McpError } from "@modelcontextprotocol/sdk/types.js" -import { createId } from "@paralleldrive/cuid2" -import type { - CallToolResultContent, - FileInlinePart, - ImageInlinePart, - Resource, - TextPart, -} from "@perstack/core" - -/** - * Handle MCP tool errors and convert them to TextPart responses. - * McpError instances are converted to error messages, other errors are re-thrown. - */ -export function handleToolError( - error: unknown, - toolName: string, - McpErrorClass: typeof McpError, -): Array { - if (error instanceof McpErrorClass) { - return [ - { - type: "textPart", - text: `Error calling tool ${toolName}: ${error.message}`, - id: createId(), - }, - ] - } - throw error -} - -/** - * Convert MCP CallToolResult to internal part types. - */ -export function convertToolResult( - result: CallToolResult, - toolName: string, - input: Record, -): Array { - if (!result.content || result.content.length === 0) { - return [ - { - type: "textPart", - text: `Tool ${toolName} returned nothing with arguments: ${JSON.stringify(input)}`, - id: createId(), - }, - ] - } - - return result.content - .filter((part) => part.type !== "audio" && part.type !== "resource_link") - .map((part) => convertPart(part as CallToolResultContent)) -} - -/** - * Convert a single MCP content part to internal part type. - */ -export function convertPart( - part: CallToolResultContent, -): TextPart | ImageInlinePart | FileInlinePart { - switch (part.type) { - case "text": - if (!part.text || part.text === "") { - return { type: "textPart", text: "Error: No content", id: createId() } - } - return { type: "textPart", text: part.text, id: createId() } - - case "image": - if (!part.data || !part.mimeType) { - throw new Error("Image part must have both data and mimeType") - } - return { - type: "imageInlinePart", - encodedData: part.data, - mimeType: part.mimeType, - id: createId(), - } - - case "resource": - if (!part.resource) { - throw new Error("Resource part must have resource content") - } - return convertResource(part.resource) - } -} - -/** - * Convert MCP resource to internal part type. - */ -export function convertResource(resource: Resource): TextPart | FileInlinePart { - if (!resource.mimeType) { - throw new Error(`Resource ${JSON.stringify(resource)} has no mimeType`) - } - - if (resource.text && typeof resource.text === "string") { - return { type: "textPart", text: resource.text, id: createId() } - } - - if (resource.blob && typeof resource.blob === "string") { - return { - type: "fileInlinePart", - encodedData: resource.blob, - mimeType: resource.mimeType, - id: createId(), - } - } - - throw new Error(`Unsupported resource type: ${JSON.stringify(resource)}`) -} 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/mcp.ts b/packages/runtime/src/skill-manager/mcp.ts deleted file mode 100644 index 1a451f0c..00000000 --- a/packages/runtime/src/skill-manager/mcp.ts +++ /dev/null @@ -1,200 +0,0 @@ -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" - -interface InitTimingInfo { - startTime: number - spawnDurationMs: number - handshakeDurationMs: number - serverInfo?: { name: string; version: string } -} - -export interface McpSkillManagerOptions { - transportFactory?: TransportFactory -} - -export class McpSkillManager extends BaseSkillManager { - readonly name: string - readonly type: SkillType = "mcp" - readonly lazyInit: boolean - override readonly skill: McpStdioSkill | McpSseSkill - private _mcpClient?: McpClient - private _env: Record - private _transportFactory: TransportFactory - - constructor( - skill: McpStdioSkill | McpSseSkill, - env: Record, - jobId: string, - runId: string, - eventListener?: (event: RunEvent | RuntimeEvent) => void, - options?: McpSkillManagerOptions, - ) { - super(jobId, runId, eventListener) - 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 { - 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) - } else { - await this._initSse(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, - description: tool.description, - 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 { - if (!skill.command) { - throw new PerstackError(`Skill ${skill.name} has no command`) - } - const requiredEnv: Record = {} - for (const envName of skill.requiredEnv) { - if (!this._env[envName]) { - throw new PerstackError(`Skill ${skill.name} requires environment variable ${envName}`) - } - 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 transport = this._transportFactory.createStdio({ command, args, env, stderr: "pipe" }) - const spawnDurationMs = Date.now() - startTime - 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) - } - }) - } - 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 { - if (!skill.endpoint) { - throw new PerstackError(`Skill ${skill.name} has no endpoint`) - } - const url = new URL(skill.endpoint) - if (url.protocol !== "https:") { - throw new PerstackError(`Skill ${skill.name} SSE endpoint must use HTTPS: ${skill.endpoint}`) - } - if (isPrivateOrLocalIP(url.hostname)) { - throw new PerstackError( - `Skill ${skill.name} SSE endpoint cannot use private/local IP: ${skill.endpoint}`, - ) - } - const transport = this._transportFactory.createSse({ url }) - await this._mcpClient!.connect(transport) - } - - override async close(): 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) - } - } - } - - 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)) - } - - override async callTool( - toolName: string, - input: Record, - ): Promise> { - if (!this.isInitialized() || !this._mcpClient) { - throw new Error(`${this.name} is not initialized`) - } - try { - const result = (await this._mcpClient.callTool({ - name: toolName, - arguments: input, - })) as CallToolResult - return convertToolResult(result, toolName, input) - } catch (error) { - return handleToolError(error, toolName, McpError) - } - } -} 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/skill-manager/transport-factory.ts b/packages/runtime/src/skill-manager/transport-factory.ts deleted file mode 100644 index 4a2153a4..00000000 --- a/packages/runtime/src/skill-manager/transport-factory.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js" - -export interface StdioTransportOptions { - command: string - args: string[] - env: Record - stderr?: "pipe" | "inherit" | "ignore" -} - -export interface SseTransportOptions { - url: URL -} - -/** - * Factory interface for creating MCP transports. - * Allows for dependency injection and easier testing. - */ -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] -} - -/** - * Default implementation of TransportFactory using real MCP SDK transports. - */ -export class DefaultTransportFactory implements TransportFactory { - createStdio(options: StdioTransportOptions): StdioClientTransport { - return new StdioClientTransport({ - command: options.command, - args: options.args, - env: options.env, - stderr: options.stderr, - }) - } - - createSse(options: SseTransportOptions): Transport { - return new SSEClientTransport(options.url) - } - - createInMemoryPair(): [Transport, Transport] { - return InMemoryTransport.createLinkedPair() - } -} - -/** - * Default transport factory instance. - */ -export const defaultTransportFactory = new DefaultTransportFactory() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 300b0fbe..1f6500fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -254,9 +254,6 @@ importers: '@perstack/perstack-toml': specifier: workspace:* version: link:../perstack-toml - '@perstack/runtime': - specifier: workspace:* - version: link:../runtime '@perstack/skill-manager': specifier: workspace:* version: link:../skill-manager @@ -669,18 +666,12 @@ 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 From a5e1f211343e3853e1419a05d1fbef37137191c3 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Mon, 16 Feb 2026 14:46:54 +0000 Subject: [PATCH 3/6] fix: emit skillConnected runtime events after skill manager creation The old skill-manager code emitted skillConnected events from each manager's init method. Add spawnDurationMs/connectDurationMs tracking to SkillAdapter and emit skillConnected events from coordinator-executor after SkillManager creation. Co-Authored-By: Claude Opus 4.6 --- .../src/orchestration/coordinator-executor.ts | 16 ++++++++++++++++ .../src/adapters/in-memory-base-adapter.ts | 2 ++ .../skill-manager/src/adapters/mcp-adapter.ts | 2 ++ packages/skill-manager/src/skill-adapter.ts | 14 ++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/packages/runtime/src/orchestration/coordinator-executor.ts b/packages/runtime/src/orchestration/coordinator-executor.ts index 4082a466..77e65c7b 100644 --- a/packages/runtime/src/orchestration/coordinator-executor.ts +++ b/packages/runtime/src/orchestration/coordinator-executor.ts @@ -78,6 +78,8 @@ export class CoordinatorExecutor { isDelegatedRun: !!checkpoint?.delegatedBy, }) + this.emitSkillConnectedEvents(setting, skillManager) + const initialCheckpoint = checkpoint ? createNextStepCheckpoint(createId(), checkpoint, setting.runId) : createInitialCheckpoint(createId(), { @@ -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/skill-manager/src/adapters/in-memory-base-adapter.ts b/packages/skill-manager/src/adapters/in-memory-base-adapter.ts index e67be9e9..19972b38 100644 --- a/packages/skill-manager/src/adapters/in-memory-base-adapter.ts +++ b/packages/skill-manager/src/adapters/in-memory-base-adapter.ts @@ -36,6 +36,8 @@ export class InMemoryBaseSkillAdapter extends SkillAdapter { } protected override async _doConnect(): Promise { + this._spawnDurationMs = 0 // No process spawn for in-memory + // Create linked transport pair const [clientTransport, serverTransport] = this._transportFactory.createInMemoryPair() diff --git a/packages/skill-manager/src/adapters/mcp-adapter.ts b/packages/skill-manager/src/adapters/mcp-adapter.ts index 408e3132..66d27cf7 100644 --- a/packages/skill-manager/src/adapters/mcp-adapter.ts +++ b/packages/skill-manager/src/adapters/mcp-adapter.ts @@ -75,7 +75,9 @@ export class McpSkillAdapter extends SkillAdapter { } const env = getFilteredEnv(requiredEnv) const { command, args } = getCommandArgs(skill) + const spawnStart = Date.now() const transport = this._transportFactory.createStdio({ command, args, env, stderr: "pipe" }) + this._spawnDurationMs = Date.now() - spawnStart if ((transport as StdioClientTransport).stderr) { ;(transport as StdioClientTransport).stderr!.on("data", (chunk: Buffer) => { this._emitLifecycleEvent("stderr", { diff --git a/packages/skill-manager/src/skill-adapter.ts b/packages/skill-manager/src/skill-adapter.ts index b71a50d9..0caf4bf0 100644 --- a/packages/skill-manager/src/skill-adapter.ts +++ b/packages/skill-manager/src/skill-adapter.ts @@ -23,6 +23,8 @@ export abstract class SkillAdapter { 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) { @@ -33,6 +35,16 @@ export abstract class SkillAdapter { 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`) @@ -45,9 +57,11 @@ export abstract class SkillAdapter { } 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) { From d5ca8756ea19209ae4930e787494bcc83269833e Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Mon, 16 Feb 2026 16:29:37 +0000 Subject: [PATCH 4/6] fix: skip MCP skills with missing requiredEnv instead of crashing Skills whose required environment variables are not available are now filtered out during SkillManager initialization. This replaces the old lazyInit behavior with a cleaner approach: availability is determined by env vars, not a config flag. Co-Authored-By: Claude Opus 4.6 --- packages/skill-manager/src/skill-manager.ts | 28 ++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/skill-manager/src/skill-manager.ts b/packages/skill-manager/src/skill-manager.ts index 5f4dbd78..39bcfedf 100644 --- a/packages/skill-manager/src/skill-manager.ts +++ b/packages/skill-manager/src/skill-manager.ts @@ -61,6 +61,18 @@ function applyBaseSkillCommandOverride( 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]) +} + /** * SkillManager manages all adapters for a single Expert. * Provides static factory methods for creation and dynamic management of skills/delegates. @@ -124,13 +136,14 @@ export class SkillManager { await connectAdaptersWithCleanup([baseAdapter], allAdapters) } - // Process MCP skills (excluding base if using bundled) + // 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) => { @@ -198,11 +211,13 @@ export class SkillManager { const allAdapters: SkillAdapter[] = [] - // Create lockfile adapters for MCP skills - const mcpSkills = Object.values(skills).filter( - (skill): skill is McpStdioSkill | McpSseSkill => - skill.type === "mcpStdioSkill" || skill.type === "mcpSseSkill", - ) + // 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({ @@ -285,6 +300,7 @@ export class SkillManager { 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) => { From dcb5a1ed3b15d182f690277043f53fd5a10b084e Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Mon, 16 Feb 2026 16:46:45 +0000 Subject: [PATCH 5/6] refactor: narrow skill-manager public API to only consumed exports Co-Authored-By: Claude Opus 4.6 --- packages/skill-manager/src/index.ts | 47 ++--------------------------- 1 file changed, 2 insertions(+), 45 deletions(-) diff --git a/packages/skill-manager/src/index.ts b/packages/skill-manager/src/index.ts index 401f53b0..2e13db36 100644 --- a/packages/skill-manager/src/index.ts +++ b/packages/skill-manager/src/index.ts @@ -1,46 +1,3 @@ -// Types - -export { DelegateSkillAdapter } from "./adapters/delegate-adapter.js" -export { InMemoryBaseSkillAdapter } from "./adapters/in-memory-base-adapter.js" -export { InteractiveSkillAdapter } from "./adapters/interactive-adapter.js" -export { LockfileSkillAdapter } from "./adapters/lockfile-adapter.js" -// Concrete adapters -export { McpSkillAdapter } from "./adapters/mcp-adapter.js" -// Delegate registry -export type { DelegateRegistry } from "./delegate-registry.js" -// Abstract base class -export { SkillAdapter } from "./skill-adapter.js" -// Factory -export type { SkillAdapterFactory, SkillAdapterFactoryContext } from "./skill-adapter-factory.js" -export { DefaultSkillAdapterFactory, defaultSkillAdapterFactory } from "./skill-adapter-factory.js" -// Skill manager +export type { SkillAdapter } from "./skill-adapter.js" export { SkillManager } from "./skill-manager.js" -// Transport factory -export type { - SseTransportOptions, - StdioTransportOptions, - TransportFactory, -} from "./transport-factory.js" -export { DefaultTransportFactory, defaultTransportFactory } from "./transport-factory.js" -export type { - CollectedToolDefinition, - LockfileInitOptions, - SkillAdapterLifecycleEvent, - SkillAdapterState, - SkillManagerOptions, - ToolCallResult, -} from "./types.js" -export { - hasExplicitBaseVersion, - isBaseSkill, - shouldUseBundledBase, -} from "./utils/base-skill-helpers.js" -// Utilities -export { type CommandArgs, getCommandArgs } from "./utils/command-args.js" -export { isPrivateOrLocalIP } from "./utils/ip-validator.js" -export { - convertPart, - convertResource, - convertToolResult, - handleToolError, -} from "./utils/mcp-converters.js" +export type { CollectedToolDefinition, LockfileInitOptions, SkillManagerOptions } from "./types.js" From ff591a7d24177c13fd500554c519fb525cb1ab76 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Mon, 16 Feb 2026 17:38:38 +0000 Subject: [PATCH 6/6] feat: add dynamic skill management tools to @perstack/base Bridge SkillManager's addSkill/removeSkill/addDelegate/removeDelegate methods to the LLM via MCP tools. Uses a deferred binding pattern where InMemoryBaseSkillAdapter registers tools with placeholder callbacks, then SkillManager.fromExpert() binds real implementations after construction. Co-Authored-By: Claude Opus 4.6 --- apps/base/src/index.ts | 2 + apps/base/src/server.ts | 11 +- apps/base/src/tools/skill-management.test.ts | 202 ++++++++++++++++++ apps/base/src/tools/skill-management.ts | 142 ++++++++++++ e2e/experts/skills.toml | 55 ++++- e2e/perstack-cli/skills.test.ts | 67 ++++++ .../src/adapters/in-memory-base-adapter.ts | 22 +- .../skill-manager/src/delegate-registry.ts | 11 - .../skill-manager/src/skill-manager.test.ts | 135 +++++++++++- packages/skill-manager/src/skill-manager.ts | 80 ++++++- pnpm-lock.yaml | 2 +- 11 files changed, 706 insertions(+), 23 deletions(-) create mode 100644 apps/base/src/tools/skill-management.test.ts create mode 100644 apps/base/src/tools/skill-management.ts delete mode 100644 packages/skill-manager/src/delegate-registry.ts 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/skill-manager/src/adapters/in-memory-base-adapter.ts b/packages/skill-manager/src/adapters/in-memory-base-adapter.ts index 19972b38..3265bf99 100644 --- a/packages/skill-manager/src/adapters/in-memory-base-adapter.ts +++ b/packages/skill-manager/src/adapters/in-memory-base-adapter.ts @@ -1,7 +1,7 @@ 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, createBaseServer } from "@perstack/base" +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" @@ -24,6 +24,20 @@ export class InMemoryBaseSkillAdapter extends SkillAdapter { 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, @@ -42,7 +56,7 @@ export class InMemoryBaseSkillAdapter extends SkillAdapter { 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 @@ -80,6 +94,10 @@ export class InMemoryBaseSkillAdapter extends SkillAdapter { .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, diff --git a/packages/skill-manager/src/delegate-registry.ts b/packages/skill-manager/src/delegate-registry.ts deleted file mode 100644 index 2bd738cb..00000000 --- a/packages/skill-manager/src/delegate-registry.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Expert } from "@perstack/core" -import type { SkillAdapter } from "./skill-adapter.js" - -/** Registry for managing delegate adapters */ -export interface DelegateRegistry { - register(expert: Expert): SkillAdapter - unregister(expertKey: string): boolean - get(expertKey: string): SkillAdapter | undefined - getAll(): ReadonlyMap - has(expertKey: string): boolean -} diff --git a/packages/skill-manager/src/skill-manager.test.ts b/packages/skill-manager/src/skill-manager.test.ts index 032dc477..c7ead79e 100644 --- a/packages/skill-manager/src/skill-manager.test.ts +++ b/packages/skill-manager/src/skill-manager.test.ts @@ -1,8 +1,9 @@ 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 { SkillManager } from "./skill-manager.js" +import { buildSkillFromInput, SkillManager } from "./skill-manager.js" import type { ToolCallResult } from "./types.js" // Helper to create mock adapters @@ -620,3 +621,135 @@ describe("@perstack/skill-manager: SkillManager", () => { }) }) }) + +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 index 39bcfedf..eb968499 100644 --- a/packages/skill-manager/src/skill-manager.ts +++ b/packages/skill-manager/src/skill-manager.ts @@ -5,6 +5,7 @@ import type { 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" @@ -65,14 +66,56 @@ function applyBaseSkillCommandOverride( * 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 { +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. @@ -184,7 +227,34 @@ export class SkillManager { for (const adapter of allAdapters) { adapterMap.set(adapter.name, adapter) } - return new SkillManager(adapterMap, factory, env) + 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( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f6500fc..5db2a352 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -766,7 +766,7 @@ importers: 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.0.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) + 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: