diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 4c7509bf051a7..11e3d38d8ae69 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -13,7 +13,6 @@ import { OrchestratorMcpFailure, PreviewTabId, ProviderInstanceId, - ProviderInteractionMode, RuntimeMode, ThreadId, } from "@t3tools/contracts"; @@ -1413,10 +1412,7 @@ it.effect( const StartsToolkit = Toolkit.make( Tool.make("starts", { description: "Reports the modes a started thread would run with.", - parameters: Schema.Struct({ - runtimeMode: Schema.optional(RuntimeMode), - interactionMode: Schema.optional(ProviderInteractionMode), - }), + parameters: Schema.Struct({ runtimeMode: Schema.optional(RuntimeMode) }), success: Schema.Struct({ modes: Schema.String }), failure: OrchestratorMcpFailure, failureMode: "return", @@ -1524,9 +1520,6 @@ it.effect( expect(yield* start("approval-required/plan", { runtimeMode: "auto" })).toBe( "runtime_mode_escalation_denied", ); - expect(yield* start("auto-accept-edits/plan", { interactionMode: "default" })).toBe( - "interaction_mode_escalation_denied", - ); // A broader header changes nothing: the approved ceiling still holds. expect(yield* start("full-access/default")).toBe("auto/default"); expect(yield* start("full-access/default", { runtimeMode: "full-access" })).toBe( diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 6d63287f4eb2a..f8872e8322023 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -4,7 +4,6 @@ import { OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, ProviderInstanceId, - ProviderInteractionMode, RuntimeMode, ThreadId, } from "@t3tools/contracts"; @@ -123,10 +122,7 @@ const ProbeToolkit = Toolkit.make( }), Tool.make("starts_threads", { ...probe, - parameters: Schema.Struct({ - runtimeMode: Schema.optional(RuntimeMode), - interactionMode: Schema.optional(ProviderInteractionMode), - }), + parameters: Schema.Struct({ runtimeMode: Schema.optional(RuntimeMode) }), }), Tool.make("writes_environment", probe), ); @@ -303,8 +299,7 @@ it.effect.each([ ["starts_threads", supervised, {}, "approval-required/default"], ["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"], ["starts_threads", plan, {}, "approval-required/plan"], - ["starts_threads", plan, { interactionMode: "default" }, "interaction_mode_escalation_denied"], - ["starts_threads", fullAccess, { runtimeMode: "auto", interactionMode: "plan" }, "auto/plan"], + ["starts_threads", fullAccess, { runtimeMode: "auto" }, "auto/default"], ["starts_threads", ended, {}, "parent_not_active"], ["starts_threads", supervisedClient, {}, "approval-required/default"], ["starts_threads", supervisedClient, { runtimeMode: "auto" }, "runtime_mode_escalation_denied"], @@ -366,7 +361,6 @@ const linkedProbe = { environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), runtimeMode: Schema.optional(RuntimeMode), - interactionMode: Schema.optional(ProviderInteractionMode), }), }; const LinkedProbeToolkit = Toolkit.make( @@ -417,12 +411,6 @@ it.effect.each([ ["writes", ended, { environmentId: box }, "parent_not_active"], ["writes", supervised, { environmentId: box }, "writes in environment-box"], ["writes_with_capability", readOnlyClient, { environmentId: box }, "capability_denied"], - [ - "starts_threads", - plan, - { environmentId: box, interactionMode: "default" }, - "interaction_mode_escalation_denied", - ], ["starts_threads", plan, { environmentId: box }, "starts_threads in environment-box"], ["writes_environment", supervised, { environmentId: box }, "capability_denied"], [ diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index b82d9bb5ae59c..af24e5f70655a 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -23,7 +23,7 @@ import { import * as PeerForwarding from "../peer/PeerForwarding.ts"; import { assertNotLinked, assertSameLink, callerLinkOrigin } from "./linkOrigin.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; -import { resolveInteractionMode, resolveRuntimeMode } from "./delegatedTaskModes.ts"; +import { resolveRuntimeMode } from "./delegatedTaskModes.ts"; import { assertFullAccess, assertLiveCaller, @@ -224,15 +224,13 @@ export interface StartedModes { } /** - * Starts threads with the modes `modes` requests, which may not be broader - * than the caller's. `linked` says what a linked caller gets: threads stamped - * with its `linkOrigin`, or a refusal where the tool cannot carry one. + * Starts threads in the caller's interaction mode and the runtime mode + * `modes` requests, which may not be broader than the caller's. `linked` says + * what a linked caller gets: threads stamped with its `linkOrigin`, or a + * refusal where the tool cannot carry one. */ export const startsThreads = ( - modes: (params: P) => { - readonly runtimeMode?: RuntimeMode | undefined; - readonly interactionMode?: ProviderInteractionMode | undefined; - }, + modes: (params: P) => { readonly runtimeMode?: RuntimeMode | undefined }, handle: (params: P, modes: StartedModes) => Effect.Effect, linked: "stamped" | { readonly refused: string }, ) => { @@ -240,10 +238,7 @@ export const startsThreads = ( const requested = modes(params); return { runtimeMode: yield* resolveRuntimeMode(caller.limits.runtimeMode, requested.runtimeMode), - interactionMode: yield* resolveInteractionMode( - caller.limits.interactionMode, - requested.interactionMode, - ), + interactionMode: caller.limits.interactionMode, linkOrigin: callerLinkOrigin(caller), } satisfies StartedModes; }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 5211a9f7c516c..2677f831883f7 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2420,10 +2420,6 @@ const make = Effect.gen(function* () { input.runtimeMode, target, ); - const interactionMode = yield* resolveInteractionMode( - parent.thread.interactionMode, - input.interactionMode, - ); let workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy | undefined; if (input.isolation === "worktree" && !isRetryOfAcceptedDelegation) { const project = yield* requireProject(parent.thread.projectId); @@ -2470,7 +2466,7 @@ const make = Effect.gen(function* () { ...(input.title === undefined ? {} : { title: input.title }), modelSelection: target.modelSelection, runtimeMode, - interactionMode, + interactionMode: parent.thread.interactionMode, ...(workspaceStrategy === undefined ? {} : { workspaceStrategy }), // Async delegations wake the parent on every child terminal; wait // delegations deliver through the blocking tool call, so a wake is @@ -2833,10 +2829,6 @@ const make = Effect.gen(function* () { request.runtimeMode, target, ); - const interactionMode = yield* resolveInteractionMode( - parent.thread.interactionMode, - request.interactionMode, - ); yield* assertTargetEnabledForProject(parent, commandId, target); const title = threadTitle({ parentTitle: parent.thread.title, @@ -2855,7 +2847,7 @@ const make = Effect.gen(function* () { title, modelSelection: target.modelSelection, runtimeMode, - interactionMode, + interactionMode: parent.thread.interactionMode, branch: parent.thread.branch, worktreePath: parent.thread.worktreePath, ...(parent.thread.linkOrigin === undefined diff --git a/apps/server/src/mcp/delegatedTaskModes.ts b/apps/server/src/mcp/delegatedTaskModes.ts index 54d25bbb36e02..9ce084def946a 100644 --- a/apps/server/src/mcp/delegatedTaskModes.ts +++ b/apps/server/src/mcp/delegatedTaskModes.ts @@ -2,7 +2,6 @@ import { type ModelSelection, OrchestratorMcpFailure, type OrchestratorMcpDelegateTaskInput, - type OrchestratorMcpInteractionMode, type OrchestratorMcpRuntimeMode, type ProviderInteractionMode, type RuntimeMode, @@ -71,12 +70,11 @@ export function resolveRuntimeMode( ); } -/** The same, for interaction modes. */ +/** Refuses a target interaction mode broader than its parent's. */ export function resolveInteractionMode( parentMode: ProviderInteractionMode, - requested: OrchestratorMcpInteractionMode | undefined, + resolved: ProviderInteractionMode, ): Effect.Effect { - const resolved = requested === undefined || requested === "inherit" ? parentMode : requested; return interactionModeRank(resolved) > interactionModeRank(parentMode) ? Effect.fail( new OrchestratorMcpFailure({ diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 56799959f6cd8..54b3c2f4f54a2 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -9,13 +9,13 @@ import { OrchestrationV2RunStatus, OrchestrationV2ThreadLaunchWorkspaceStrategy, RuntimeMode, - ProviderInteractionMode, Project, ProjectCreatePayload, ProjectUpdatePayload, ProjectId, OrchestratorMcpClientRequestId, OrchestratorMcpEnvironmentTarget, + OrchestratorMcpInteractionMode, OrchestratorMcpFailure, SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -124,7 +124,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - "Create one new top-level thread, bound to its workspace before the agent starts. Task in `message`. Omit `projectId`, model and modes to inherit yours (outside a thread, pass `projectId` or `scratch`); modes cannot exceed yours. After an error or lost reply, retry with the same `clientRequestId`. Follow it with `t3_thread_read` or `t3_thread_wait`. Mention it with its `link`.", + "Create one new top-level thread, bound to its workspace before the agent starts. Task in `message`. Omit `projectId`, model and `runtimeMode` to inherit yours (outside a thread, pass `projectId` or `scratch`); `runtimeMode` cannot exceed yours. After an error or lost reply, retry with the same `clientRequestId`. Follow it with `t3_thread_read` or `t3_thread_wait`. Mention it with its `link`.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: Schema.optional(ProjectId), @@ -137,7 +137,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { title: TrimmedNonEmptyString, modelSelection: Schema.optional(ModelSelection), runtimeMode: Schema.optional(RuntimeMode), - interactionMode: Schema.optional(ProviderInteractionMode), + interactionMode: Schema.optional(OrchestratorMcpInteractionMode), workspaceStrategy: Schema.optional( OrchestrationV2ThreadLaunchWorkspaceStrategy.annotate({ description: diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 4e15b02d04e53..6ee557807f663 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -517,9 +517,11 @@ it.effect("launches in a linked environment with the caller's modes, retry-safe" expect(relaunched?.threadId).toBe(launched?.threadId); expect(retried.structuredContent).toEqual(first.structuredContent); - // An escalation past the caller's own modes is refused here. - const escalated = yield* launch("thread:laptop-plan", { interactionMode: "default" }); - expect(failureOf(escalated)).toMatchObject({ code: "interaction_mode_escalation_denied" }); + // An explicit mode from an older caller is refused, not dropped. + const chosen = yield* Effect.flip( + launch("thread:laptop-full", { clientRequestId: "launch-3", interactionMode: "plan" }), + ); + expect(String(chosen)).toContain('["interactionMode"]'); // A pending upload lives here, so it cannot go along. yield* Ref.set(b.bearers, []); const withUpload = yield* launch("thread:laptop-full", { diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index 1222f029eec51..710ea139ab860 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -29,11 +29,7 @@ import { withExecutedModelNote } from "@t3tools/provider-core/server/subagentPro import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts"; -import { - resolveInteractionMode, - resolveRuntimeMode, - taskPrompt, -} from "../mcp/delegatedTaskModes.ts"; +import { resolveRuntimeMode, taskPrompt } from "../mcp/delegatedTaskModes.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -238,17 +234,13 @@ const make = Effect.gen(function* () { (instanceId === inherited.instanceId && model === inherited.model ? inherited.options : undefined); - // Modes asked for are checked against this thread. Omitted ones are left - // to the other side, which inherits this thread's modes capped by the - // link's access, so a narrower link still takes the task. + // A runtime mode asked for is checked against this thread. Otherwise the + // other side inherits this thread's modes capped by the link's access, + // so a narrower link still takes the task. const runtimeMode = input.runtimeMode === undefined || input.runtimeMode === "inherit" ? undefined : yield* resolveRuntimeMode(parent.thread.runtimeMode, input.runtimeMode); - const interactionMode = - input.interactionMode === undefined || input.interactionMode === "inherit" - ? undefined - : yield* resolveInteractionMode(parent.thread.interactionMode, input.interactionMode); const projectId = yield* resolveRemoteProject( scope, target.environmentId, @@ -270,7 +262,6 @@ const make = Effect.gen(function* () { title, modelSelection: { instanceId, model, ...(options === undefined ? {} : { options }) }, ...(runtimeMode === undefined ? {} : { runtimeMode }), - ...(interactionMode === undefined ? {} : { interactionMode }), message: task, clientRequestId: `delegate:${scope.thread.threadId}:${key}`, // The thread there shows it is this thread's subagent, with a way back. diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 4439c50940722..de12e5f31d7de 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5597, "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48217 + "mcpToolListJsonBytes": 48192 } diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 5aa52e5eab133..f5dddee4bb00e 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -244,13 +244,13 @@ type DelegateTaskInput = { timeoutMs?: number; clientRequestId?: string; runtimeMode?: "inherit" | "approval-required" | "auto-accept-edits" | "full-access"; - interactionMode?: "inherit" | "plan" | "default"; + interactionMode?: "inherit"; }; ``` -Provider, model, runtime mode, and interaction mode inherit from the parent -when omitted. A driver-only target inherits the parent's provider instance -when it can run child tasks, and otherwise selects an available instance of +Provider, model, and runtime mode inherit from the parent when omitted. A +driver-only target inherits the parent's provider instance when it can run +child tasks, and otherwise selects an available instance of 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. @@ -350,7 +350,7 @@ type CreateThreadsInput = { model?: string; }; runtimeMode?: "inherit" | "approval-required" | "auto-accept-edits" | "full-access"; - interactionMode?: "inherit" | "plan" | "default"; + interactionMode?: "inherit"; }>; clientRequestId?: string; }; @@ -370,11 +370,11 @@ project root (`root`, also the default). The thread is bound to that workspace before the agent starts. Creating a worktree in the task prompt does not update this binding. -Pass the task in `message`. Project, model, and modes inherit when omitted; +Pass the task in `message`. Project, model, and runtime mode inherit when omitted; workspace does not. `scratch: true` launches without a project, in a folder of its own under the environment's Scratch project. For stacked PRs, use the parent branch as `baseRef` with -`startFromOrigin: false`. The new thread may not run with broader runtime or -interaction modes than the caller. As for `delegate_task`, a runtime mode the +`startFromOrigin: false`. The new thread may not run with a broader runtime +mode than the caller. As for `delegate_task`, a runtime mode the provider does not offer is refused, and an inherited or default one steps down to the broadest mode it offers. Launch has no retry key, so inspect existing threads after a failed or lost response before launching again. `create_threads` remains the batch option for a shared checkout. @@ -541,8 +541,10 @@ results use the latest assistant content from the final work turn. - A child runtime mode may stay equal to or become narrower than the parent mode. It may not escalate privileges. -- A child interaction mode may stay equal to or narrow from `default` to - `plan`. It may not escalate from `plan` to `default`. +- A child always runs in its caller's interaction mode. Agents cannot choose + one: the server does not know whether the user's client shows plan mode. + `interactionMode` accepts only `"inherit"`, so an explicit mode from an older + caller is refused rather than silently dropped. - Thread tools take any thread in the environment as a target. For a thread caller, list and search cover one project: its own unless `projectId` is given. - A tool that changes another thread needs the calling thread's live run, and diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index 951727e68e1d3..4f0cba30d67bf 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; import { + OrchestratorMcpCreateThreadRequest, OrchestratorMcpDelegateTaskInput, OrchestratorMcpDelegateTaskResult, } from "./orchestratorMcp.ts"; const decodeDelegateTaskInput = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskInput); const decodeDelegateTaskResult = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskResult); +const decodeCreateThreadRequest = Schema.decodeUnknownSync(OrchestratorMcpCreateThreadRequest); describe("orchestrator MCP contracts", () => { it("decodes cross-provider delegated task requests and durable results", () => { @@ -23,6 +25,11 @@ describe("orchestrator MCP contracts", () => { runtimeMode: "inherit", interactionMode: "inherit", }); + // Children run in their caller's interaction mode; an explicit one is refused, not dropped. + expect(() => decodeDelegateTaskInput({ task: "Plan it.", interactionMode: "plan" })).toThrow(); + expect(() => + decodeCreateThreadRequest({ prompt: "Plan it.", interactionMode: "plan" }), + ).toThrow(); const result = decodeDelegateTaskResult({ taskId: "node-task-1", childThreadId: "thread-child-1", diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index d1bb9961a0c68..6e1de8861fa79 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -130,11 +130,9 @@ export type OrchestratorMcpTarget = typeof OrchestratorMcpTarget.Type; export const OrchestratorMcpRuntimeMode = Schema.Union([Schema.Literal("inherit"), RuntimeMode]); export type OrchestratorMcpRuntimeMode = typeof OrchestratorMcpRuntimeMode.Type; -export const OrchestratorMcpInteractionMode = Schema.Union([ - Schema.Literal("inherit"), - ProviderInteractionMode, -]); -export type OrchestratorMcpInteractionMode = typeof OrchestratorMcpInteractionMode.Type; +// Children always run in their caller's interaction mode. The field stays so an +// explicit mode from an older caller fails decoding instead of being dropped. +export const OrchestratorMcpInteractionMode = Schema.Literal("inherit"); export const OrchestratorMcpTaskRole = Schema.Literals([ "implementation",