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
2 changes: 2 additions & 0 deletions .changeset/rich-numbers-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
6 changes: 4 additions & 2 deletions e2e/create-expert/create-expert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import { type CommandResult, type RunResult, withEventParsing } from "../lib/run

const PROJECT_ROOT = path.resolve(process.cwd())
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
// LLM API calls require extended timeout; delegation adds extra LLM round-trips.
// The create-expert workflow involves multiple delegation round-trips which can
// take over 2 minutes when the LLM needs to iterate on expert definitions.
const LLM_TIMEOUT = 300_000

function runCreateExpert(query: string, cwd: string, timeout = LLM_TIMEOUT): Promise<RunResult> {
const args = injectProviderArgs(["--headless", query])
Expand Down
40 changes: 39 additions & 1 deletion packages/runtime/src/orchestration/delegation-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,43 @@ describe("@perstack/runtime: delegation-executor", () => {
expect(result.nextCheckpoint.usage.reasoningTokens).toBe(10) // 0 + 0 + 10
})

it("throws error if delegation result has no expertMessage", async () => {
it("returns error result when child run returns stoppedByError", async () => {
const executor = new DelegationExecutor()
const setting = createMockSetting()
const delegations = [createMockDelegation({ toolCallId: "tc-1" })]
const context = createMockContext()
const parentExpert = { key: "parent", name: "Parent", version: "1.0" }

const runFn = vi.fn().mockResolvedValue({
...createMockCheckpoint(),
status: "stoppedByError",
messages: [],
})

const result = await executor.execute(delegations, setting, context, parentExpert, runFn)

expect(result.nextSetting.input.interactiveToolCallResult?.text).toContain(
"ended with status: stoppedByError",
)
})

it("returns error result when child run throws an exception", async () => {
const executor = new DelegationExecutor()
const setting = createMockSetting()
const delegations = [createMockDelegation({ toolCallId: "tc-1" })]
const context = createMockContext()
const parentExpert = { key: "parent", name: "Parent", version: "1.0" }

const runFn = vi.fn().mockRejectedValue(new Error("MCP connection failed"))

const result = await executor.execute(delegations, setting, context, parentExpert, runFn)

expect(result.nextSetting.input.interactiveToolCallResult?.text).toContain(
"failed: MCP connection failed",
)
})

it("throws error if completed delegation result has no expertMessage", async () => {
const executor = new DelegationExecutor()
const setting = createMockSetting()
const delegations = [
Expand All @@ -317,6 +353,7 @@ describe("@perstack/runtime: delegation-executor", () => {

const runFn = vi.fn().mockResolvedValue({
...createMockCheckpoint(),
status: "completed",
messages: [{ id: "msg-1", type: "userMessage", contents: [] }],
})

Expand All @@ -338,6 +375,7 @@ describe("@perstack/runtime: delegation-executor", () => {

const runFn = vi.fn().mockResolvedValue({
...createMockCheckpoint(),
status: "completed",
messages: [
{ id: "msg-1", type: "expertMessage", contents: [{ type: "imagePart", id: "img-1" }] },
],
Expand Down
33 changes: 29 additions & 4 deletions packages/runtime/src/orchestration/delegation-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,35 @@ export class DelegationExecutor {

// Merge parent options with returnOnDelegationComplete to ensure child runs
// inherit callbacks for checkpoint persistence and event emission
const resultCheckpoint = await runFn(
{ setting: delegateSetting, checkpoint: delegateCheckpoint },
{ ...parentOptions, returnOnDelegationComplete: true },
)
let resultCheckpoint: Checkpoint
try {
resultCheckpoint = await runFn(
{ setting: delegateSetting, checkpoint: delegateCheckpoint },
{ ...parentOptions, returnOnDelegationComplete: true },
)
} catch (error) {
// Child run crashed (e.g., MCP connection failure) - return error to parent
return {
toolCallId,
toolName,
expertKey: expert.key,
text: `Delegation to ${expert.key} failed: ${error instanceof Error ? error.message : String(error)}`,
stepNumber: parentContext.stepNumber,
deltaUsage: createEmptyUsage(),
}
}

// Handle non-completed delegation (stoppedByError, stoppedByExceededMaxSteps, etc.)
if (resultCheckpoint.status !== "completed") {
return {
toolCallId,
toolName,
expertKey: expert.key,
text: `Delegation to ${expert.key} ended with status: ${resultCheckpoint.status}`,
stepNumber: resultCheckpoint.stepNumber,
deltaUsage: resultCheckpoint.usage,
}
}

return this.extractDelegationResult(resultCheckpoint, toolCallId, toolName, expert.key)
}
Expand Down
14 changes: 13 additions & 1 deletion packages/skill-manager/src/skill-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,13 +255,25 @@ export class SkillManager {
},
removeDelegate: (name) => sm.removeDelegate(name),
createExpert: async (input) => {
// Ensure @perstack/base is always included in skills
const skills = input.skills
? {
"@perstack/base": input.skills["@perstack/base"] ?? {
type: "mcpStdioSkill" as const,
command: "npx",
packageName: "@perstack/base",
pick: ["attemptCompletion"],
},
...input.skills,
}
: undefined
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,
skills,
delegates: input.delegates,
tags: input.tags,
providerTools: input.providerTools,
Expand Down