Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
OrchestratorMcpFailure,
PreviewTabId,
ProviderInstanceId,
ProviderInteractionMode,
RuntimeMode,
ThreadId,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 2 additions & 14 deletions apps/server/src/mcp/McpToolAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
OrchestratorMcpEnvironmentTarget,
OrchestratorMcpFailure,
ProviderInstanceId,
ProviderInteractionMode,
RuntimeMode,
ThreadId,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -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),
);
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -366,7 +361,6 @@ const linkedProbe = {
environmentId: OrchestratorMcpEnvironmentTarget,
threadId: Schema.optional(ThreadId),
runtimeMode: Schema.optional(RuntimeMode),
interactionMode: Schema.optional(ProviderInteractionMode),
}),
};
const LinkedProbeToolkit = Toolkit.make(
Expand Down Expand Up @@ -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"],
[
Expand Down
19 changes: 7 additions & 12 deletions apps/server/src/mcp/McpToolAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -224,26 +224,21 @@ 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 = <P, A, E, R>(
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<A, E, R>,
linked: "stamped" | { readonly refused: string },
) => {
const started = Effect.fn("McpToolAccess.started")(function* (caller: Caller, params: P) {
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;
});
Expand Down
12 changes: 2 additions & 10 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
6 changes: 2 additions & 4 deletions apps/server/src/mcp/delegatedTaskModes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
type ModelSelection,
OrchestratorMcpFailure,
type OrchestratorMcpDelegateTaskInput,
type OrchestratorMcpInteractionMode,
type OrchestratorMcpRuntimeMode,
type ProviderInteractionMode,
type RuntimeMode,
Expand Down Expand Up @@ -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<ProviderInteractionMode, OrchestratorMcpFailure> {
const resolved = requested === undefined || requested === "inherit" ? parentMode : requested;
return interactionModeRank(resolved) > interactionModeRank(parentMode)
? Effect.fail(
new OrchestratorMcpFailure({
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/mcp/toolkits/project/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ import {
OrchestrationV2RunStatus,
OrchestrationV2ThreadLaunchWorkspaceStrategy,
RuntimeMode,
ProviderInteractionMode,
Project,
ProjectCreatePayload,
ProjectUpdatePayload,
ProjectId,
OrchestratorMcpClientRequestId,
OrchestratorMcpEnvironmentTarget,
OrchestratorMcpInteractionMode,
OrchestratorMcpFailure,
SourceControlCloneRepositoryInput,
SourceControlCloneRepositoryResult,
Expand Down Expand Up @@ -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),
Expand All @@ -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:
Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/peer/PeerForwarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
17 changes: 4 additions & 13 deletions apps/server/src/peer/RemoteDelegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/agentContextBudget.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"claudeSystemAppendBytes": 5597,
"codexAdditionalContextBytes": 7327,
"mcpToolListJsonBytes": 48217
"mcpToolListJsonBytes": 48192
}
22 changes: 12 additions & 10 deletions docs/orchestration-v2/orchestrator-mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
};
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/orchestratorMcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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",
Expand Down
8 changes: 3 additions & 5 deletions packages/contracts/src/orchestratorMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading