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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/coordinator-delegate-separation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@perstack/core": patch
"@perstack/runtime": patch
"@perstack/skill-manager": patch
"@perstack/installer": patch
---

Add coordinator/delegate expert separation with naming convention, delegation rule enforcement, and type-specific meta-prompts
1 change: 0 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ The `exec` tool blocks overriding critical variables (case-insensitive):

- Run in isolated directories without sensitive data
- Review tool calls in verbose mode
- Set `maxSteps` limits

### For Production

Expand Down
60 changes: 60 additions & 0 deletions apps/base/src/tools/skill-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"
import type { SkillManagementCallbacks } from "./skill-management.js"
import {
registerAddDelegate,
registerAddDelegateFromConfig,
registerAddSkill,
registerCreateExpert,
registerRemoveDelegate,
Expand All @@ -15,6 +16,9 @@ function createMockCallbacks(): SkillManagementCallbacks {
addDelegate: vi.fn().mockResolvedValue({ delegateToolName: "delegate-tool" }),
removeDelegate: vi.fn().mockResolvedValue(undefined),
createExpert: vi.fn().mockResolvedValue({ expertKey: "my-expert" }),
addDelegateFromConfig: vi
.fn()
.mockResolvedValue({ delegateToolName: "delegate-from-config-tool" }),
}
}

Expand Down Expand Up @@ -250,4 +254,60 @@ describe("skill-management tools", () => {
})
})
})

describe("addDelegateFromConfig", () => {
it("registers tool with correct metadata", () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
registerAddDelegateFromConfig(server as never, callbacks)
expect(server.registerTool).toHaveBeenCalledWith(
"addDelegateFromConfig",
expect.objectContaining({ title: "Add delegate from config" }),
expect.any(Function),
)
})

it("calls callback with correct input and returns delegate tool name", async () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
registerAddDelegateFromConfig(server as never, callbacks)
const handler = getHandler(server)
const input = {
configPath: "/path/to/perstack.toml",
delegateExpertName: "my-expert",
}
const result = await handler(input)
expect(callbacks.addDelegateFromConfig).toHaveBeenCalledWith(input)
expect(result).toStrictEqual({
content: [
{
type: "text",
text: JSON.stringify({ delegateToolName: "delegate-from-config-tool" }),
},
],
})
})

it("returns errorToolResult when callback throws", async () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
;(callbacks.addDelegateFromConfig as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("config not found"),
)
registerAddDelegateFromConfig(server as never, callbacks)
const handler = getHandler(server)
const result = await handler({
configPath: "/bad/path.toml",
delegateExpertName: "missing",
})
expect(result).toStrictEqual({
content: [
{
type: "text",
text: JSON.stringify({ error: "Error", message: "config not found" }),
},
],
})
})
})
})
33 changes: 33 additions & 0 deletions apps/base/src/tools/skill-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface SkillManagementCallbacks {
tags?: string[]
providerTools?: string[]
}): Promise<{ expertKey: string }>
addDelegateFromConfig(input: {
configPath: string
delegateExpertName: string
}): Promise<{ delegateToolName: string }>
}

export function registerAddSkill(server: McpServer, callbacks: SkillManagementCallbacks) {
Expand Down Expand Up @@ -233,6 +237,34 @@ export function registerCreateExpert(server: McpServer, callbacks: SkillManageme
)
}

export function registerAddDelegateFromConfig(
server: McpServer,
callbacks: SkillManagementCallbacks,
) {
server.registerTool(
"addDelegateFromConfig",
{
title: "Add delegate from config",
description:
"Load all experts from a perstack.toml config file and add the specified one as a delegate. This is a shortcut that combines reading the config, creating the expert, and adding it as a delegate in a single step.",
inputSchema: {
configPath: z.string().describe("Path to the perstack.toml config file"),
delegateExpertName: z
.string()
.describe("Name of the expert in the config to add as a delegate"),
},
},
async (input: { configPath: string; delegateExpertName: string }) => {
try {
return successToolResult(await callbacks.addDelegateFromConfig(input))
} catch (e) {
if (e instanceof Error) return errorToolResult(e)
throw e
}
},
)
}

export function registerSkillManagementTools(
server: McpServer,
callbacks: SkillManagementCallbacks,
Expand All @@ -242,4 +274,5 @@ export function registerSkillManagementTools(
registerAddDelegate(server, callbacks)
registerRemoveDelegate(server, callbacks)
registerCreateExpert(server, callbacks)
registerAddDelegateFromConfig(server, callbacks)
}
6 changes: 0 additions & 6 deletions apps/create-expert-skill/src/tools/run-expert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ interface RunExpertInput {
provider: string
model?: string
timeout: number
maxSteps?: number
}

interface RunExpertOutput {
Expand Down Expand Up @@ -156,10 +155,6 @@ export async function runExpert(input: RunExpertInput): Promise<RunExpertOutput>
args.push("--timeout", String(input.timeout))
}

if (input.maxSteps) {
args.push("--max-steps", String(input.maxSteps))
}

args.push(input.expertKey, input.query)

// Map PROVIDER_API_KEY to the provider-specific env var
Expand Down Expand Up @@ -274,7 +269,6 @@ export function registerRunExpert(server: McpServer) {
.optional()
.default(120000)
.describe("Timeout in milliseconds (default: 120000)"),
maxSteps: z.number().optional().describe("Maximum steps (optional)"),
},
},
async (input: RunExpertInput) => {
Expand Down
8 changes: 2 additions & 6 deletions apps/create-expert/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ new Command()
"--reasoning-budget <budget>",
"Reasoning budget for native LLM reasoning (minimal, low, medium, high, or token count)",
)
.option(
"--max-steps <maxSteps>",
"Maximum number of steps to run, default is undefined (no limit)",
)
.option("--max-retries <maxRetries>", "Maximum number of generation retries, default is 5")
.option(
"--timeout <timeout>",
Expand Down Expand Up @@ -66,13 +62,13 @@ new Command()
console.error("Error: query argument is required in headless mode")
process.exit(1)
}
await runHandler("expert", query, options, {
await runHandler("create-expert", query, options, {
perstackConfig: config,
lockfile,
additionalEnv,
})
} else {
await startHandler("expert", query, options, {
await startHandler("create-expert", query, options, {
perstackConfig: config,
lockfile,
additionalEnv,
Expand Down
Loading