History

fix(orchestrator): Stop treating a wait timeout as a dead child (#7427)

Mike Olson committed 2 months ago

Commit 1eb7dfa1 parent 7d703c23 files · +45 −4Browse files

Unified · Split

3 files
  1. apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +30 −0
  2. apps/server/src/mcp/toolkits/orchestrator/tools.ts +1 −1
  3. packages/contracts/src/orchestratorMcp.ts +14 −3
apps/server/src/mcp/toolkits/orchestrator/tools.test.ts+30−0
@@ -9,6 +9,36 @@ describe("orchestrator MCP tool guidance", () => { · 8 unmodified lines
99 assert.include(DelegateTaskTool.description ?? "", "cross-provider");
1010 assert.include(CreateThreadsTool.description ?? "", "not delegation");
1111 assert.include(CreateThreadsTool.description ?? "", "call delegate_task");
12+ assert.include(DelegateTaskTool.description ?? "", "waitTimedOut");
13+ assert.include(DelegateTaskTool.description ?? "", "does not cancel the child");
14+ assert.include(DelegateTaskTool.description ?? "", "keep that taskId");
15+ });
16+
17+ it("documents wait timeout as a parent budget, not a child failure", () => {
18+ const schema = Tool.getJsonSchema(DelegateTaskTool) as {
19+ readonly properties?: Readonly<
20+ Record<
21+ string,
22+ {
23+ readonly description?: unknown;
24+ readonly anyOf?: ReadonlyArray<{ readonly description?: unknown }>;
25+ }
26+ >
27+ >;
28+ };
29+ const mode = schema.properties?.mode;
30+ const timeoutMs = schema.properties?.timeoutMs;
31+ const modeText = [mode?.description, ...(mode?.anyOf ?? []).map((entry) => entry.description)]
32+ .filter((value) => typeof value === "string")
33+ .join(" ");
34+ const timeoutText = [
35+ timeoutMs?.description,
36+ ...(timeoutMs?.anyOf ?? []).map((entry) => entry.description),
37+ ]
38+ .filter((value) => typeof value === "string")
39+ .join(" ");
40+ assert.include(modeText, "Defaults to async");
41+ assert.include(timeoutText, "does not cancel the child");
1242 });
1343
1444 it("publishes an actionable schedule schema and compatibility string branch", () => {
apps/server/src/mcp/toolkits/orchestrator/tools.ts+1−1
@@ -49,7 +49,7 @@ export const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities · 48 unmodified lines
4949
5050 export const DelegateTaskTool = Tool.make("delegate_task", {
5151 description:
52− "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Use this whenever the user asks for an agent, subagent, worker, delegated task, or parallel help—including cross-provider work. The childThreadId is backing storage, not an ordinary top-level thread. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. An async child's completion wakes this thread with a continuation message naming the task (queued behind any turn in progress), so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.",
52+ "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Use this whenever the user asks for an agent, subagent, worker, delegated task, or parallel help, including cross-provider work. The childThreadId is backing storage, not an ordinary top-level thread. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread with a continuation message naming the task (queued behind any turn in progress), so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.",
5353 parameters: OrchestratorMcpDelegateTaskInput,
5454 success: OrchestratorMcpDelegateTaskResult,
5555 failure: OrchestratorMcpFailure,
packages/contracts/src/orchestratorMcp.ts+14−3
@@ -162,8 +162,16 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ · 161 unmodified lines
162162 target: Schema.optional(OrchestratorMcpTarget),
163163 title: Schema.optional(OrchestratorMcpTitle),
164164 role: Schema.optional(OrchestratorMcpTaskRole),
165− mode: Schema.optional(Schema.Literals(["async", "wait"])),
166− timeoutMs: Schema.optional(Schema.Number),
165+ mode: Schema.optional(
166+ Schema.Literals(["async", "wait"]).annotate({
167+ description:
168+ "Defaults to async. Use wait only when this turn needs the child's result before you can continue.",
169+ }),
170+ ),
171+ timeoutMs: Schema.optional(Schema.Number).annotate({
172+ description:
173+ "Wait budget for mode=wait only. Default 10 minutes. Elapsing it returns waitTimedOut=true on that call and does not cancel the child.",
174+ }),
167175 clientRequestId: Schema.optional(OrchestratorMcpClientRequestId),
168176 runtimeMode: Schema.optional(OrchestratorMcpRuntimeMode),
169177 interactionMode: Schema.optional(OrchestratorMcpInteractionMode),
@@ -180,7 +188,10 @@ export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ · 10 unmodified lines
180188 model: Schema.NullOr(Schema.String),
181189 summary: Schema.NullOr(Schema.String),
182190 resultContextTransferId: Schema.NullOr(ContextTransferId),
183− waitTimedOut: Schema.Boolean,
191+ waitTimedOut: Schema.Boolean.annotate({
192+ description:
193+ "True only on that mode=wait call when timeoutMs elapsed. The timeout does not cancel the child. Later task_status reads return false and use status for liveness.",
194+ }),
184195 });
185196 export type OrchestratorMcpDelegateTaskResult = typeof OrchestratorMcpDelegateTaskResult.Type;
186197