From 68d89ce9c6b2fa642d7e8ae17d43f4cc271e551d Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Tue, 17 Feb 2026 09:12:01 +0000 Subject: [PATCH 1/2] refactor: align create-expert e2e tests with shared utilities - Use withEventParsing from runner.ts instead of raw parseEvents - Add delegation verification (stopRunByDelegate events) - Add JSDoc header comment matching other e2e test conventions - Increase timeout to 180s for delegation round-trips - Move createTempDir to module scope - Add create-expert section to e2e/README.md Co-Authored-By: Claude Opus 4.6 --- e2e/README.md | 11 ++++++ e2e/create-expert/create-expert.test.ts | 48 +++++++++++++++---------- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index 51cfdb13..95783d9a 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -25,6 +25,8 @@ pnpm test:e2e -- --testNamePattern "delegate" ``` e2e/ +├── create-expert/ # Create expert tests +│ └── create-expert.test.ts # Expert creation and modification ├── perstack-cli/ # perstack CLI tests │ ├── bundled-base.test.ts # Bundled base skill │ ├── continue.test.ts # Continue job, resume from checkpoint @@ -59,6 +61,15 @@ e2e/ ## Functional Test Categories +### create-expert/ + +#### Create Expert (`create-expert.test.ts`) + +| Test | Purpose | +| ----------------------------------------- | ----------------------------------------- | +| `should create a new perstack.toml` | Verify new expert creation via delegation | +| `should modify an existing perstack.toml` | Verify existing experts are preserved | + ### perstack-cli/ #### Continue Job (`continue.test.ts`) diff --git a/e2e/create-expert/create-expert.test.ts b/e2e/create-expert/create-expert.test.ts index 7fc1db4d..6570d80a 100644 --- a/e2e/create-expert/create-expert.test.ts +++ b/e2e/create-expert/create-expert.test.ts @@ -1,3 +1,13 @@ +/** + * Create Expert E2E Tests + * + * Tests the create-expert agent that creates/modifies perstack.toml files: + * - Creates new expert definitions via createExpert + addDelegate workflow + * - Tests experts in-process via delegation before writing perstack.toml + * - Preserves existing experts when modifying perstack.toml + * + * Config: apps/create-expert/perstack.toml + */ import { spawn } from "node:child_process" import fs from "node:fs" import os from "node:os" @@ -5,20 +15,18 @@ import path from "node:path" import TOML from "smol-toml" import { describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" -import { parseEvents } from "../lib/event-parser.js" +import { filterEventsByType } from "../lib/event-parser.js" import { injectProviderArgs } from "../lib/round-robin.js" +import { type CommandResult, type RunResult, withEventParsing } from "../lib/runner.js" -const LLM_TIMEOUT = 120_000 const PROJECT_ROOT = path.resolve(process.cwd()) const CONFIG_PATH = path.join(PROJECT_ROOT, "apps/create-expert/perstack.toml") +// LLM API calls require extended timeout; delegation adds extra LLM round-trips +const LLM_TIMEOUT = 180_000 -function runCreateExpert( - query: string, - cwd: string, - timeout = LLM_TIMEOUT, -): Promise<{ stdout: string; stderr: string; exitCode: number }> { +function runCreateExpert(query: string, cwd: string, timeout = LLM_TIMEOUT): Promise { const args = injectProviderArgs(["run", "--config", CONFIG_PATH, "expert", query]) - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let stdout = "" let stderr = "" const proc = spawn( @@ -48,14 +56,14 @@ function runCreateExpert( clearTimeout(timer) reject(err) }) - }) + }).then(withEventParsing) } -describe("create-expert", () => { - function createTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "create-expert-")) - } +function createTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "create-expert-")) +} +describe("create-expert", () => { it( "should create a new perstack.toml", async () => { @@ -67,9 +75,13 @@ describe("create-expert", () => { ) expect(result.exitCode).toBe(0) + expect(assertEventSequenceContains(result.events, ["startRun", "completeRun"]).passed).toBe( + true, + ) - const events = parseEvents(result.stdout) - expect(assertEventSequenceContains(events, ["startRun", "completeRun"]).passed).toBe(true) + // Verify delegation occurred (createExpert + addDelegate + delegate workflow) + const delegateEvents = filterEventsByType(result.events, "stopRunByDelegate") + expect(delegateEvents.length).toBeGreaterThanOrEqual(1) // Verify perstack.toml was created const tomlPath = path.join(tempDir, "perstack.toml") @@ -113,9 +125,9 @@ pick = ["attemptCompletion"] const result = await runCreateExpert("Add a testing expert that runs unit tests", tempDir) expect(result.exitCode).toBe(0) - - const events = parseEvents(result.stdout) - expect(assertEventSequenceContains(events, ["startRun", "completeRun"]).passed).toBe(true) + expect(assertEventSequenceContains(result.events, ["startRun", "completeRun"]).passed).toBe( + true, + ) // Verify perstack.toml was updated const tomlPath = path.join(tempDir, "perstack.toml") From 8a9a0b47fadc585eda1629c9d4d0bdade4a90a3f Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Tue, 17 Feb 2026 09:14:24 +0000 Subject: [PATCH 2/2] feat: add createExpert tool and replace runExpert workflow with delegation - Add createExpert callback and MCP tool to skill-management for dynamically creating expert definitions in memory - Implement createExpert in SkillManager.fromExpert to parse and store experts in the experts map - Update runtime to pass dynamically created experts to delegation executor - Update create-expert perstack.toml to use createExpert + addDelegate workflow instead of runExpert subprocess - Remove create-expert-skill dependency from create-expert config - Add unit tests for createExpert in skill-management and skill-manager Co-Authored-By: Claude Opus 4.6 --- .changeset/create-expert-delegation.md | 8 ++ apps/base/src/tools/skill-management.test.ts | 51 +++++++++ apps/base/src/tools/skill-management.ts | 103 ++++++++++++++++++ apps/create-expert/perstack.toml | 47 ++++---- e2e/create-expert/create-expert.test.ts | 80 +++++++++----- packages/runtime/src/run.ts | 5 +- .../src/adapters/in-memory-base-adapter.ts | 3 + .../skill-manager/src/skill-manager.test.ts | 47 +++++++- packages/skill-manager/src/skill-manager.ts | 28 ++++- 9 files changed, 313 insertions(+), 59 deletions(-) create mode 100644 .changeset/create-expert-delegation.md diff --git a/.changeset/create-expert-delegation.md b/.changeset/create-expert-delegation.md new file mode 100644 index 00000000..be85627d --- /dev/null +++ b/.changeset/create-expert-delegation.md @@ -0,0 +1,8 @@ +--- +"@perstack/base": patch +"create-expert": patch +"@perstack/runtime": patch +"@perstack/skill-manager": patch +--- + +Add createExpert tool and replace runExpert workflow with in-process delegation diff --git a/apps/base/src/tools/skill-management.test.ts b/apps/base/src/tools/skill-management.test.ts index 79baf14f..d75370a9 100644 --- a/apps/base/src/tools/skill-management.test.ts +++ b/apps/base/src/tools/skill-management.test.ts @@ -3,6 +3,7 @@ import type { SkillManagementCallbacks } from "./skill-management.js" import { registerAddDelegate, registerAddSkill, + registerCreateExpert, registerRemoveDelegate, registerRemoveSkill, } from "./skill-management.js" @@ -13,6 +14,7 @@ function createMockCallbacks(): SkillManagementCallbacks { removeSkill: vi.fn().mockResolvedValue(undefined), addDelegate: vi.fn().mockResolvedValue({ delegateToolName: "delegate-tool" }), removeDelegate: vi.fn().mockResolvedValue(undefined), + createExpert: vi.fn().mockResolvedValue({ expertKey: "my-expert" }), } } @@ -199,4 +201,53 @@ describe("skill-management tools", () => { }) }) }) + + describe("createExpert", () => { + it("registers tool with correct metadata", () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + registerCreateExpert(server as never, callbacks) + expect(server.registerTool).toHaveBeenCalledWith( + "createExpert", + expect.objectContaining({ title: "Create expert" }), + expect.any(Function), + ) + }) + + it("calls callback with correct input and returns expert key", async () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + registerCreateExpert(server as never, callbacks) + const handler = getHandler(server) + const input = { + key: "test-expert", + instruction: "Test instruction", + description: "A test expert", + } + const result = await handler(input) + expect(callbacks.createExpert).toHaveBeenCalledWith(input) + expect(result).toStrictEqual({ + content: [{ type: "text", text: JSON.stringify({ expertKey: "my-expert" }) }], + }) + }) + + it("returns errorToolResult when callback throws", async () => { + const server = createMockServer() + const callbacks = createMockCallbacks() + ;(callbacks.createExpert as ReturnType).mockRejectedValue( + new Error("invalid expert"), + ) + registerCreateExpert(server as never, callbacks) + const handler = getHandler(server) + const result = await handler({ key: "bad", instruction: "x" }) + expect(result).toStrictEqual({ + content: [ + { + type: "text", + text: JSON.stringify({ error: "Error", message: "invalid expert" }), + }, + ], + }) + }) + }) }) diff --git a/apps/base/src/tools/skill-management.ts b/apps/base/src/tools/skill-management.ts index e74b2840..e66c673c 100644 --- a/apps/base/src/tools/skill-management.ts +++ b/apps/base/src/tools/skill-management.ts @@ -19,6 +19,31 @@ export interface SkillManagementCallbacks { removeSkill(skillName: string): Promise addDelegate(expertKey: string): Promise<{ delegateToolName: string }> removeDelegate(expertName: string): Promise + createExpert(input: { + key: string + instruction: string + description?: string + version?: string + skills?: Record< + string, + { + type: "mcpStdioSkill" | "mcpSseSkill" + command?: string + packageName?: string + args?: string[] + requiredEnv?: string[] + endpoint?: string + description?: string + rule?: string + pick?: string[] + omit?: string[] + lazyInit?: boolean + } + > + delegates?: string[] + tags?: string[] + providerTools?: string[] + }): Promise<{ expertKey: string }> } export function registerAddSkill(server: McpServer, callbacks: SkillManagementCallbacks) { @@ -131,6 +156,83 @@ export function registerRemoveDelegate(server: McpServer, callbacks: SkillManage ) } +export function registerCreateExpert(server: McpServer, callbacks: SkillManagementCallbacks) { + server.registerTool( + "createExpert", + { + title: "Create expert", + description: + "Dynamically create an expert definition in memory. Returns the expert key so you can add it as a delegate.", + inputSchema: { + key: z.string().describe("Unique expert key (kebab-case)"), + instruction: z.string().describe("System instruction for the expert"), + description: z.string().optional().describe("Human-readable description"), + version: z.string().optional().describe("Semantic version (defaults to 1.0.0)"), + skills: z + .record( + z.string(), + z.object({ + 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)"), + lazyInit: z.boolean().optional().describe("Lazy initialization"), + }), + ) + .optional() + .describe("Skills map (defaults to @perstack/base)"), + delegates: z.array(z.string()).optional().describe("Expert keys to delegate to"), + tags: z.array(z.string()).optional().describe("Tags for categorization"), + providerTools: z.array(z.string()).optional().describe("Provider-specific tool names"), + }, + }, + async (input: { + key: string + instruction: string + description?: string + version?: string + skills?: Record< + string, + { + type: "mcpStdioSkill" | "mcpSseSkill" + command?: string + packageName?: string + args?: string[] + requiredEnv?: string[] + endpoint?: string + description?: string + rule?: string + pick?: string[] + omit?: string[] + lazyInit?: boolean + } + > + delegates?: string[] + tags?: string[] + providerTools?: string[] + }) => { + try { + return successToolResult(await callbacks.createExpert(input)) + } catch (e) { + if (e instanceof Error) return errorToolResult(e) + throw e + } + }, + ) +} + export function registerSkillManagementTools( server: McpServer, callbacks: SkillManagementCallbacks, @@ -139,4 +241,5 @@ export function registerSkillManagementTools( registerRemoveSkill(server, callbacks) registerAddDelegate(server, callbacks) registerRemoveDelegate(server, callbacks) + registerCreateExpert(server, callbacks) } diff --git a/apps/create-expert/perstack.toml b/apps/create-expert/perstack.toml index dbf8e205..0fb8fd5d 100644 --- a/apps/create-expert/perstack.toml +++ b/apps/create-expert/perstack.toml @@ -65,22 +65,26 @@ pick = ["readTextFile", "writeTextFile", "listDirectory", "think", "attemptCompl 1. First, check if a `perstack.toml` already exists in the current directory using `readTextFile` 2. If it exists, read and understand the current configuration -3. Based on the user's request, create or modify the expert definition -4. Write the updated perstack.toml using `writeTextFile` -5. Preserve all existing content when modifying (do not remove existing experts unless asked) -6. After writing, test-run the expert using `runExpert` to verify it works -7. Review the activities: check that expected tools were called and the completion text is reasonable -8. If the test run shows errors or unexpected behavior, fix the perstack.toml and re-test -9. Use `attemptCompletion` when the expert is created and verified - -## Testing with runExpert - -After writing a perstack.toml file, always test-run the expert you created: -- Use the absolute path to the perstack.toml you just wrote as `configPath` (use the current working directory path) -- Use the expert key you defined as `expertKey` -- Choose a simple, realistic query that exercises the expert's core functionality -- Review the activities: check that expected tools were called and the completion text is reasonable -- If the run fails or produces errors, fix the perstack.toml and re-test +3. Based on the user's request, draft the expert definition +4. Create the expert in memory using `createExpert` to validate the definition +5. Add it as a delegate using `addDelegate` so you can test it +6. Test the expert by calling the delegate tool with a simple, realistic query +7. Review the result: check that the expert behaves as expected +8. If the test shows errors or unexpected behavior: + - Use `removeDelegate` to remove the current delegate + - Modify the definition and call `createExpert` again with the same key + - Add it as a delegate again with `addDelegate` and re-test +9. Once the expert works correctly, write the final `perstack.toml` using `writeTextFile` +10. Use `attemptCompletion` when the expert is created and verified + +## Testing with createExpert + addDelegate + +After drafting an expert definition, always test it in memory before writing perstack.toml: +- Use `createExpert` with the expert key, instruction, description, skills, and other fields +- Use `addDelegate` with the expert key to make it callable +- Call the delegate tool with a simple query that exercises the expert's core functionality +- Review the result to verify correctness +- If issues arise, iterate: `removeDelegate` -> fix -> `createExpert` -> `addDelegate` -> re-test ## Important Rules @@ -106,12 +110,7 @@ pick = [ "getFileInfo", "think", "attemptCompletion", + "createExpert", + "addDelegate", + "removeDelegate", ] - -[experts."expert".skills."create-expert-skill"] -type = "mcpStdioSkill" -description = "Test-run expert definitions to verify they work correctly" -command = "npx" -packageName = "@perstack/create-expert-skill" -requiredEnv = ["PROVIDER_API_KEY"] -rule = "After creating or modifying an expert in perstack.toml, use runExpert to test it with a simple query. Review the activities to verify correctness." diff --git a/e2e/create-expert/create-expert.test.ts b/e2e/create-expert/create-expert.test.ts index 6570d80a..a7476995 100644 --- a/e2e/create-expert/create-expert.test.ts +++ b/e2e/create-expert/create-expert.test.ts @@ -6,7 +6,7 @@ * - Tests experts in-process via delegation before writing perstack.toml * - Preserves existing experts when modifying perstack.toml * - * Config: apps/create-expert/perstack.toml + * Binary: apps/create-expert/dist/bin/cli.js (--headless mode) */ import { spawn } from "node:child_process" import fs from "node:fs" @@ -15,29 +15,25 @@ import path from "node:path" import TOML from "smol-toml" import { describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" -import { filterEventsByType } from "../lib/event-parser.js" +import { extractToolCalls, filterEventsByType } from "../lib/event-parser.js" import { injectProviderArgs } from "../lib/round-robin.js" import { type CommandResult, type RunResult, withEventParsing } from "../lib/runner.js" const PROJECT_ROOT = path.resolve(process.cwd()) -const CONFIG_PATH = path.join(PROJECT_ROOT, "apps/create-expert/perstack.toml") +const CLI_PATH = path.join(PROJECT_ROOT, "apps/create-expert/dist/bin/cli.js") // LLM API calls require extended timeout; delegation adds extra LLM round-trips const LLM_TIMEOUT = 180_000 function runCreateExpert(query: string, cwd: string, timeout = LLM_TIMEOUT): Promise { - const args = injectProviderArgs(["run", "--config", CONFIG_PATH, "expert", query]) + const args = injectProviderArgs(["--headless", query]) return new Promise((resolve, reject) => { let stdout = "" let stderr = "" - const proc = spawn( - "node", - [path.join(PROJECT_ROOT, "apps/perstack/dist/bin/cli.js"), ...args], - { - cwd, - env: { ...process.env }, - stdio: ["pipe", "pipe", "pipe"], - }, - ) + const proc = spawn("node", [CLI_PATH, ...args], { + cwd, + env: { ...process.env }, + stdio: ["pipe", "pipe", "pipe"], + }) const timer = setTimeout(() => { proc.kill("SIGTERM") reject(new Error(`Timeout after ${timeout}ms`)) @@ -63,6 +59,13 @@ function createTempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "create-expert-")) } +/** Extract all tool names called across callTools events */ +function getAllCalledToolNames(result: RunResult): string[] { + return filterEventsByType(result.events, "callTools").flatMap((e) => + extractToolCalls(e).map((tc) => tc.toolName), + ) +} + describe("create-expert", () => { it( "should create a new perstack.toml", @@ -75,21 +78,37 @@ describe("create-expert", () => { ) expect(result.exitCode).toBe(0) - expect(assertEventSequenceContains(result.events, ["startRun", "completeRun"]).passed).toBe( + + // Verify control flow: coordinator starts, delegates, then completes + const controlFlow = result.events + .filter((e) => ["startRun", "stopRunByDelegate", "completeRun"].includes(e.type)) + .map((e) => e.type) + expect(controlFlow[0]).toBe("startRun") + expect(controlFlow).toContain("stopRunByDelegate") + expect(controlFlow.at(-1)).toBe("completeRun") + + // Verify the coordinator (expert) starts and completes + const startEvents = filterEventsByType(result.events, "startRun") + const completeEvents = filterEventsByType(result.events, "completeRun") + expect(startEvents.some((e) => (e as { expertKey: string }).expertKey === "expert")).toBe( + true, + ) + expect(completeEvents.some((e) => (e as { expertKey: string }).expertKey === "expert")).toBe( true, ) - // Verify delegation occurred (createExpert + addDelegate + delegate workflow) - const delegateEvents = filterEventsByType(result.events, "stopRunByDelegate") - expect(delegateEvents.length).toBeGreaterThanOrEqual(1) + // Verify delegation: at least 2 completeRun (delegate + coordinator) + expect(completeEvents.length).toBeGreaterThanOrEqual(2) - // Verify perstack.toml was created + // Verify createExpert + addDelegate tools were called + const toolNames = getAllCalledToolNames(result) + expect(toolNames).toContain("createExpert") + expect(toolNames).toContain("addDelegate") + + // Verify perstack.toml was created with valid expert definitions const tomlPath = path.join(tempDir, "perstack.toml") expect(fs.existsSync(tomlPath)).toBe(true) - - // Verify it's valid TOML with expert definitions - const tomlContent = fs.readFileSync(tomlPath, "utf-8") - const parsed = TOML.parse(tomlContent) + const parsed = TOML.parse(fs.readFileSync(tomlPath, "utf-8")) expect(parsed.experts).toBeDefined() expect(Object.keys(parsed.experts as Record).length).toBeGreaterThanOrEqual( 1, @@ -125,14 +144,21 @@ pick = ["attemptCompletion"] const result = await runCreateExpert("Add a testing expert that runs unit tests", tempDir) expect(result.exitCode).toBe(0) - expect(assertEventSequenceContains(result.events, ["startRun", "completeRun"]).passed).toBe( - true, - ) + + // Verify control flow: start → delegate → complete + expect( + assertEventSequenceContains(result.events, ["startRun", "stopRunByDelegate", "completeRun"]) + .passed, + ).toBe(true) + + // Verify createExpert + addDelegate tools were called + const toolNames = getAllCalledToolNames(result) + expect(toolNames).toContain("createExpert") + expect(toolNames).toContain("addDelegate") // Verify perstack.toml was updated const tomlPath = path.join(tempDir, "perstack.toml") - const tomlContent = fs.readFileSync(tomlPath, "utf-8") - const parsed = TOML.parse(tomlContent) + const parsed = TOML.parse(fs.readFileSync(tomlPath, "utf-8")) expect(parsed.experts).toBeDefined() const experts = parsed.experts as Record diff --git a/packages/runtime/src/run.ts b/packages/runtime/src/run.ts index d54bf790..fbf9ee07 100755 --- a/packages/runtime/src/run.ts +++ b/packages/runtime/src/run.ts @@ -137,9 +137,12 @@ export async function run(runInput: RunParamsInput, options?: RunOptions): Promi const executor = new DelegationExecutor() const context = extractDelegationContext(resultCheckpoint) + // Use runResult.experts to include dynamically created experts + const updatedSetting = { ...setting, experts: runResult.experts } + const delegationResult = await executor.execute( delegateTo, - setting, + updatedSetting, context, runResult.expertToRun, run, 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 3265bf99..8e3bc635 100644 --- a/packages/skill-manager/src/adapters/in-memory-base-adapter.ts +++ b/packages/skill-manager/src/adapters/in-memory-base-adapter.ts @@ -37,6 +37,9 @@ export class InMemoryBaseSkillAdapter extends SkillAdapter { removeDelegate: () => { throw new Error("Skill management not initialized") }, + createExpert: () => { + throw new Error("Skill management not initialized") + }, } constructor( diff --git a/packages/skill-manager/src/skill-manager.test.ts b/packages/skill-manager/src/skill-manager.test.ts index 25c28fb6..1b209c71 100644 --- a/packages/skill-manager/src/skill-manager.test.ts +++ b/packages/skill-manager/src/skill-manager.test.ts @@ -753,10 +753,54 @@ describe("InMemoryBaseSkillAdapter binding", () => { 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 }) + const createExpert = vi.fn().mockResolvedValue({ expertKey: "x" }) + adapter.bindSkillManagement({ + addSkill, + removeSkill, + addDelegate, + removeDelegate, + createExpert, + }) // No error means the binding succeeded — real verification happens through integration }) + it("createExpert callback adds expert to experts map", async () => { + const baseAdapter = createMockAdapter("@perstack/base", [ + { + skillName: "@perstack/base", + name: "createExpert", + description: "Create expert", + inputSchema: { type: "object" }, + interactive: false, + }, + ]) + const bindFn = vi.fn() + ;(baseAdapter as unknown as { bindSkillManagement: typeof bindFn }).bindSkillManagement = bindFn + Object.setPrototypeOf(baseAdapter, InMemoryBaseSkillAdapter.prototype) + + const factory = createMockFactory({ "@perstack/base": baseAdapter }) + const experts: Record = {} + const expert = createExpert() + await SkillManager.fromExpert(expert, experts, { env: {}, factory }) + + const callbacks = bindFn.mock.calls[0][0] + const result = await callbacks.createExpert({ + key: "new-expert", + instruction: "Do something useful", + description: "A dynamically created expert", + }) + + expect(result).toStrictEqual({ expertKey: "new-expert" }) + expect(experts["new-expert"]).toBeDefined() + expect(experts["new-expert"].key).toBe("new-expert") + expect(experts["new-expert"].name).toBe("new-expert") + expect(experts["new-expert"].instruction).toBe("Do something useful") + expect(experts["new-expert"].description).toBe("A dynamically created expert") + expect(experts["new-expert"].version).toBe("1.0.0") + expect(experts["new-expert"].delegates).toStrictEqual([]) + expect(experts["new-expert"].tags).toStrictEqual([]) + }) + it("fromExpert binds skill management to in-memory base adapter", async () => { const baseAdapter = createMockAdapter("@perstack/base", [ { @@ -783,6 +827,7 @@ describe("InMemoryBaseSkillAdapter binding", () => { removeSkill: expect.any(Function), addDelegate: expect.any(Function), removeDelegate: expect.any(Function), + createExpert: 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 48c45245..d215a0a4 100644 --- a/packages/skill-manager/src/skill-manager.ts +++ b/packages/skill-manager/src/skill-manager.ts @@ -1,9 +1,10 @@ -import type { - Expert, - InteractiveSkill, - McpSseSkill, - McpStdioSkill, - ToolDefinition, +import { + type Expert, + expertSchema, + type InteractiveSkill, + type McpSseSkill, + type McpStdioSkill, + type ToolDefinition, } from "@perstack/core" import { InMemoryBaseSkillAdapter } from "./adapters/in-memory-base-adapter.js" import { LockfileSkillAdapter } from "./adapters/lockfile-adapter.js" @@ -253,6 +254,21 @@ export class SkillManager { return { delegateToolName: toolName } }, removeDelegate: (name) => sm.removeDelegate(name), + createExpert: async (input) => { + const expert = expertSchema.parse({ + key: input.key, + name: input.key, + version: input.version ?? "1.0.0", + description: input.description, + instruction: input.instruction, + skills: input.skills, + delegates: input.delegates, + tags: input.tags, + providerTools: input.providerTools, + }) + experts[expert.key] = expert + return { expertKey: expert.key } + }, }) break }