authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-10 20:04:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 20:04:40-07:00
loga4a1a803a3a6d10d16e45f505a2b95e99430b1cb
treea41fceee54155b11a3eb33cf0a2138294431087e
parentd404a301189beb5d9ded5bd66bd1fa35db655ef6
signature Signed by PGP key B5690EEEBB952194

fix(server): delegated Muse tasks no longer ask approval for every command (#18065)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

5 files changed, 91 insertions(+), 11 deletions(-)

apps/server/src/mcp/OrchestratorMcpService.test.ts+33
......@@ -1817,4 +1817,37 @@ describe("OrchestratorMcpService provider resolution", () => {
18171817 }),
18181818 );
18191819 });
1820
1821 describe("resolveRuntimeMode", () => {
1822 // Muse offers only these two, and runs any other mode as Supervised.
1823 const muse = {
1824 modelSelection: { instanceId: ProviderInstanceId.make("muse"), model: "muse-model" },
1825 supportedRuntimeModes: ["approval-required", "full-access"] as const,
1826 };
1827
1828 it.effect("refuses an explicit mode the target provider does not offer", () =>
1829 Effect.gen(function* () {
1830 const error = yield* OrchestratorMcpService.resolveRuntimeMode(
1831 "full-access",
1832 "auto-accept-edits",
1833 muse,
1834 ).pipe(Effect.flip);
1835 assert.equal(error.code, "invalid_request");
1836 assert.include(error.message, "approval-required, full-access");
1837 }),
1838 );
1839
1840 it.effect("steps an inherited mode down to the broadest one the provider offers", () =>
1841 Effect.gen(function* () {
1842 assert.equal(
1843 yield* OrchestratorMcpService.resolveRuntimeMode("full-access", "inherit", muse),
1844 "full-access",
1845 );
1846 assert.equal(
1847 yield* OrchestratorMcpService.resolveRuntimeMode("auto", undefined, muse),
1848 "approval-required",
1849 );
1850 }),
1851 );
1852 });
18201853});
apps/server/src/mcp/OrchestratorMcpService.ts+48-10
......@@ -113,6 +113,7 @@ const DEFAULT_THREAD_ITEM_MAX_CHARS = 20_000;
113113
114114interface ResolvedTarget {
115115 readonly modelSelection: ModelSelection;
116 readonly supportedRuntimeModes: ReadonlyArray<RuntimeMode> | undefined;
116117}
117118
118119type TerminalTaskStatus = Extract<
......@@ -531,19 +532,46 @@ function interactionModeRank(mode: ProviderInteractionMode): number {
531532 return mode === "plan" ? 0 : 1;
532533}
533534
535/**
536 * The mode a new thread runs in: the requested mode, else the parent's, never
537 * broader than the parent's. Pass `target` when the thread runs on a known
538 * provider. A provider runs a mode it does not offer as Supervised, so an
539 * explicit request for one is refused, and an inherited one steps down to the
540 * broadest mode the provider offers.
541 */
534542export function resolveRuntimeMode(
535543 parentMode: RuntimeMode,
536544 requested: OrchestratorMcpRuntimeMode | undefined,
545 target?: Pick<ResolvedTarget, "modelSelection" | "supportedRuntimeModes">,
537546): Effect.Effect<RuntimeMode, OrchestratorMcpFailure> {
538 const resolved = requested === undefined || requested === "inherit" ? parentMode : requested;
539 return runtimeModeRank(resolved) > runtimeModeRank(parentMode)
540 ? Effect.fail(
541 failure(
542 "runtime_mode_escalation_denied",
543 `Child runtime mode ${resolved} is broader than parent mode ${parentMode}.`,
544 ),
545 )
546 : Effect.succeed(resolved);
547 const inherited = requested === undefined || requested === "inherit";
548 const resolved = inherited ? parentMode : requested;
549 if (runtimeModeRank(resolved) > runtimeModeRank(parentMode)) {
550 return Effect.fail(
551 failure(
552 "runtime_mode_escalation_denied",
553 `Child runtime mode ${resolved} is broader than parent mode ${parentMode}.`,
554 ),
555 );
556 }
557 const supported = target?.supportedRuntimeModes;
558 if (supported === undefined || supported.length === 0 || supported.includes(resolved)) {
559 return Effect.succeed(resolved);
560 }
561 if (!inherited) {
562 return Effect.fail(
563 failure(
564 "invalid_request",
565 `Provider ${target?.modelSelection.instanceId} does not offer runtime mode ${resolved}, so it would run as approval-required. Use one of: ${supported.join(", ")}.`,
566 ),
567 );
568 }
569 return Effect.succeed(
570 supported
571 .filter((mode) => runtimeModeRank(mode) <= runtimeModeRank(resolved))
572 .toSorted((left, right) => runtimeModeRank(right) - runtimeModeRank(left))[0] ??
573 "approval-required",
574 );
547575}
548576
549577export function resolveInteractionMode(
......@@ -1238,6 +1266,7 @@ const make = Effect.gen(function* () {
12381266 : requestedOptions === undefined
12391267 ? { instanceId, model }
12401268 : { instanceId, model, options: requestedOptions },
1269 supportedRuntimeModes: provider.supportedRuntimeModes,
12411270 };
12421271 });
12431272
......@@ -1844,6 +1873,10 @@ const make = Effect.gen(function* () {
18441873 ? {}
18451874 : { options: model.capabilities.optionDescriptors }),
18461875 })) ?? [],
1876 ...(provider.supportedRuntimeModes === undefined ||
1877 provider.supportedRuntimeModes.length === 0
1878 ? {}
1879 : { runtimeModes: [...provider.supportedRuntimeModes] }),
18471880 canRunChildTask: constraints.length === 0,
18481881 canRunCrossProviderChildTask: constraints.length === 0,
18491882 constraints: [...constraints],
......@@ -1883,7 +1916,11 @@ const make = Effect.gen(function* () {
18831916 target: input.target,
18841917 providers,
18851918 });
1886 const runtimeMode = yield* resolveRuntimeMode(parent.thread.runtimeMode, input.runtimeMode);
1919 const runtimeMode = yield* resolveRuntimeMode(
1920 parent.thread.runtimeMode,
1921 input.runtimeMode,
1922 target,
1923 );
18871924 const interactionMode = yield* resolveInteractionMode(
18881925 parent.thread.interactionMode,
18891926 input.interactionMode,
......@@ -2168,6 +2205,7 @@ const make = Effect.gen(function* () {
21682205 const runtimeMode = yield* resolveRuntimeMode(
21692206 parent.thread.runtimeMode,
21702207 request.runtimeMode,
2208 target,
21712209 );
21722210 const interactionMode = yield* resolveInteractionMode(
21732211 parent.thread.interactionMode,
apps/server/src/mcp/toolkits/orchestrator/tools.ts+1-1
......@@ -63,7 +63,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {
6363
6464export const DelegateTaskTool = Tool.make("delegate_task", {
6565 description:
66 "Needs an agent running inside a T3 thread. 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.",
66 "Needs an agent running inside a T3 thread. 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. A requested runtimeMode must be one the target provider lists in orchestrator_capabilities runtimeModes; an inherited one steps down to the broadest mode it offers. 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.",
6767 parameters: OrchestratorMcpDelegateTaskInput,
6868 success: OrchestratorMcpDelegateTaskResult,
6969 failure: OrchestratorMcpFailure,
docs/orchestration-v2/orchestrator-mcp-server.md+7
......@@ -237,6 +237,13 @@ that driver; an explicit `providerInstanceId` is honored exactly and fails
237237when unavailable. Selecting a different provider without a model uses that
238238provider's first advertised model.
239239
240A provider runs a runtime mode it does not offer as `approval-required`, so a
241child would ask for approval on every command. `orchestrator_capabilities`
242lists `runtimeModes` only for a provider that offers some of the modes, and
243omits it for a provider that offers all of them. When the target lists
244`runtimeModes`, an explicit `runtimeMode` outside that list is refused, and an
245inherited mode steps down to the broadest mode the target offers.
246
240247Each delegated review round uses a new `delegate_task` call with the original brief,
241248prior findings, responses, and unresolved objections. Track each round by its own `taskId` and use
242249a distinct `clientRequestId` per round, stable across retries of that round.
packages/contracts/src/orchestratorMcp.ts+2
......@@ -487,6 +487,8 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({
487487 options: Schema.optional(Schema.Array(ProviderOptionDescriptor)),
488488 }),
489489 ),
490 /** Runtime modes the provider offers; omitted when it offers all of them. */
491 runtimeModes: Schema.optional(Schema.Array(RuntimeMode)),
490492 canRunChildTask: Schema.Boolean,
491493 canRunCrossProviderChildTask: Schema.Boolean,
492494 constraints: Schema.Array(Schema.String),