From 52d4d3f72cba5d8b2ae4e6e517418d666f871c5c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 02:20:55 -0700 Subject: [PATCH] fix(server): keep delegated review rounds on the task API --- apps/server/src/mcp/OrchestratorMcpService.ts | 2 ++ .../OrchestratorMcpToolkit.integration.test.ts | 8 ++++++++ .../src/mcp/toolkits/orchestrator/tools.test.ts | 11 +++++++++++ .../src/mcp/toolkits/orchestrator/tools.ts | 8 ++++---- .../provider/T3OrchestrationInstructions.test.ts | 5 +++++ .../src/provider/T3OrchestrationInstructions.ts | 3 ++- docs/orchestration-v2/orchestrator-mcp-server.md | 16 ++++++++++++++-- 7 files changed, 46 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 5fdc7eb81c80..9c7323ce546a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1510,6 +1510,8 @@ const make = Effect.gen(function* () { ), ), ); + // Published task results stay terminal. Later child-thread messages do not + // reopen the task, so cancelling it must not interrupt those separate runs. if (isTerminalTaskStatus(current.status)) { yield* disposeCompletionDelivery; return { diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 7d20bf512b10..142e0cc55f12 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1651,6 +1651,14 @@ describe("orchestrator MCP toolkit", () => { taskId: delegated.taskId, status: "completed", }); + expect( + (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === delegated.taskId, + ), + ).toMatchObject({ + result: delegatedResult, + completionDelivery: { state: "disposed" }, + }); expect( (yield* orchestrator.getThreadProjection(delegated.childThreadId)).runs.find( (run) => run.id === activeChildFollowup.runId, diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts index de85b6f72e0c..572cdcbf43e9 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -4,6 +4,7 @@ import { Tool } from "effect/unstable/ai"; import { CreateThreadsTool, DelegateTaskTool, + OrchestratorToolkit, ScheduleTaskTool, ThreadUpdateTool, } from "./tools.ts"; @@ -17,6 +18,16 @@ describe("orchestrator MCP tool guidance", () => { assert.include(DelegateTaskTool.description ?? "", "waitTimedOut"); assert.include(DelegateTaskTool.description ?? "", "does not cancel the child"); assert.include(DelegateTaskTool.description ?? "", "keep that taskId"); + assert.include(DelegateTaskTool.description ?? "", "call delegate_task again"); + assert.include(DelegateTaskTool.description ?? "", "childThreadId is backing storage"); + assert.include( + OrchestratorToolkit.tools.t3_thread_send.description ?? "", + "Do not use a delegated task's childThreadId to start another review round", + ); + assert.include( + OrchestratorToolkit.tools.task_cancel.description ?? "", + "without interrupting later child-thread runs", + ); }); it("documents wait timeout as a parent budget, not a child failure", () => { diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 833e88bd2ae8..a86f1ce214c5 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -57,7 +57,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { export const DelegateTaskTool = Tool.make("delegate_task", { description: - "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. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. 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 through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "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. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. 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 through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -70,7 +70,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", { const TaskStatusTool = Tool.make("task_status", { description: - "Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns; latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", + "Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task or extend task_cancel to those turns. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", parameters: OrchestratorMcpTaskStatusInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -84,7 +84,7 @@ const TaskStatusTool = Tool.make("task_status", { const TaskCancelTool = Tool.make("task_cancel", { description: - "Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. Completed task results remain available.", + "Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. For a terminal task, return its existing status and dispose delivery without interrupting later child-thread runs, even when task_status reports hasPendingChildRuns=true. Published task results remain available. Use t3_thread_interrupt for a later active run.", parameters: OrchestratorMcpTaskCancelInput, success: OrchestratorMcpTaskCancelResult, failure: OrchestratorMcpFailure, @@ -200,7 +200,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send a message to a T3 thread in the calling project. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", + "Send a message to a T3 thread in the calling project. Do not use a delegated task's childThreadId to start another review round here; use delegate_task with the full review context and a new clientRequestId for that round. Thread messages do not create a new delegated task or reopen a completed task. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/T3OrchestrationInstructions.test.ts b/apps/server/src/provider/T3OrchestrationInstructions.test.ts index dbcd209a136f..e625e40c1125 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.test.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.test.ts @@ -13,6 +13,11 @@ describe("T3 orchestration provider instructions", () => { assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "ordinary top-level T3 conversations"); assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "Never use them merely"); assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "cross-provider"); + assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "call `delegate_task` again"); + assert.include( + T3_CODE_ORCHESTRATION_INSTRUCTIONS, + "Do not use `t3_thread_send` on `childThreadId`", + ); }); it("documents structured schedules instead of JSON strings", () => { diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index ed510b879c0f..a70ef35bffcd 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -6,8 +6,9 @@ export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = ` The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly: -- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent; do not replace delegation with ordinary thread creation. +- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round. - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. +- For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule. By default runs return to the current thread; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling, report the returned cadence and next run time. ### Choose the workspace before starting a new thread diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index ae68b1fc8539..eab33d76f5b0 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -228,6 +228,14 @@ that driver; an explicit `providerInstanceId` is honored exactly and fails when unavailable. Selecting a different provider without a model uses that provider's first advertised model. +Each delegated review round uses a new `delegate_task` call with the original brief, +prior findings, responses, and unresolved objections. Track each round by its own `taskId` and use +a distinct `clientRequestId` per round, stable across retries of that round. +`childThreadId` is backing storage, not a target for another review round through +`t3_thread_send`. Ordinary thread messaging remains available for user-requested +conversations; it does not reopen a completed task. There is no task-level follow-up +API for preserving the same reviewer session. + Delegation requires an active parent run owned by the MCP credential's provider session. The request becomes the V2 command `delegated_task.request`. @@ -272,8 +280,12 @@ the published task result. ### `task_cancel` Interrupts the currently active task run through the normal V2 `run.interrupt` -command. Native background work between turns currently has no interruptible run. It is idempotent for terminal tasks and accepts an optional cancellation -reason. Use `t3_thread_interrupt` to interrupt a later follow-up run. +command and disposes automatic parent delivery. Native background work between +turns currently has no interruptible run. For a terminal task, it returns the +existing status and disposes delivery without interrupting later child-thread runs, +even when `task_status` reports `hasPendingChildRuns: true`. Published task results +remain available. It accepts an optional cancellation reason. Use +`t3_thread_interrupt` to stop a later active run. ### `create_threads`