,
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",