| author | |
| committer | |
| log | a4a1a803a3a6d10d16e45f505a2b95e99430b1cb |
| tree | a41fceee54155b11a3eb33cf0a2138294431087e |
| parent | d404a301189beb5d9ded5bd66bd1fa35db655ef6 |
| signature | Signed by PGP key B5690EEEBB952194 |
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", () => { | ... | @@ -1817,4 +1817,37 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1817 | }), | 1817 | }), |
| 1818 | ); | 1818 | ); |
| 1819 | }); | 1819 | }); |
| 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 | }); | ||
| 1820 | }); | 1853 | }); |
apps/server/src/mcp/OrchestratorMcpService.ts+48-10| ... | @@ -113,6 +113,7 @@ const DEFAULT_THREAD_ITEM_MAX_CHARS = 20_000; | ... | @@ -113,6 +113,7 @@ const DEFAULT_THREAD_ITEM_MAX_CHARS = 20_000; |
| 113 | 113 | ||
| 114 | interface ResolvedTarget { | 114 | interface ResolvedTarget { |
| 115 | readonly modelSelection: ModelSelection; | 115 | readonly modelSelection: ModelSelection; |
| 116 | readonly supportedRuntimeModes: ReadonlyArray<RuntimeMode> | undefined; | ||
| 116 | } | 117 | } |
| 117 | 118 | ||
| 118 | type TerminalTaskStatus = Extract< | 119 | type TerminalTaskStatus = Extract< |
| ... | @@ -531,19 +532,46 @@ function interactionModeRank(mode: ProviderInteractionMode): number { | ... | @@ -531,19 +532,46 @@ function interactionModeRank(mode: ProviderInteractionMode): number { |
| 531 | return mode === "plan" ? 0 : 1; | 532 | return mode === "plan" ? 0 : 1; |
| 532 | } | 533 | } |
| 533 | 534 | ||
| 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 | */ | ||
| 534 | export function resolveRuntimeMode( | 542 | export function resolveRuntimeMode( |
| 535 | parentMode: RuntimeMode, | 543 | parentMode: RuntimeMode, |
| 536 | requested: OrchestratorMcpRuntimeMode | undefined, | 544 | requested: OrchestratorMcpRuntimeMode | undefined, |
| 545 | target?: Pick<ResolvedTarget, "modelSelection" | "supportedRuntimeModes">, | ||
| 537 | ): Effect.Effect<RuntimeMode, OrchestratorMcpFailure> { | 546 | ): Effect.Effect<RuntimeMode, OrchestratorMcpFailure> { |
| 538 | const resolved = requested === undefined || requested === "inherit" ? parentMode : requested; | 547 | const inherited = requested === undefined || requested === "inherit"; |
| 539 | return runtimeModeRank(resolved) > runtimeModeRank(parentMode) | 548 | const resolved = inherited ? parentMode : requested; |
| 540 | ? Effect.fail( | 549 | if (runtimeModeRank(resolved) > runtimeModeRank(parentMode)) { |
| 541 | failure( | 550 | return Effect.fail( |
| 542 | "runtime_mode_escalation_denied", | 551 | failure( |
| 543 | `Child runtime mode ${resolved} is broader than parent mode ${parentMode}.`, | 552 | "runtime_mode_escalation_denied", |
| 544 | ), | 553 | `Child runtime mode ${resolved} is broader than parent mode ${parentMode}.`, |
| 545 | ) | 554 | ), |
| 546 | : Effect.succeed(resolved); | 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 | ); | ||
| 547 | } | 575 | } |
| 548 | 576 | ||
| 549 | export function resolveInteractionMode( | 577 | export function resolveInteractionMode( |
| ... | @@ -1238,6 +1266,7 @@ const make = Effect.gen(function* () { | ... | @@ -1238,6 +1266,7 @@ const make = Effect.gen(function* () { |
| 1238 | : requestedOptions === undefined | 1266 | : requestedOptions === undefined |
| 1239 | ? { instanceId, model } | 1267 | ? { instanceId, model } |
| 1240 | : { instanceId, model, options: requestedOptions }, | 1268 | : { instanceId, model, options: requestedOptions }, |
| 1269 | supportedRuntimeModes: provider.supportedRuntimeModes, | ||
| 1241 | }; | 1270 | }; |
| 1242 | }); | 1271 | }); |
| 1243 | 1272 | ||
| ... | @@ -1844,6 +1873,10 @@ const make = Effect.gen(function* () { | ... | @@ -1844,6 +1873,10 @@ const make = Effect.gen(function* () { |
| 1844 | ? {} | 1873 | ? {} |
| 1845 | : { options: model.capabilities.optionDescriptors }), | 1874 | : { options: model.capabilities.optionDescriptors }), |
| 1846 | })) ?? [], | 1875 | })) ?? [], |
| 1876 | ...(provider.supportedRuntimeModes === undefined || | ||
| 1877 | provider.supportedRuntimeModes.length === 0 | ||
| 1878 | ? {} | ||
| 1879 | : { runtimeModes: [...provider.supportedRuntimeModes] }), | ||
| 1847 | canRunChildTask: constraints.length === 0, | 1880 | canRunChildTask: constraints.length === 0, |
| 1848 | canRunCrossProviderChildTask: constraints.length === 0, | 1881 | canRunCrossProviderChildTask: constraints.length === 0, |
| 1849 | constraints: [...constraints], | 1882 | constraints: [...constraints], |
| ... | @@ -1883,7 +1916,11 @@ const make = Effect.gen(function* () { | ... | @@ -1883,7 +1916,11 @@ const make = Effect.gen(function* () { |
| 1883 | target: input.target, | 1916 | target: input.target, |
| 1884 | providers, | 1917 | providers, |
| 1885 | }); | 1918 | }); |
| 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 | ); | ||
| 1887 | const interactionMode = yield* resolveInteractionMode( | 1924 | const interactionMode = yield* resolveInteractionMode( |
| 1888 | parent.thread.interactionMode, | 1925 | parent.thread.interactionMode, |
| 1889 | input.interactionMode, | 1926 | input.interactionMode, |
| ... | @@ -2168,6 +2205,7 @@ const make = Effect.gen(function* () { | ... | @@ -2168,6 +2205,7 @@ const make = Effect.gen(function* () { |
| 2168 | const runtimeMode = yield* resolveRuntimeMode( | 2205 | const runtimeMode = yield* resolveRuntimeMode( |
| 2169 | parent.thread.runtimeMode, | 2206 | parent.thread.runtimeMode, |
| 2170 | request.runtimeMode, | 2207 | request.runtimeMode, |
| 2208 | target, | ||
| 2171 | ); | 2209 | ); |
| 2172 | const interactionMode = yield* resolveInteractionMode( | 2210 | const interactionMode = yield* resolveInteractionMode( |
| 2173 | parent.thread.interactionMode, | 2211 | parent.thread.interactionMode, |
apps/server/src/mcp/toolkits/orchestrator/tools.ts+1-1| ... | @@ -63,7 +63,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { | ... | @@ -63,7 +63,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { |
| 63 | 63 | ||
| 64 | export const DelegateTaskTool = Tool.make("delegate_task", { | 64 | export const DelegateTaskTool = Tool.make("delegate_task", { |
| 65 | description: | 65 | 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.", |
| 67 | parameters: OrchestratorMcpDelegateTaskInput, | 67 | parameters: OrchestratorMcpDelegateTaskInput, |
| 68 | success: OrchestratorMcpDelegateTaskResult, | 68 | success: OrchestratorMcpDelegateTaskResult, |
| 69 | failure: OrchestratorMcpFailure, | 69 | failure: OrchestratorMcpFailure, |
docs/orchestration-v2/orchestrator-mcp-server.md+7| ... | @@ -237,6 +237,13 @@ that driver; an explicit `providerInstanceId` is honored exactly and fails | ... | @@ -237,6 +237,13 @@ that driver; an explicit `providerInstanceId` is honored exactly and fails |
| 237 | when unavailable. Selecting a different provider without a model uses that | 237 | when unavailable. Selecting a different provider without a model uses that |
| 238 | provider's first advertised model. | 238 | provider's first advertised model. |
| 239 | 239 | ||
| 240 | A provider runs a runtime mode it does not offer as `approval-required`, so a | ||
| 241 | child would ask for approval on every command. `orchestrator_capabilities` | ||
| 242 | lists `runtimeModes` only for a provider that offers some of the modes, and | ||
| 243 | omits 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 | ||
| 245 | inherited mode steps down to the broadest mode the target offers. | ||
| 246 | |||
| 240 | Each delegated review round uses a new `delegate_task` call with the original brief, | 247 | Each delegated review round uses a new `delegate_task` call with the original brief, |
| 241 | prior findings, responses, and unresolved objections. Track each round by its own `taskId` and use | 248 | prior findings, responses, and unresolved objections. Track each round by its own `taskId` and use |
| 242 | a distinct `clientRequestId` per round, stable across retries of that round. | 249 | a 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({ | ... | @@ -487,6 +487,8 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({ |
| 487 | options: Schema.optional(Schema.Array(ProviderOptionDescriptor)), | 487 | options: Schema.optional(Schema.Array(ProviderOptionDescriptor)), |
| 488 | }), | 488 | }), |
| 489 | ), | 489 | ), |
| 490 | /** Runtime modes the provider offers; omitted when it offers all of them. */ | ||
| 491 | runtimeModes: Schema.optional(Schema.Array(RuntimeMode)), | ||
| 490 | canRunChildTask: Schema.Boolean, | 492 | canRunChildTask: Schema.Boolean, |
| 491 | canRunCrossProviderChildTask: Schema.Boolean, | 493 | canRunCrossProviderChildTask: Schema.Boolean, |
| 492 | constraints: Schema.Array(Schema.String), | 494 | constraints: Schema.Array(Schema.String), |