From a59eaccf753e13f56438a9cea75f5f088f0c7508 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:06:55 +0000 Subject: [PATCH 01/12] fix(server): let parents recover their delegated children across accounts --- apps/server/src/mcp/McpToolAccess.test.ts | 70 +++++ apps/server/src/mcp/McpToolAccess.ts | 29 +- apps/server/src/mcp/OrchestratorMcpService.ts | 17 +- apps/server/src/mcp/threadAccess.ts | 18 ++ .../src/mcp/toolkits/attachment/handlers.ts | 1 + .../src/mcp/toolkits/attachment/tools.ts | 2 +- apps/server/src/mcp/toolkits/core.test.ts | 267 +++++++++++++++++- .../src/mcp/toolkits/orchestrator/handlers.ts | 1 + .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/mcp/toolkits/thread/handlers.ts | 36 ++- .../src/provider/agentContextBudget.json | 2 +- 11 files changed, 422 insertions(+), 23 deletions(-) diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 6d63287f4eb2a..3c4b515f8d535 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -15,6 +15,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; +import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; import * as PeerForwarding from "../peer/PeerForwarding.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; @@ -31,6 +32,24 @@ const backgroundThreadId = ThreadId.make("thread:background"); const oldProviderThreadId = ThreadId.make("thread:old-provider-background"); const archivedThreadId = ThreadId.make("thread:archived-background"); const unknownOwnerThreadId = ThreadId.make("thread:unknown-background-owner"); +const ownChildThreadId = ThreadId.make("thread:own-child"); +const forgedChildThreadId = ThreadId.make("thread:forged-child"); +const othersChildThreadId = ThreadId.make("thread:others-child"); +const planChildThreadId = ThreadId.make("thread:plan-child"); + +const childShell = (threadId: ThreadId, parentThreadId: ThreadId) => ({ + ...liveThreadShell(threadId), + lineage: { + rootThreadId: parentThreadId, + parentThreadId, + relationshipToParent: "subagent" as const, + }, +}); +const appOwnedChildren = new Map([ + [supervisedThreadId, [ownChildThreadId]], + [fullAccessThreadId, [othersChildThreadId]], + [planThreadId, [planChildThreadId]], +]); const backgroundShell = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => ({ ...liveThreadShell(threadId, { activeRunId: null }), @@ -63,6 +82,11 @@ const shells = new Map([ pendingBackgroundTasks: [{ taskId: "task:unknown", kind: "subagent" as const }], }, ], + [ownChildThreadId, childShell(ownChildThreadId, supervisedThreadId)], + // Says it is the supervised thread's child, but that thread never delegated it. + [forgedChildThreadId, childShell(forgedChildThreadId, supervisedThreadId)], + [othersChildThreadId, childShell(othersChildThreadId, fullAccessThreadId)], + [planChildThreadId, childShell(planChildThreadId, planThreadId)], ]); const threadCaller = (threadId: ThreadId): McpInvocationContext.McpInvocationScope => ({ @@ -121,6 +145,10 @@ const ProbeToolkit = Toolkit.make( ...probe, parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), }), + Tool.make("writes_own_children", { + ...probe, + parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), + }), Tool.make("starts_threads", { ...probe, parameters: Schema.Struct({ @@ -141,6 +169,16 @@ const probeHandlers: McpToolAccess.Handlers = { (input) => [input.threadId], () => ran, ), + // Answers with the modes the orchestrator rechecks under its lock. + writes_own_children: McpToolAccess.writesThreads( + (input) => [input.threadId], + () => + Effect.gen(function* () { + const limit = yield* DispatchModeLimit; + return { ran: `${limit?.runtimeMode}/${limit?.interactionMode}` }; + }), + { ownChildren: "at-their-runtime-mode" }, + ), starts_threads: McpToolAccess.startsThreads( (input) => input, (_, modes) => Effect.succeed({ ran: `${modes.runtimeMode}/${modes.interactionMode}` }), @@ -184,6 +222,7 @@ export const refusedAtCompileTime = () => { writes: unchecked, writes_with_capability: unchecked, writes_threads: unchecked, + writes_own_children: unchecked, starts_threads: unchecked, writes_environment: unchecked, }), @@ -300,6 +339,35 @@ it.effect.each([ ], ["writes_threads", supervised, { threadId: "thread:missing" }, "ran"], + // A thread moves and resumes its own delegated children at the runtime mode they already have. + ["writes_own_children", supervised, { threadId: ownChildThreadId }, "full-access/default"], + [ + "writes_own_children", + plan, + { threadId: planChildThreadId }, + "interaction_mode_escalation_denied", + ], + ["writes_own_children", supervised, { threadId: planThreadId }, "approval-required/default"], + [ + "writes_own_children", + supervised, + { threadId: forgedChildThreadId }, + "runtime_mode_escalation_denied", + ], + [ + "writes_own_children", + supervised, + { threadId: othersChildThreadId }, + "runtime_mode_escalation_denied", + ], + [ + "writes_own_children", + supervisedClient, + { threadId: ownChildThreadId }, + "runtime_mode_escalation_denied", + ], + ["writes_threads", supervised, { threadId: ownChildThreadId }, "runtime_mode_escalation_denied"], + ["starts_threads", supervised, {}, "approval-required/default"], ["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"], ["starts_threads", plan, {}, "approval-required/plan"], @@ -322,6 +390,8 @@ it.effect.each([ probeServer( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: (threadId) => Effect.succeed(shells.get(threadId) ?? null), + listAppOwnedChildThreadIds: (threadId) => + Effect.succeed(appOwnedChildren.get(threadId) ?? []), }), ), ), diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index b82d9bb5ae59c..ddcb737f05b6b 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -23,12 +23,17 @@ 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 { + resolveInteractionMode, + resolveRuntimeMode, + runtimeModeRank, +} from "./delegatedTaskModes.ts"; import { assertFullAccess, assertLiveCaller, assertTargetWithinLimits, type Caller, + isOwnDelegatedChild, loadCaller, readCaller, unavailable, @@ -176,25 +181,43 @@ export const writesWithCapability = (handle: (params: P) => Effect.E export const writesThreads = ( threads: (params: P) => ReadonlyArray, handle: (params: P) => Effect.Effect, + options?: { + /** + * Whether the caller may act on its own delegated children whose runtime + * mode is above its own, at that mode, as moving one to another account + * and resuming it need. Interaction modes stay within the caller's. + */ + readonly ownChildren?: "at-their-runtime-mode"; + }, ) => declare( (params: P) => Effect.gen(function* () { const caller = yield* writingCaller; + let runtimeMode = caller.limits.runtimeMode; for (const threadId of threads(params)) { if (threadId === undefined || threadId === caller.scope.thread?.threadId) continue; const target = yield* caller.threads .getThreadShell(threadId) .pipe(Effect.mapError(unavailable)); if (target !== null && target.deletedAt === null) { - yield* assertTargetWithinLimits(caller.limits, target); + const ownChild = + options?.ownChildren === "at-their-runtime-mode" && + runtimeModeRank(target.runtimeMode) > runtimeModeRank(caller.limits.runtimeMode) && + (yield* isOwnDelegatedChild(caller.threads, caller.caller?.id, target)); + if (ownChild && runtimeModeRank(target.runtimeMode) > runtimeModeRank(runtimeMode)) + runtimeMode = target.runtimeMode; + yield* assertTargetWithinLimits( + ownChild ? { ...caller.limits, runtimeMode } : caller.limits, + target, + ); yield* assertSameLink(caller, target); } } // Modes can rise after this check; the orchestrator rechecks under lock and records refusal before handlers wrap errors. const refused = yield* Ref.make(undefined); return yield* handle(params).pipe( - Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }), + Effect.provideService(DispatchModeLimit, { ...caller.limits, runtimeMode, refused }), Effect.catch((error) => Effect.flatMap(Ref.get(refused), (refusal) => Effect.fail( diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 074b19fad3fec..c3fa7000eafe9 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -63,6 +63,7 @@ import { type ServerProviderModel, ThreadId, isOrchestrationV2WorkActive, + isRuntimeModeAbove, resolveProjectProviderInstanceEnabled, SECRET_REQUEST_UNSAVED_NOTE, } from "@t3tools/contracts"; @@ -124,7 +125,7 @@ import { runtimeModeRank, taskPrompt, } from "./delegatedTaskModes.ts"; -import { isLiveCallerShell, threadNotFound } from "./threadAccess.ts"; +import { isLiveCallerShell, isOwnDelegatedChild, threadNotFound } from "./threadAccess.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as PeerForwarding from "../peer/PeerForwarding.ts"; import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; @@ -3138,8 +3139,20 @@ const make = Effect.gen(function* () { } satisfies OrchestratorMcpThreadReadResult; }), sendToThread: Effect.fn("OrchestratorMcpService.sendToThread")(function* (scope, input) { - const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); + const { + parent, + limits: callerLimits, + target, + } = yield* loadScopedThread(scope, input.threadId); yield* assertLiveCallerForOtherThread(scope, parent, target); + // The declaration may lift only the runtime limit, only to its preflight value, only for the caller's own child. + const declared = yield* DispatchModeLimit; + const limits = + declared !== undefined && + isRuntimeModeAbove(declared.runtimeMode, callerLimits.runtimeMode) && + (yield* isOwnDelegatedChild(threadManagement, parent?.thread.id, target.thread)) + ? { ...callerLimits, runtimeMode: declared.runtimeMode } + : callerLimits; yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 4bf90f0182fc4..77c891f1e840c 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -120,6 +120,24 @@ export const assertTargetWithinLimits = ( Effect.asVoid, ); +/** A thread the calling thread itself delegated here, as `delegate_task` records it on the caller. */ +export const isOwnDelegatedChild = Effect.fn("mcp.isOwnDelegatedChild")(function* ( + threads: Pick, + callerThreadId: ThreadId | undefined, + target: Pick, +) { + if ( + callerThreadId === undefined || + target.lineage.parentThreadId !== callerThreadId || + target.lineage.relationshipToParent !== "subagent" + ) + return false; + const children = yield* threads + .listAppOwnedChildThreadIds(callerThreadId) + .pipe(Effect.mapError(unavailable)); + return children.includes(target.id); +}); + /** Background subagents retain credentials until provider exit; archived threads and other instances cannot act. */ export const isLiveCallerShell = ( shell: Pick< diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 8d72943f337ae..c2b2b6118416f 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -125,6 +125,7 @@ export const layer = McpToolAccess.toLayer( status: result.run.status, }; }), + { ownChildren: "at-their-runtime-mode" }, ), }; }), diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts index f9965850ec8c8..d5791c0d8261a 100644 --- a/apps/server/src/mcp/toolkits/attachment/tools.ts +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -48,7 +48,7 @@ const AttachmentSendTool = Tool.make("t3_thread_send_attachments", { ...shared, dependencies: [...shared.dependencies, DocumentText.DocumentText], description: - "Send uploaded attachments as a new message to a thread. No retry key. Target modes cannot exceed yours. Success does not mean the provider read them or finished.", + "Send uploaded attachments. Targets must fit your modes, except your delegated child's runtime mode. No retry key. Success does not mean the provider read them or finished.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 602031290763d..aa1be9276fdc4 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -2,20 +2,24 @@ import * as AssetSigner from "../../assets/AssetSigner.ts"; import * as DocumentText from "../../provider/DocumentText.ts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, ChatImageAttachment, CommandId, EnvironmentId, + MessageId, NodeId, OrchestratorMcpThreadReadResult, + ProjectId, ProviderDriverKind, ProviderInstanceId, + type ProviderInteractionMode, RunId, ScheduledTaskError, ScheduledTask, RuntimeRequestId, + type RuntimeMode, ThreadId, TurnItemId, type OrchestrationV2ProjectedTurnItem, @@ -38,6 +42,13 @@ import { } from "../../orchestration-v2/Orchestrator.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import { CodexProviderCapabilitiesV2 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as CommandReceiptStore from "../../orchestration-v2/CommandReceiptStore.ts"; +import { DispatchModeLimit } from "../../orchestration-v2/DispatchModeLimit.ts"; +import * as Orchestrator from "../../orchestration-v2/Orchestrator.ts"; +import * as ProviderReplayHarness from "../../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as SqlitePersistence from "../../persistence/Sqlite.ts"; +import * as ProviderRegistryMock from "../../provider/testUtils/providerRegistryMock.ts"; import * as ServerConfig from "../../config.ts"; import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; @@ -872,6 +883,260 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( ), ); +describe("a thread's own delegated child above its modes", () => { + const codex = ProviderDriverKind.make("codex"); + const accountA = ProviderInstanceId.make("account-a"); + const accountB = ProviderInstanceId.make("account-b"); + const parentId = ThreadId.make("thread:own-child-parent"); + const unrelatedId = ThreadId.make("thread:own-child-unrelated"); + const layerDatabase = SqlitePersistence.layerMemory; + const layerSettings = ServerSettings.layerTest({ + providerInstances: { + [accountA]: { driver: codex, enabled: true }, + [accountB]: { driver: codex, enabled: true }, + }, + }); + const layerAdapters = ProviderAdapterRegistry.layerFromAdapters( + [accountA, accountB].map((instanceId) => ({ + instanceId, + driver: codex, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("No provider process runs in these tests"), + })), + ); + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "mcp-own-child" }, + layerAdapters, + { databaseLayer: layerDatabase, runEffectWorker: false }, + ); + const layerThreads = ThreadManagement.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(layerSettings), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ); + const layerProviders = ProviderRegistryMock.layer( + [accountA, accountB].map((instanceId) => ({ + instanceId, + driver: codex, + enabled: true, + installed: true, + version: "test", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-11-03T00:00:00.000Z", + models: [{ slug: "gpt-5", name: "gpt-5", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + })), + ); + + const callAsParent = (name: string, args: Record) => + McpServer.McpServer.pipe( + Effect.flatMap((server) => server.callTool({ name, arguments: args })), + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...scope, + thread: { + threadId: parentId, + providerSessionId: "own-child", + providerInstanceId: accountA, + }, + }), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + /** + * A parent with a live run on account A, its Full-access/default delegated child stopped and + * snoozed there, and an unrelated Full-access/default thread. The user applies `narrow` to the + * parent once, after a tool's declaration read the parent and before the tool ran. + */ + const withOwnChild = ( + parentRuntimeMode: RuntimeMode, + narrow: { + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + }, + body: (childId: ThreadId) => Effect.Effect, + ) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const real = yield* ThreadManagement.ThreadManagementService; + let commands = 0; + const commandId = () => CommandId.make(`own-child:${commands++}`); + const create = (threadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.create", + commandId: commandId(), + threadId, + projectId: ProjectId.make("project:own-child"), + title: threadId, + modelSelection: { instanceId: accountA, model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const setParentModes = (modes: typeof narrow) => + Effect.all([ + modes.runtimeMode === undefined + ? Effect.void + : orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: commandId(), + threadId: parentId, + runtimeMode: modes.runtimeMode, + }), + modes.interactionMode === undefined + ? Effect.void + : orchestrator.dispatch({ + type: "thread.interaction-mode.set", + commandId: commandId(), + threadId: parentId, + interactionMode: modes.interactionMode, + }), + ]); + yield* create(parentId); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: commandId(), + threadId: parentId, + messageId: MessageId.make("message:own-child-parent"), + text: "Coordinate the child.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* create(unrelatedId); + const run = (yield* orchestrator.getThreadProjection(parentId)).runs.at(-1)!; + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: commandId(), + parentThreadId: parentId, + parentRunId: run.id, + parentNodeId: run.rootNodeId!, + task: "Keep the delegated context.", + modelSelection: { instanceId: accountA, model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + }); + const childId = (yield* orchestrator.getThreadProjection(parentId)).subagents[0]! + .childThreadId!; + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: commandId(), + threadId: childId, + reason: "Account A reached its usage limit.", + }); + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: commandId(), + threadId: childId, + snoozedUntil: "2099-11-10T00:00:00.000Z", + }); + yield* setParentModes({ runtimeMode: parentRuntimeMode }); + + let pending = Object.keys(narrow).length > 0; + const threads = ThreadManagement.ThreadManagementService.of({ + ...real, + getThreadShell: (id) => + real.getThreadShell(id).pipe( + Effect.tap(() => { + if (id !== parentId || !pending) return Effect.void; + pending = false; + return setParentModes(narrow).pipe( + Effect.provideService(DispatchModeLimit, undefined), + Effect.orDie, + ); + }), + ), + }); + return yield* body(childId).pipe( + // The declaration reads the caller through the calling context's service. + Effect.provideService(ThreadManagement.ThreadManagementService, threads), + Effect.provide( + Layer.mergeAll(layerThreadToolkit, layerOrchestratorToolkit).pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide( + Layer.mergeAll( + layerSettings, + layerAdapters, + layerProviders, + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(PeerForwarding.PeerForwarding)({}), + Layer.mock(PeerLinkRequests.PeerLinkRequests)({}), + Layer.mock(PeerLinks.PeerLinks)({}), + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + }).pipe(Effect.provide(Layer.merge(layerOrchestrator, layerThreads))); + + it.effect("moves it to another account and resumes it at its own modes", () => + withOwnChild("auto", {}, (childId) => + Effect.gen(function* () { + const moved = yield* callAsParent("t3_thread_configure", { + threadId: childId, + modelSelection: { instanceId: accountB, model: "gpt-5" }, + }); + expect(moved.isError).toBe(false); + const resumed = yield* callAsParent("t3_thread_send", { + threadId: childId, + message: "Resume.", + }); + expect(resumed.structuredContent).toMatchObject({ delivery: "started" }); + const child = yield* (yield* Orchestrator.OrchestratorV2).getThreadProjection(childId); + expect(child.thread).toMatchObject({ + providerInstanceId: accountB, + runtimeMode: "full-access", + interactionMode: "default", + snoozedUntil: null, + lineage: { parentThreadId: parentId, relationshipToParent: "subagent" }, + }); + expect(child.runs.at(-1)?.modelSelection.instanceId).toBe(accountB); + }), + ), + ); + + it.effect.each([ + { + name: "an unrelated Full-access thread once its caller drops to Auto", + parentRuntimeMode: "full-access", + narrow: { runtimeMode: "auto" }, + target: "unrelated", + code: "runtime_mode_escalation_denied", + }, + { + name: "its own child once its caller drops to plan", + parentRuntimeMode: "auto", + narrow: { interactionMode: "plan" }, + target: "child", + code: "interaction_mode_escalation_denied", + }, + ] as const)("t3_thread_send refuses $name", ({ parentRuntimeMode, narrow, target, code }) => + withOwnChild(parentRuntimeMode, narrow, (childId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = target === "child" ? childId : unrelatedId; + const runs = (yield* orchestrator.getThreadProjection(threadId)).runs.length; + const sent = yield* callAsParent("t3_thread_send", { threadId, message: "Go." }); + expect(declaredFailure(sent)).toMatchObject({ code }); + expect((yield* orchestrator.getThreadProjection(threadId)).runs).toHaveLength(runs); + }), + ), + ); +}); + it.effect("discovers workflow stop targets and requires orchestration capability", () => { const threadId = ThreadId.make("workflow-parent"); const shell = McpToolAccessTestkit.liveThreadShell(threadId); diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 3db297bc0d303..037165facedc7 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -176,6 +176,7 @@ const handlers = { const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.sendToThread(scope, input); }), + { ownChildren: "at-their-runtime-mode" }, ), t3_thread_wait: McpToolAccess.reads((input) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 150962e232a13..44c037d0e5304 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -270,7 +270,7 @@ const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send a message to any thread. The target cannot run with broader modes than yours. `mode:'auto'` starts an idle thread, steers a running turn, or queues. `queue`: new turn. `steer`: mid-turn update. `restart`: interrupt and restart. A send to a finished child thread starts a fresh task and returns its `taskId`. In your reply, name it as its `link` verbatim.", + "Send to a thread within your modes, or your delegated child at its runtime mode. `mode:'auto'`: start if idle, steer if running, otherwise queue. `queue`: new turn. `steer`: mid-turn update. `restart`: interrupt and restart. Sending to a finished child starts a fresh task and returns `taskId`. In your reply, name it as its `link` verbatim.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index f968312437518..2271ca6f30da3 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -88,7 +88,8 @@ const required = (value: A | undefined, name: string) => : Effect.succeed(value); const writesThread =

( handle: (params: P) => Effect.Effect, -) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); + options?: Parameters[2], +) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle, options); export const layer = McpToolAccess.toLayer(ThreadToolkit, { run_scheduled_task_now: McpToolAccess.writesEnvironment((input) => @@ -211,19 +212,26 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { }; }), ), - writesThread((input) => - Effect.gen(function* () { - const modelSelection = yield* required(input.modelSelection, "modelSelection"); - const { - threads, - projection: { thread }, - } = yield* readThread(input.threadId); - const type = modelSelectionCommandType(thread.providerInstanceId, modelSelection); - const result = yield* threads - .dispatch({ type, threadId: thread.id, commandId: yield* newCommandId(), modelSelection }) - .pipe(Effect.mapError(dispatchFailure)); - return { sequence: result.sequence }; - }), + writesThread( + (input) => + Effect.gen(function* () { + const modelSelection = yield* required(input.modelSelection, "modelSelection"); + const { + threads, + projection: { thread }, + } = yield* readThread(input.threadId); + const type = modelSelectionCommandType(thread.providerInstanceId, modelSelection); + const result = yield* threads + .dispatch({ + type, + threadId: thread.id, + commandId: yield* newCommandId(), + modelSelection, + }) + .pipe(Effect.mapError(dispatchFailure)); + return { sequence: result.sequence }; + }), + { ownChildren: "at-their-runtime-mode" }, ), ), t3_pending_request: McpToolAccess.either( diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 0a03b931fcc08..3d169dc23c694 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5133, "codexAdditionalContextBytes": 6863, - "mcpToolListJsonBytes": 47818 + "mcpToolListJsonBytes": 47810 } From 040451a86f83bdc36e453fd2278deaa67476cc21 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:56:16 +0000 Subject: [PATCH 02/12] fix(server): preserve fresh limits when resuming delegated children --- apps/server/src/mcp/McpToolAccess.ts | 9 +- apps/server/src/mcp/OrchestratorMcpService.ts | 13 ++- apps/server/src/mcp/toolkits/core.test.ts | 89 +++++++++++++------ .../src/orchestration-v2/DispatchModeLimit.ts | 2 + 4 files changed, 82 insertions(+), 31 deletions(-) diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index ddcb737f05b6b..333581d13343b 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -217,7 +217,14 @@ export const writesThreads = ( // Modes can rise after this check; the orchestrator rechecks under lock and records refusal before handlers wrap errors. const refused = yield* Ref.make(undefined); return yield* handle(params).pipe( - Effect.provideService(DispatchModeLimit, { ...caller.limits, runtimeMode, refused }), + Effect.provideService(DispatchModeLimit, { + ...caller.limits, + runtimeMode, + ...(runtimeMode === caller.limits.runtimeMode + ? {} + : { ownChildRuntimeMode: runtimeMode }), + refused, + }), Effect.catch((error) => Effect.flatMap(Ref.get(refused), (refusal) => Effect.fail( diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index c3fa7000eafe9..26a7a99605dfa 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -3145,13 +3145,13 @@ const make = Effect.gen(function* () { target, } = yield* loadScopedThread(scope, input.threadId); yield* assertLiveCallerForOtherThread(scope, parent, target); - // The declaration may lift only the runtime limit, only to its preflight value, only for the caller's own child. + // The declaration may lift only the runtime limit, only to its own child's preflight mode, only for that child. const declared = yield* DispatchModeLimit; const limits = - declared !== undefined && - isRuntimeModeAbove(declared.runtimeMode, callerLimits.runtimeMode) && + declared?.ownChildRuntimeMode !== undefined && + isRuntimeModeAbove(declared.ownChildRuntimeMode, callerLimits.runtimeMode) && (yield* isOwnDelegatedChild(threadManagement, parent?.thread.id, target.thread)) - ? { ...callerLimits, runtimeMode: declared.runtimeMode } + ? { ...callerLimits, runtimeMode: declared.ownChildRuntimeMode } : callerLimits; yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); @@ -3182,6 +3182,11 @@ const make = Effect.gen(function* () { creationSource: "mcp", }) .pipe( + // Recheck the limits checked above under the lock, not the declaration's older ones. + Effect.provideService( + DispatchModeLimit, + declared === undefined ? undefined : { ...declared, ...limits }, + ), Effect.mapError((error) => isThreadManagementError(error) ? threadManagementFailure(error) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index aa1be9276fdc4..ededef181cef2 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -945,16 +945,23 @@ describe("a thread's own delegated child above its modes", () => { Effect.provideService(McpSchema.McpServerClient, client), ); + interface Modes { + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + } + /** - * A parent with a live run on account A, its Full-access/default delegated child stopped and - * snoozed there, and an unrelated Full-access/default thread. The user applies `narrow` to the - * parent once, after a tool's declaration read the parent and before the tool ran. + * A parent with a live run on account A, its delegated child (Full-access/default unless `child` + * says otherwise) stopped and snoozed there, and an unrelated Full-access/default thread. The user + * applies `narrow` to the parent once, after a tool's declaration read the parent and before the + * tool ran, and `childBeforeSend` to the child once, as a send reaches thread management. */ const withOwnChild = ( - parentRuntimeMode: RuntimeMode, - narrow: { - readonly runtimeMode?: RuntimeMode; - readonly interactionMode?: ProviderInteractionMode; + setup: { + readonly parentRuntimeMode: RuntimeMode; + readonly narrow?: Modes; + readonly child?: Modes; + readonly childBeforeSend?: Modes; }, body: (childId: ThreadId) => Effect.Effect, ) => @@ -978,14 +985,14 @@ describe("a thread's own delegated child above its modes", () => { createdBy: "user", creationSource: "web", }); - const setParentModes = (modes: typeof narrow) => + const setModes = (threadId: ThreadId, modes: Modes = {}) => Effect.all([ modes.runtimeMode === undefined ? Effect.void : orchestrator.dispatch({ type: "thread.runtime-mode.set", commandId: commandId(), - threadId: parentId, + threadId, runtimeMode: modes.runtimeMode, }), modes.interactionMode === undefined @@ -993,10 +1000,14 @@ describe("a thread's own delegated child above its modes", () => { : orchestrator.dispatch({ type: "thread.interaction-mode.set", commandId: commandId(), - threadId: parentId, + threadId, interactionMode: modes.interactionMode, }), - ]); + ]).pipe( + // The user changes modes outside any tool's limit. + Effect.provideService(DispatchModeLimit, undefined), + Effect.orDie, + ); yield* create(parentId); yield* orchestrator.dispatch({ type: "message.dispatch", @@ -1038,22 +1049,27 @@ describe("a thread's own delegated child above its modes", () => { threadId: childId, snoozedUntil: "2099-11-10T00:00:00.000Z", }); - yield* setParentModes({ runtimeMode: parentRuntimeMode }); + yield* setModes(childId, setup.child); + yield* setModes(parentId, { runtimeMode: setup.parentRuntimeMode }); - let pending = Object.keys(narrow).length > 0; + let narrowPending = setup.narrow !== undefined; + let childPending = setup.childBeforeSend !== undefined; const threads = ThreadManagement.ThreadManagementService.of({ ...real, getThreadShell: (id) => real.getThreadShell(id).pipe( Effect.tap(() => { - if (id !== parentId || !pending) return Effect.void; - pending = false; - return setParentModes(narrow).pipe( - Effect.provideService(DispatchModeLimit, undefined), - Effect.orDie, - ); + if (id !== parentId || !narrowPending) return Effect.void; + narrowPending = false; + return setModes(parentId, setup.narrow); }), ), + sendToThread: (input) => + Effect.suspend(() => { + if (input.threadId !== childId || !childPending) return Effect.void; + childPending = false; + return setModes(childId, setup.childBeforeSend); + }).pipe(Effect.andThen(real.sendToThread(input))), }); return yield* body(childId).pipe( // The declaration reads the caller through the calling context's service. @@ -1063,6 +1079,7 @@ describe("a thread's own delegated child above its modes", () => { Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide( Layer.mergeAll( + Layer.succeed(ThreadManagement.ThreadManagementService, threads), layerSettings, layerAdapters, layerProviders, @@ -1083,7 +1100,7 @@ describe("a thread's own delegated child above its modes", () => { }).pipe(Effect.provide(Layer.merge(layerOrchestrator, layerThreads))); it.effect("moves it to another account and resumes it at its own modes", () => - withOwnChild("auto", {}, (childId) => + withOwnChild({ parentRuntimeMode: "auto" }, (childId) => Effect.gen(function* () { const moved = yield* callAsParent("t3_thread_configure", { threadId: childId, @@ -1111,20 +1128,40 @@ describe("a thread's own delegated child above its modes", () => { it.effect.each([ { name: "an unrelated Full-access thread once its caller drops to Auto", - parentRuntimeMode: "full-access", - narrow: { runtimeMode: "auto" }, + setup: { parentRuntimeMode: "full-access", narrow: { runtimeMode: "auto" } }, target: "unrelated", code: "runtime_mode_escalation_denied", }, { name: "its own child once its caller drops to plan", - parentRuntimeMode: "auto", - narrow: { interactionMode: "plan" }, + setup: { parentRuntimeMode: "auto", narrow: { interactionMode: "plan" } }, target: "child", code: "interaction_mode_escalation_denied", }, - ] as const)("t3_thread_send refuses $name", ({ parentRuntimeMode, narrow, target, code }) => - withOwnChild(parentRuntimeMode, narrow, (childId) => + { + name: "its own plan child raised to default once its caller drops to plan", + setup: { + parentRuntimeMode: "auto", + narrow: { interactionMode: "plan" }, + child: { interactionMode: "plan" }, + childBeforeSend: { interactionMode: "default" }, + }, + target: "child", + code: "interaction_mode_escalation_denied", + }, + { + name: "its own Auto child raised to Full access once its caller drops to Auto", + setup: { + parentRuntimeMode: "full-access", + narrow: { runtimeMode: "auto" }, + child: { runtimeMode: "auto" }, + childBeforeSend: { runtimeMode: "full-access" }, + }, + target: "child", + code: "runtime_mode_escalation_denied", + }, + ] as const)("t3_thread_send refuses $name", ({ setup, target, code }) => + withOwnChild(setup, (childId) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const threadId = target === "child" ? childId : unrelatedId; diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.ts index 627806347c5ff..0dbeb5bd8d1bf 100644 --- a/apps/server/src/orchestration-v2/DispatchModeLimit.ts +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.ts @@ -21,6 +21,8 @@ export interface DispatchModeRefusal extends DispatchModes { /** Recheck target modes under the command lock; the user can raise them after preflight. */ export interface DispatchModeLimitValue extends DispatchModes { readonly refused?: Ref.Ref; + /** Set when `runtimeMode` was lifted above the caller's to its own delegated child's preflight mode. */ + readonly ownChildRuntimeMode?: RuntimeMode; } export const DispatchModeLimit = Context.Reference( From 2961543e18d7ac4298730024ef3cff45da59fdd6 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:03:31 +0000 Subject: [PATCH 03/12] fix(server): clarify when automatic thread sends steer --- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 2 +- apps/server/src/provider/agentContextBudget.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 44c037d0e5304..a2e5af02e2ab3 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -270,7 +270,7 @@ const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send to a thread within your modes, or your delegated child at its runtime mode. `mode:'auto'`: start if idle, steer if running, otherwise queue. `queue`: new turn. `steer`: mid-turn update. `restart`: interrupt and restart. Sending to a finished child starts a fresh task and returns `taskId`. In your reply, name it as its `link` verbatim.", + "Send to a thread within your modes, or your delegated child at its runtime mode. `mode:'auto'`: start if idle, steer if steerable, otherwise queue. `queue`: new turn. `steer`: mid-turn update. `restart`: interrupt and restart. Sending to a finished child starts a fresh task and returns `taskId`. In your reply, name it as its `link` verbatim.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 3d169dc23c694..f7af4805da568 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5133, "codexAdditionalContextBytes": 6863, - "mcpToolListJsonBytes": 47810 + "mcpToolListJsonBytes": 47812 } From fcf60e94e33659d6d7ff907b13a791b22db861b4 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:54:25 +0000 Subject: [PATCH 04/12] fix(server): refresh caller limits before sending child attachments --- .../src/mcp/toolkits/attachment/handlers.ts | 28 ++- apps/server/src/mcp/toolkits/core.test.ts | 179 +++++++++++------- 2 files changed, 134 insertions(+), 73 deletions(-) diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index c2b2b6118416f..7a246f8a16cd1 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -3,6 +3,7 @@ import { type ChatAttachment, MessageId, OrchestratorMcpFailure, + isRuntimeModeAbove, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; @@ -10,9 +11,11 @@ import * as AssetSigner from "../../../assets/AssetSigner.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as Upload from "../../../assets/AttachmentUpload.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; +import { DispatchModeLimit } from "../../../orchestration-v2/DispatchModeLimit.ts"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; -import { newCommandId, readThread, unavailable } from "../../threadAccess.ts"; +import { resolveInteractionMode, resolveRuntimeMode } from "../../delegatedTaskModes.ts"; +import { isOwnDelegatedChild, newCommandId, readThread, unavailable } from "../../threadAccess.ts"; import { AttachmentToolkit } from "./tools.ts"; export function resolveAttachmentReferences( @@ -85,7 +88,24 @@ export const layer = McpToolAccess.toLayer( (input) => [input.threadId], (input) => Effect.gen(function* () { - const { caller, projection } = yield* readThread(input.threadId, ["messages"]); + const { + caller, + threads, + limits: callerLimits, + projection, + } = yield* readThread(input.threadId, ["messages"]); + const declared = yield* DispatchModeLimit; + const limits = + declared?.ownChildRuntimeMode !== undefined && + isRuntimeModeAbove(declared.ownChildRuntimeMode, callerLimits.runtimeMode) && + (yield* isOwnDelegatedChild(threads, caller?.id, projection.thread)) + ? { ...callerLimits, runtimeMode: declared.ownChildRuntimeMode } + : callerLimits; + yield* resolveRuntimeMode(limits.runtimeMode, projection.thread.runtimeMode); + yield* resolveInteractionMode( + limits.interactionMode, + projection.thread.interactionMode, + ); if (projection.thread.archivedAt !== null) return yield* new OrchestratorMcpFailure({ code: "invalid_request", @@ -109,6 +129,10 @@ export const layer = McpToolAccess.toLayer( createdBy: "agent", creationSource: "mcp", }).pipe( + Effect.provideService( + DispatchModeLimit, + declared === undefined ? undefined : { ...declared, ...limits }, + ), Effect.mapError((error) => error._tag === "AttachmentClaimError" ? new OrchestratorMcpFailure({ diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index ededef181cef2..c9d596e8ab624 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -28,6 +28,8 @@ import { import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -50,6 +52,7 @@ import * as ProviderReplayHarness from "../../orchestration-v2/testkit/ProviderR import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import * as ProviderRegistryMock from "../../provider/testUtils/providerRegistryMock.ts"; import * as ServerConfig from "../../config.ts"; +import { createPendingAttachmentId } from "../../attachmentStore.ts"; import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; @@ -945,6 +948,21 @@ describe("a thread's own delegated child above its modes", () => { Effect.provideService(McpSchema.McpServerClient, client), ); + const sendAsParent = (tool: string, threadId: ThreadId) => + Effect.gen(function* () { + if (tool === "t3_thread_send") return yield* callAsParent(tool, { threadId, message: "Go." }); + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const id = createPendingAttachmentId(); + yield* fs.writeFileString(path.join(config.attachmentsDir, `${id}.png`), "test"); + return yield* callAsParent(tool, { + threadId, + message: "Go.", + attachments: [{ type: "image", id, name: "test.png", mimeType: "image/png", sizeBytes: 4 }], + }); + }); + interface Modes { readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; @@ -1075,7 +1093,13 @@ describe("a thread's own delegated child above its modes", () => { // The declaration reads the caller through the calling context's service. Effect.provideService(ThreadManagement.ThreadManagementService, threads), Effect.provide( - Layer.mergeAll(layerThreadToolkit, layerOrchestratorToolkit).pipe( + Layer.mergeAll( + layerThreadToolkit, + layerOrchestratorToolkit, + McpHttpServer.layerAttachmentToolkit.pipe( + Layer.provide(AssetSigner.layer.pipe(Layer.provideMerge(ServerSecretStore.layer))), + ), + ).pipe( Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provide( Layer.mergeAll( @@ -1097,81 +1121,94 @@ describe("a thread's own delegated child above its modes", () => { ), ), ); - }).pipe(Effect.provide(Layer.merge(layerOrchestrator, layerThreads))); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerOrchestrator, + layerThreads, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-own-child-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + Layer.mock(DocumentText.DocumentText)({ ensure: () => Effect.undefined }), + ), + ), + ); - it.effect("moves it to another account and resumes it at its own modes", () => - withOwnChild({ parentRuntimeMode: "auto" }, (childId) => - Effect.gen(function* () { - const moved = yield* callAsParent("t3_thread_configure", { - threadId: childId, - modelSelection: { instanceId: accountB, model: "gpt-5" }, - }); - expect(moved.isError).toBe(false); - const resumed = yield* callAsParent("t3_thread_send", { - threadId: childId, - message: "Resume.", - }); - expect(resumed.structuredContent).toMatchObject({ delivery: "started" }); - const child = yield* (yield* Orchestrator.OrchestratorV2).getThreadProjection(childId); - expect(child.thread).toMatchObject({ - providerInstanceId: accountB, - runtimeMode: "full-access", - interactionMode: "default", - snoozedUntil: null, - lineage: { parentThreadId: parentId, relationshipToParent: "subagent" }, - }); - expect(child.runs.at(-1)?.modelSelection.instanceId).toBe(accountB); - }), - ), + it.effect.each(["t3_thread_send", "t3_thread_send_attachments"])( + "%s moves it to another account and resumes it at its own modes", + (tool) => + withOwnChild({ parentRuntimeMode: "auto" }, (childId) => + Effect.gen(function* () { + const moved = yield* callAsParent("t3_thread_configure", { + threadId: childId, + modelSelection: { instanceId: accountB, model: "gpt-5" }, + }); + expect(moved.isError).toBe(false); + const resumed = yield* sendAsParent(tool, childId); + expect(resumed.isError).toBe(false); + const child = yield* (yield* Orchestrator.OrchestratorV2).getThreadProjection(childId); + expect(child.thread).toMatchObject({ + providerInstanceId: accountB, + runtimeMode: "full-access", + interactionMode: "default", + snoozedUntil: null, + lineage: { parentThreadId: parentId, relationshipToParent: "subagent" }, + }); + expect(child.runs.at(-1)?.modelSelection.instanceId).toBe(accountB); + if (tool === "t3_thread_send_attachments") + expect(child.messages.at(-1)?.attachments).toMatchObject([{ name: "test.png" }]); + }), + ), ); - it.effect.each([ - { - name: "an unrelated Full-access thread once its caller drops to Auto", - setup: { parentRuntimeMode: "full-access", narrow: { runtimeMode: "auto" } }, - target: "unrelated", - code: "runtime_mode_escalation_denied", - }, - { - name: "its own child once its caller drops to plan", - setup: { parentRuntimeMode: "auto", narrow: { interactionMode: "plan" } }, - target: "child", - code: "interaction_mode_escalation_denied", - }, - { - name: "its own plan child raised to default once its caller drops to plan", - setup: { - parentRuntimeMode: "auto", - narrow: { interactionMode: "plan" }, - child: { interactionMode: "plan" }, - childBeforeSend: { interactionMode: "default" }, + for (const tool of ["t3_thread_send", "t3_thread_send_attachments"]) + it.effect.each([ + { + name: "an unrelated Full-access thread once its caller drops to Auto", + setup: { parentRuntimeMode: "full-access", narrow: { runtimeMode: "auto" } }, + target: "unrelated", + code: "runtime_mode_escalation_denied", }, - target: "child", - code: "interaction_mode_escalation_denied", - }, - { - name: "its own Auto child raised to Full access once its caller drops to Auto", - setup: { - parentRuntimeMode: "full-access", - narrow: { runtimeMode: "auto" }, - child: { runtimeMode: "auto" }, - childBeforeSend: { runtimeMode: "full-access" }, + { + name: "its own child once its caller drops to plan", + setup: { parentRuntimeMode: "auto", narrow: { interactionMode: "plan" } }, + target: "child", + code: "interaction_mode_escalation_denied", }, - target: "child", - code: "runtime_mode_escalation_denied", - }, - ] as const)("t3_thread_send refuses $name", ({ setup, target, code }) => - withOwnChild(setup, (childId) => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = target === "child" ? childId : unrelatedId; - const runs = (yield* orchestrator.getThreadProjection(threadId)).runs.length; - const sent = yield* callAsParent("t3_thread_send", { threadId, message: "Go." }); - expect(declaredFailure(sent)).toMatchObject({ code }); - expect((yield* orchestrator.getThreadProjection(threadId)).runs).toHaveLength(runs); - }), - ), - ); + { + name: "its own plan child raised to default once its caller drops to plan", + setup: { + parentRuntimeMode: "auto", + narrow: { interactionMode: "plan" }, + child: { interactionMode: "plan" }, + childBeforeSend: { interactionMode: "default" }, + }, + target: "child", + code: "interaction_mode_escalation_denied", + }, + { + name: "its own Auto child raised to Full access once its caller drops to Auto", + setup: { + parentRuntimeMode: "full-access", + narrow: { runtimeMode: "auto" }, + child: { runtimeMode: "auto" }, + childBeforeSend: { runtimeMode: "full-access" }, + }, + target: "child", + code: "runtime_mode_escalation_denied", + }, + ] as const)(`${tool} refuses $name`, ({ setup, target, code }) => + withOwnChild(setup, (childId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = target === "child" ? childId : unrelatedId; + const runs = (yield* orchestrator.getThreadProjection(threadId)).runs.length; + const sent = yield* sendAsParent(tool, threadId); + expect(declaredFailure(sent)).toMatchObject({ code }); + expect((yield* orchestrator.getThreadProjection(threadId)).runs).toHaveLength(runs); + }), + ), + ); }); it.effect("discovers workflow stop targets and requires orchestration capability", () => { From 890d560fd0ce135a33433e054cece3ff859b7b77 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 6 Nov 2026 05:34:23 +0000 Subject: [PATCH 05/12] fix(server): refresh caller limits before configuring threads --- apps/server/src/mcp/toolkits/core.test.ts | 49 ++++++++++++------- .../src/mcp/toolkits/thread/handlers.ts | 23 ++++++++- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index c9d596e8ab624..ecca0503e2392 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -948,8 +948,13 @@ describe("a thread's own delegated child above its modes", () => { Effect.provideService(McpSchema.McpServerClient, client), ); - const sendAsParent = (tool: string, threadId: ThreadId) => + const writeAsParent = (tool: string, threadId: ThreadId) => Effect.gen(function* () { + if (tool === "t3_thread_configure") + return yield* callAsParent(tool, { + threadId, + modelSelection: { instanceId: accountB, model: "gpt-5" }, + }); if (tool === "t3_thread_send") return yield* callAsParent(tool, { threadId, message: "Go." }); const config = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; @@ -972,14 +977,14 @@ describe("a thread's own delegated child above its modes", () => { * A parent with a live run on account A, its delegated child (Full-access/default unless `child` * says otherwise) stopped and snoozed there, and an unrelated Full-access/default thread. The user * applies `narrow` to the parent once, after a tool's declaration read the parent and before the - * tool ran, and `childBeforeSend` to the child once, as a send reaches thread management. + * tool ran, and `childBeforeWrite` to the child once, as the write reaches thread management. */ const withOwnChild = ( setup: { readonly parentRuntimeMode: RuntimeMode; readonly narrow?: Modes; readonly child?: Modes; - readonly childBeforeSend?: Modes; + readonly childBeforeWrite?: Modes; }, body: (childId: ThreadId) => Effect.Effect, ) => @@ -1071,7 +1076,13 @@ describe("a thread's own delegated child above its modes", () => { yield* setModes(parentId, { runtimeMode: setup.parentRuntimeMode }); let narrowPending = setup.narrow !== undefined; - let childPending = setup.childBeforeSend !== undefined; + let childPending = setup.childBeforeWrite !== undefined; + const updateChildModes = (threadId: ThreadId | undefined) => + Effect.suspend(() => { + if (threadId !== childId || !childPending) return Effect.void; + childPending = false; + return setModes(childId, setup.childBeforeWrite); + }); const threads = ThreadManagement.ThreadManagementService.of({ ...real, getThreadShell: (id) => @@ -1083,11 +1094,12 @@ describe("a thread's own delegated child above its modes", () => { }), ), sendToThread: (input) => - Effect.suspend(() => { - if (input.threadId !== childId || !childPending) return Effect.void; - childPending = false; - return setModes(childId, setup.childBeforeSend); - }).pipe(Effect.andThen(real.sendToThread(input))), + updateChildModes(input.threadId).pipe(Effect.andThen(real.sendToThread(input))), + dispatch: (command) => + (command.type === "provider.switch" || command.type === "thread.model-selection.set" + ? updateChildModes(command.threadId) + : Effect.void + ).pipe(Effect.andThen(real.dispatch(command))), }); return yield* body(childId).pipe( // The declaration reads the caller through the calling context's service. @@ -1144,7 +1156,7 @@ describe("a thread's own delegated child above its modes", () => { modelSelection: { instanceId: accountB, model: "gpt-5" }, }); expect(moved.isError).toBe(false); - const resumed = yield* sendAsParent(tool, childId); + const resumed = yield* writeAsParent(tool, childId); expect(resumed.isError).toBe(false); const child = yield* (yield* Orchestrator.OrchestratorV2).getThreadProjection(childId); expect(child.thread).toMatchObject({ @@ -1161,7 +1173,7 @@ describe("a thread's own delegated child above its modes", () => { ), ); - for (const tool of ["t3_thread_send", "t3_thread_send_attachments"]) + for (const tool of ["t3_thread_send", "t3_thread_send_attachments", "t3_thread_configure"]) it.effect.each([ { name: "an unrelated Full-access thread once its caller drops to Auto", @@ -1181,7 +1193,7 @@ describe("a thread's own delegated child above its modes", () => { parentRuntimeMode: "auto", narrow: { interactionMode: "plan" }, child: { interactionMode: "plan" }, - childBeforeSend: { interactionMode: "default" }, + childBeforeWrite: { interactionMode: "default" }, }, target: "child", code: "interaction_mode_escalation_denied", @@ -1192,7 +1204,7 @@ describe("a thread's own delegated child above its modes", () => { parentRuntimeMode: "full-access", narrow: { runtimeMode: "auto" }, child: { runtimeMode: "auto" }, - childBeforeSend: { runtimeMode: "full-access" }, + childBeforeWrite: { runtimeMode: "full-access" }, }, target: "child", code: "runtime_mode_escalation_denied", @@ -1202,10 +1214,13 @@ describe("a thread's own delegated child above its modes", () => { Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const threadId = target === "child" ? childId : unrelatedId; - const runs = (yield* orchestrator.getThreadProjection(threadId)).runs.length; - const sent = yield* sendAsParent(tool, threadId); - expect(declaredFailure(sent)).toMatchObject({ code }); - expect((yield* orchestrator.getThreadProjection(threadId)).runs).toHaveLength(runs); + const before = yield* orchestrator.getThreadProjection(threadId); + const result = yield* writeAsParent(tool, threadId); + expect(declaredFailure(result)).toMatchObject({ code }); + const after = yield* orchestrator.getThreadProjection(threadId); + expect(after.runs).toHaveLength(before.runs.length); + expect(after.thread.modelSelection).toEqual(before.thread.modelSelection); + expect(after.thread.providerInstanceId).toBe(before.thread.providerInstanceId); }), ), ); diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 2271ca6f30da3..67f9904faa803 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -6,6 +6,7 @@ import { type RunId, OrchestratorMcpFailure, type OrchestrationV2Command, + isRuntimeModeAbove, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { modelSelectionCommandType } from "@t3tools/shared/model"; @@ -14,11 +15,14 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { dispatchFailure, + isOwnDelegatedChild, newCommandId, readCaller, readThread, unavailable, } from "../../threadAccess.ts"; +import { resolveInteractionMode, resolveRuntimeMode } from "../../delegatedTaskModes.ts"; +import { DispatchModeLimit } from "../../../orchestration-v2/DispatchModeLimit.ts"; import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts"; import * as ScheduledTasks from "../../../scheduledTasks/ScheduledTaskService.ts"; import { queuedRunsInDeliveryOrder } from "../../../orchestration-v2/QueuedRunOrder.ts"; @@ -217,9 +221,20 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { Effect.gen(function* () { const modelSelection = yield* required(input.modelSelection, "modelSelection"); const { + caller, threads, + limits: callerLimits, projection: { thread }, } = yield* readThread(input.threadId); + const declared = yield* DispatchModeLimit; + const limits = + declared?.ownChildRuntimeMode !== undefined && + isRuntimeModeAbove(declared.ownChildRuntimeMode, callerLimits.runtimeMode) && + (yield* isOwnDelegatedChild(threads, caller?.id, thread)) + ? { ...callerLimits, runtimeMode: declared.ownChildRuntimeMode } + : callerLimits; + yield* resolveRuntimeMode(limits.runtimeMode, thread.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, thread.interactionMode); const type = modelSelectionCommandType(thread.providerInstanceId, modelSelection); const result = yield* threads .dispatch({ @@ -228,7 +243,13 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { commandId: yield* newCommandId(), modelSelection, }) - .pipe(Effect.mapError(dispatchFailure)); + .pipe( + Effect.provideService( + DispatchModeLimit, + declared === undefined ? undefined : { ...declared, ...limits }, + ), + Effect.mapError(dispatchFailure), + ); return { sequence: result.sequence }; }), { ownChildren: "at-their-runtime-mode" }, From d747dbd3fcf365b3d170de0b4260728d899a7deb Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:00:58 +0000 Subject: [PATCH 06/12] fix(server): recheck parent interaction mode under dispatch locks --- apps/server/src/mcp/McpToolAccess.ts | 3 + apps/server/src/mcp/toolkits/core.test.ts | 27 +++++--- .../src/orchestration-v2/DispatchModeLimit.ts | 11 ++++ .../src/orchestration-v2/Orchestrator.ts | 64 +++++++++++++------ 4 files changed, 77 insertions(+), 28 deletions(-) diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 333581d13343b..98dded3a27822 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -223,6 +223,9 @@ export const writesThreads = ( ...(runtimeMode === caller.limits.runtimeMode ? {} : { ownChildRuntimeMode: runtimeMode }), + ...(options?.ownChildren === undefined || caller.caller === undefined + ? {} + : { callerThreadId: caller.caller.id }), refused, }), Effect.catch((error) => diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index ecca0503e2392..2ed045a17fe60 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -976,8 +976,8 @@ describe("a thread's own delegated child above its modes", () => { /** * A parent with a live run on account A, its delegated child (Full-access/default unless `child` * says otherwise) stopped and snoozed there, and an unrelated Full-access/default thread. The user - * applies `narrow` to the parent once, after a tool's declaration read the parent and before the - * tool ran, and `childBeforeWrite` to the child once, as the write reaches thread management. + * applies `narrow` to the parent after the declaration read it, and `childBeforeWrite` to the + * child and `parentBeforeWrite` to the parent as the write reaches thread management, once each. */ const withOwnChild = ( setup: { @@ -985,6 +985,7 @@ describe("a thread's own delegated child above its modes", () => { readonly narrow?: Modes; readonly child?: Modes; readonly childBeforeWrite?: Modes; + readonly parentBeforeWrite?: Modes; }, body: (childId: ThreadId) => Effect.Effect, ) => @@ -1076,12 +1077,14 @@ describe("a thread's own delegated child above its modes", () => { yield* setModes(parentId, { runtimeMode: setup.parentRuntimeMode }); let narrowPending = setup.narrow !== undefined; - let childPending = setup.childBeforeWrite !== undefined; - const updateChildModes = (threadId: ThreadId | undefined) => + let writePending = true; + const updateModesBeforeWrite = (threadId: ThreadId | undefined) => Effect.suspend(() => { - if (threadId !== childId || !childPending) return Effect.void; - childPending = false; - return setModes(childId, setup.childBeforeWrite); + if (threadId !== childId || !writePending) return Effect.void; + writePending = false; + return setModes(childId, setup.childBeforeWrite).pipe( + Effect.andThen(setModes(parentId, setup.parentBeforeWrite)), + ); }); const threads = ThreadManagement.ThreadManagementService.of({ ...real, @@ -1094,10 +1097,10 @@ describe("a thread's own delegated child above its modes", () => { }), ), sendToThread: (input) => - updateChildModes(input.threadId).pipe(Effect.andThen(real.sendToThread(input))), + updateModesBeforeWrite(input.threadId).pipe(Effect.andThen(real.sendToThread(input))), dispatch: (command) => (command.type === "provider.switch" || command.type === "thread.model-selection.set" - ? updateChildModes(command.threadId) + ? updateModesBeforeWrite(command.threadId) : Effect.void ).pipe(Effect.andThen(real.dispatch(command))), }); @@ -1187,6 +1190,12 @@ describe("a thread's own delegated child above its modes", () => { target: "child", code: "interaction_mode_escalation_denied", }, + { + name: "its own child once its caller drops to plan as the write is dispatched", + setup: { parentRuntimeMode: "auto", parentBeforeWrite: { interactionMode: "plan" } }, + target: "child", + code: "interaction_mode_escalation_denied", + }, { name: "its own plan child raised to default once its caller drops to plan", setup: { diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.ts index 0dbeb5bd8d1bf..3c6ac929167fb 100644 --- a/apps/server/src/orchestration-v2/DispatchModeLimit.ts +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.ts @@ -23,6 +23,8 @@ export interface DispatchModeLimitValue extends DispatchModes { readonly refused?: Ref.Ref; /** Set when `runtimeMode` was lifted above the caller's to its own delegated child's preflight mode. */ readonly ownChildRuntimeMode?: RuntimeMode; + /** The calling thread. Commands to its app-owned children recheck its interaction mode under its lock. */ + readonly callerThreadId?: ThreadId; } export const DispatchModeLimit = Context.Reference( @@ -42,6 +44,15 @@ export const exceededDispatchModeLimit = ( ? "interaction" : undefined; +/** A limit lowered to the interaction mode its caller has now. */ +export const narrowToInteractionMode = ( + limit: Limit, + interactionMode: ProviderInteractionMode, +): Limit => + interactionModeRank[interactionMode] < interactionModeRank[limit.interactionMode] + ? { ...limit, interactionMode } + : limit; + /** The administrator's `limits.maxRuntimeMode`, which applies to every caller, the user included. */ export interface RuntimeModeCeiling { readonly organization: string; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index bcec0a13cf20a..4d989ffd19e60 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -148,6 +148,7 @@ import { DispatchModeLimit, exceededDispatchModeLimit, narrowToCeiling, + narrowToInteractionMode, type RuntimeModeCeiling, runtimeModeCeilingRefusal, threadsStartingWork, @@ -12471,15 +12472,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio * Thread lineage and fork origin are immutable, so this * is safe to read without holding either thread's dispatch lock. */ + const appOwnedParentOf = ( + childThread: Pick, + ): ThreadId | undefined => + childThread.lineage.relationshipToParent === "subagent" && + childThread.lineage.parentThreadId !== null && + childThread.forkedFrom?.type === "node" + ? childThread.lineage.parentThreadId + : undefined; const appOwnedSubagentParentThreadId = Effect.fn("Orchestrator.appOwnedSubagentParentThreadId")( function* (childThreadId: ThreadId) { - const childThread = yield* projectionStore.getThread(childThreadId); - const lineage = childThread.lineage; - return lineage.relationshipToParent === "subagent" && - lineage.parentThreadId !== null && - childThread.forkedFrom?.type === "node" - ? lineage.parentThreadId - : undefined; + return appOwnedParentOf(yield* projectionStore.getThread(childThreadId)); }, ); @@ -14065,20 +14068,43 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); // Answering a message-mode question can also start a follow-up. - if ( - command.type !== "runtime-request.respond" && - (command.type !== "message.dispatch" || !mayStartSubagentFollowup(command)) - ) - return yield* dispatch; - const parentThreadId = yield* appOwnedSubagentParentThreadId(command.threadId).pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + const followup = + command.type === "runtime-request.respond" || + (command.type === "message.dispatch" && mayStartSubagentFollowup(command)); + const limit = yield* DispatchModeLimit; + if (!followup && limit?.callerThreadId === undefined) return yield* dispatch; + const threadId = commandThreadId(command); + const parentThreadId = yield* ( + followup + ? appOwnedSubagentParentThreadId(threadId) + : projectionStore + .getThreadShell(threadId) + .pipe(Effect.map((shell) => (shell === null ? undefined : appOwnedParentOf(shell)))) + ).pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + if (parentThreadId === undefined) return yield* dispatch; + if (limit === undefined || parentThreadId !== limit.callerThreadId) + return yield* followup ? threadDispatch.withLock(parentThreadId, dispatch) : dispatch; + // Follow-up intake and Stop serialize on the parent before taking the child lock. The parent + // changes its interaction mode only under its lock, so read the one that bounds its child here. + return yield* threadDispatch.withLock( + parentThreadId, + threadDispatch.withLock( + threadId, + projectionStore.getThreadShell(parentThreadId).pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: parentThreadId, cause }), + ), + Effect.flatMap((parent) => + dispatchWithReceiptEffect(command).pipe( + Effect.provideService( + DispatchModeLimit, + narrowToInteractionMode(limit, parent?.interactionMode ?? "plan"), + ), + ), + ), + ), ), ); - // Follow-up intake and Stop serialize on the parent before taking the child lock. - return yield* parentThreadId === undefined - ? dispatch - : threadDispatch.withLock(parentThreadId, dispatch); }); const handleRunUpdate = Effect.fn("Orchestrator.handleRunUpdate")( From 0fa5929a459d45450a87714bf009d1abea52629a Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:23:24 +0000 Subject: [PATCH 07/12] fix(server): keep the owned-child exception to the runtime limit Drop the caller-mode race follow-ups: they closed races that main already has for every target of these tools, and their fresh-limit reread could let a child run above the mode it had at preflight. Configure and attachment sends only opt in; text send recognizes the declared allowance. Trim the tests to the cases that fail on main, and add an eval guard for the recovery flow. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpToolAccess.test.ts | 11 - apps/server/src/mcp/McpToolAccess.ts | 3 - apps/server/src/mcp/OrchestratorMcpService.ts | 20 +- .../src/mcp/toolkits/attachment/handlers.ts | 28 +- .../src/mcp/toolkits/attachment/tools.ts | 2 +- apps/server/src/mcp/toolkits/core.test.ts | 362 +++++------------- .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/mcp/toolkits/thread/handlers.ts | 23 +- .../src/orchestration-v2/DispatchModeLimit.ts | 11 - .../src/orchestration-v2/Orchestrator.ts | 64 +--- .../src/provider/agentContextBudget.json | 2 +- evals/mcp-tool-use/cases/orchestration.ndjson | 1 + 12 files changed, 122 insertions(+), 407 deletions(-) diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 3c4b515f8d535..bb9b177c9f0cd 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -34,7 +34,6 @@ const archivedThreadId = ThreadId.make("thread:archived-background"); const unknownOwnerThreadId = ThreadId.make("thread:unknown-background-owner"); const ownChildThreadId = ThreadId.make("thread:own-child"); const forgedChildThreadId = ThreadId.make("thread:forged-child"); -const othersChildThreadId = ThreadId.make("thread:others-child"); const planChildThreadId = ThreadId.make("thread:plan-child"); const childShell = (threadId: ThreadId, parentThreadId: ThreadId) => ({ @@ -47,7 +46,6 @@ const childShell = (threadId: ThreadId, parentThreadId: ThreadId) => ({ }); const appOwnedChildren = new Map([ [supervisedThreadId, [ownChildThreadId]], - [fullAccessThreadId, [othersChildThreadId]], [planThreadId, [planChildThreadId]], ]); @@ -85,7 +83,6 @@ const shells = new Map([ [ownChildThreadId, childShell(ownChildThreadId, supervisedThreadId)], // Says it is the supervised thread's child, but that thread never delegated it. [forgedChildThreadId, childShell(forgedChildThreadId, supervisedThreadId)], - [othersChildThreadId, childShell(othersChildThreadId, fullAccessThreadId)], [planChildThreadId, childShell(planChildThreadId, planThreadId)], ]); @@ -347,26 +344,18 @@ it.effect.each([ { threadId: planChildThreadId }, "interaction_mode_escalation_denied", ], - ["writes_own_children", supervised, { threadId: planThreadId }, "approval-required/default"], [ "writes_own_children", supervised, { threadId: forgedChildThreadId }, "runtime_mode_escalation_denied", ], - [ - "writes_own_children", - supervised, - { threadId: othersChildThreadId }, - "runtime_mode_escalation_denied", - ], [ "writes_own_children", supervisedClient, { threadId: ownChildThreadId }, "runtime_mode_escalation_denied", ], - ["writes_threads", supervised, { threadId: ownChildThreadId }, "runtime_mode_escalation_denied"], ["starts_threads", supervised, {}, "approval-required/default"], ["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"], diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 98dded3a27822..333581d13343b 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -223,9 +223,6 @@ export const writesThreads = ( ...(runtimeMode === caller.limits.runtimeMode ? {} : { ownChildRuntimeMode: runtimeMode }), - ...(options?.ownChildren === undefined || caller.caller === undefined - ? {} - : { callerThreadId: caller.caller.id }), refused, }), Effect.catch((error) => diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index ffff5ba72add4..b581b3cade296 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -63,7 +63,6 @@ import { type ServerProviderModel, ThreadId, isOrchestrationV2WorkActive, - isRuntimeModeAbove, resolveProjectProviderInstanceEnabled, SECRET_REQUEST_UNSAVED_NOTE, } from "@t3tools/contracts"; @@ -125,7 +124,7 @@ import { runtimeModeRank, taskPrompt, } from "./delegatedTaskModes.ts"; -import { isLiveCallerShell, isOwnDelegatedChild, threadNotFound } from "./threadAccess.ts"; +import { isLiveCallerShell, threadNotFound } from "./threadAccess.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as PeerForwarding from "../peer/PeerForwarding.ts"; import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; @@ -3148,14 +3147,12 @@ const make = Effect.gen(function* () { target, } = yield* loadScopedThread(scope, input.threadId); yield* assertLiveCallerForOtherThread(scope, parent, target); - // The declaration may lift only the runtime limit, only to its own child's preflight mode, only for that child. - const declared = yield* DispatchModeLimit; + // The declaration lifts only the runtime limit, and only to its own child's preflight mode. + const ownChildRuntimeMode = (yield* DispatchModeLimit)?.ownChildRuntimeMode; const limits = - declared?.ownChildRuntimeMode !== undefined && - isRuntimeModeAbove(declared.ownChildRuntimeMode, callerLimits.runtimeMode) && - (yield* isOwnDelegatedChild(threadManagement, parent?.thread.id, target.thread)) - ? { ...callerLimits, runtimeMode: declared.ownChildRuntimeMode } - : callerLimits; + ownChildRuntimeMode === undefined + ? callerLimits + : { ...callerLimits, runtimeMode: ownChildRuntimeMode }; yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); @@ -3185,11 +3182,6 @@ const make = Effect.gen(function* () { creationSource: "mcp", }) .pipe( - // Recheck the limits checked above under the lock, not the declaration's older ones. - Effect.provideService( - DispatchModeLimit, - declared === undefined ? undefined : { ...declared, ...limits }, - ), Effect.mapError((error) => isThreadManagementError(error) ? threadManagementFailure(error) diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 7a246f8a16cd1..c2b2b6118416f 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -3,7 +3,6 @@ import { type ChatAttachment, MessageId, OrchestratorMcpFailure, - isRuntimeModeAbove, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; @@ -11,11 +10,9 @@ import * as AssetSigner from "../../../assets/AssetSigner.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as Upload from "../../../assets/AttachmentUpload.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; -import { DispatchModeLimit } from "../../../orchestration-v2/DispatchModeLimit.ts"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; -import { resolveInteractionMode, resolveRuntimeMode } from "../../delegatedTaskModes.ts"; -import { isOwnDelegatedChild, newCommandId, readThread, unavailable } from "../../threadAccess.ts"; +import { newCommandId, readThread, unavailable } from "../../threadAccess.ts"; import { AttachmentToolkit } from "./tools.ts"; export function resolveAttachmentReferences( @@ -88,24 +85,7 @@ export const layer = McpToolAccess.toLayer( (input) => [input.threadId], (input) => Effect.gen(function* () { - const { - caller, - threads, - limits: callerLimits, - projection, - } = yield* readThread(input.threadId, ["messages"]); - const declared = yield* DispatchModeLimit; - const limits = - declared?.ownChildRuntimeMode !== undefined && - isRuntimeModeAbove(declared.ownChildRuntimeMode, callerLimits.runtimeMode) && - (yield* isOwnDelegatedChild(threads, caller?.id, projection.thread)) - ? { ...callerLimits, runtimeMode: declared.ownChildRuntimeMode } - : callerLimits; - yield* resolveRuntimeMode(limits.runtimeMode, projection.thread.runtimeMode); - yield* resolveInteractionMode( - limits.interactionMode, - projection.thread.interactionMode, - ); + const { caller, projection } = yield* readThread(input.threadId, ["messages"]); if (projection.thread.archivedAt !== null) return yield* new OrchestratorMcpFailure({ code: "invalid_request", @@ -129,10 +109,6 @@ export const layer = McpToolAccess.toLayer( createdBy: "agent", creationSource: "mcp", }).pipe( - Effect.provideService( - DispatchModeLimit, - declared === undefined ? undefined : { ...declared, ...limits }, - ), Effect.mapError((error) => error._tag === "AttachmentClaimError" ? new OrchestratorMcpFailure({ diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts index d5791c0d8261a..6bbcaa3190467 100644 --- a/apps/server/src/mcp/toolkits/attachment/tools.ts +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -48,7 +48,7 @@ const AttachmentSendTool = Tool.make("t3_thread_send_attachments", { ...shared, dependencies: [...shared.dependencies, DocumentText.DocumentText], description: - "Send uploaded attachments. Targets must fit your modes, except your delegated child's runtime mode. No retry key. Success does not mean the provider read them or finished.", + "Send uploaded attachments as a new message to a thread. No retry key. Target modes cannot exceed yours, except your child's runtime mode. Success does not mean the provider read them or finished.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 2ed045a17fe60..d29f1ddfe3c0d 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -14,12 +14,10 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, - type ProviderInteractionMode, RunId, ScheduledTaskError, ScheduledTask, RuntimeRequestId, - type RuntimeMode, ThreadId, TurnItemId, type OrchestrationV2ProjectedTurnItem, @@ -28,8 +26,6 @@ import { import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -46,13 +42,11 @@ import { import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import { CodexProviderCapabilitiesV2 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "../../orchestration-v2/CommandReceiptStore.ts"; -import { DispatchModeLimit } from "../../orchestration-v2/DispatchModeLimit.ts"; import * as Orchestrator from "../../orchestration-v2/Orchestrator.ts"; import * as ProviderReplayHarness from "../../orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import * as ProviderRegistryMock from "../../provider/testUtils/providerRegistryMock.ts"; import * as ServerConfig from "../../config.ts"; -import { createPendingAttachmentId } from "../../attachmentStore.ts"; import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; @@ -891,7 +885,6 @@ describe("a thread's own delegated child above its modes", () => { const accountA = ProviderInstanceId.make("account-a"); const accountB = ProviderInstanceId.make("account-b"); const parentId = ThreadId.make("thread:own-child-parent"); - const unrelatedId = ThreadId.make("thread:own-child-unrelated"); const layerDatabase = SqlitePersistence.layerMemory; const layerSettings = ServerSettings.layerTest({ providerInstances: { @@ -933,6 +926,25 @@ describe("a thread's own delegated child above its modes", () => { skills: [], })), ); + const layerToolkits = Layer.mergeAll(layerThreadToolkit, layerOrchestratorToolkit).pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide( + Layer.mergeAll( + layerSettings, + layerAdapters, + layerProviders, + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(PeerForwarding.PeerForwarding)({}), + Layer.mock(PeerLinkRequests.PeerLinkRequests)({}), + Layer.mock(PeerLinks.PeerLinks)({}), + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ); const callAsParent = (name: string, args: Record) => McpServer.McpServer.pipe( @@ -948,59 +960,19 @@ describe("a thread's own delegated child above its modes", () => { Effect.provideService(McpSchema.McpServerClient, client), ); - const writeAsParent = (tool: string, threadId: ThreadId) => - Effect.gen(function* () { - if (tool === "t3_thread_configure") - return yield* callAsParent(tool, { - threadId, - modelSelection: { instanceId: accountB, model: "gpt-5" }, - }); - if (tool === "t3_thread_send") return yield* callAsParent(tool, { threadId, message: "Go." }); - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const id = createPendingAttachmentId(); - yield* fs.writeFileString(path.join(config.attachmentsDir, `${id}.png`), "test"); - return yield* callAsParent(tool, { - threadId, - message: "Go.", - attachments: [{ type: "image", id, name: "test.png", mimeType: "image/png", sizeBytes: 4 }], - }); - }); - - interface Modes { - readonly runtimeMode?: RuntimeMode; - readonly interactionMode?: ProviderInteractionMode; - } - - /** - * A parent with a live run on account A, its delegated child (Full-access/default unless `child` - * says otherwise) stopped and snoozed there, and an unrelated Full-access/default thread. The user - * applies `narrow` to the parent after the declaration read it, and `childBeforeWrite` to the - * child and `parentBeforeWrite` to the parent as the write reaches thread management, once each. - */ - const withOwnChild = ( - setup: { - readonly parentRuntimeMode: RuntimeMode; - readonly narrow?: Modes; - readonly child?: Modes; - readonly childBeforeWrite?: Modes; - readonly parentBeforeWrite?: Modes; - }, - body: (childId: ThreadId) => Effect.Effect, - ) => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const real = yield* ThreadManagement.ThreadManagementService; - let commands = 0; - const commandId = () => CommandId.make(`own-child:${commands++}`); - const create = (threadId: ThreadId) => - orchestrator.dispatch({ + it.effect( + "an Auto parent moves its stopped Full-access child to another account and resumes it", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + let commands = 0; + const commandId = () => CommandId.make(`own-child:${commands++}`); + yield* orchestrator.dispatch({ type: "thread.create", commandId: commandId(), - threadId, + threadId: parentId, projectId: ProjectId.make("project:own-child"), - title: threadId, + title: "parent", modelSelection: { instanceId: accountA, model: "gpt-5" }, runtimeMode: "full-access", interactionMode: "default", @@ -1009,230 +981,76 @@ describe("a thread's own delegated child above its modes", () => { createdBy: "user", creationSource: "web", }); - const setModes = (threadId: ThreadId, modes: Modes = {}) => - Effect.all([ - modes.runtimeMode === undefined - ? Effect.void - : orchestrator.dispatch({ - type: "thread.runtime-mode.set", - commandId: commandId(), - threadId, - runtimeMode: modes.runtimeMode, - }), - modes.interactionMode === undefined - ? Effect.void - : orchestrator.dispatch({ - type: "thread.interaction-mode.set", - commandId: commandId(), - threadId, - interactionMode: modes.interactionMode, - }), - ]).pipe( - // The user changes modes outside any tool's limit. - Effect.provideService(DispatchModeLimit, undefined), - Effect.orDie, - ); - yield* create(parentId); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: commandId(), - threadId: parentId, - messageId: MessageId.make("message:own-child-parent"), - text: "Coordinate the child.", - attachments: [], - dispatchMode: { type: "start_immediately" }, - createdBy: "user", - creationSource: "web", - }); - yield* create(unrelatedId); - const run = (yield* orchestrator.getThreadProjection(parentId)).runs.at(-1)!; - yield* orchestrator.dispatch({ - type: "delegated_task.request", - commandId: commandId(), - parentThreadId: parentId, - parentRunId: run.id, - parentNodeId: run.rootNodeId!, - task: "Keep the delegated context.", - modelSelection: { instanceId: accountA, model: "gpt-5" }, - runtimeMode: "full-access", - interactionMode: "default", - createdBy: "agent", - creationSource: "mcp", - }); - const childId = (yield* orchestrator.getThreadProjection(parentId)).subagents[0]! - .childThreadId!; - yield* orchestrator.dispatch({ - type: "thread.stop", - commandId: commandId(), - threadId: childId, - reason: "Account A reached its usage limit.", - }); - yield* orchestrator.dispatch({ - type: "thread.snooze", - commandId: commandId(), - threadId: childId, - snoozedUntil: "2099-11-10T00:00:00.000Z", - }); - yield* setModes(childId, setup.child); - yield* setModes(parentId, { runtimeMode: setup.parentRuntimeMode }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: commandId(), + threadId: parentId, + messageId: MessageId.make("message:own-child-parent"), + text: "Coordinate the child.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const run = (yield* orchestrator.getThreadProjection(parentId)).runs.at(-1)!; + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: commandId(), + parentThreadId: parentId, + parentRunId: run.id, + parentNodeId: run.rootNodeId!, + task: "Keep the delegated context.", + modelSelection: { instanceId: accountA, model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + }); + const childId = (yield* orchestrator.getThreadProjection(parentId)).subagents[0]! + .childThreadId!; + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: commandId(), + threadId: childId, + reason: "Account A reached its usage limit.", + }); + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: commandId(), + threadId: parentId, + runtimeMode: "auto", + }); - let narrowPending = setup.narrow !== undefined; - let writePending = true; - const updateModesBeforeWrite = (threadId: ThreadId | undefined) => - Effect.suspend(() => { - if (threadId !== childId || !writePending) return Effect.void; - writePending = false; - return setModes(childId, setup.childBeforeWrite).pipe( - Effect.andThen(setModes(parentId, setup.parentBeforeWrite)), - ); + const moved = yield* callAsParent("t3_thread_configure", { + threadId: childId, + modelSelection: { instanceId: accountB, model: "gpt-5" }, }); - const threads = ThreadManagement.ThreadManagementService.of({ - ...real, - getThreadShell: (id) => - real.getThreadShell(id).pipe( - Effect.tap(() => { - if (id !== parentId || !narrowPending) return Effect.void; - narrowPending = false; - return setModes(parentId, setup.narrow); - }), - ), - sendToThread: (input) => - updateModesBeforeWrite(input.threadId).pipe(Effect.andThen(real.sendToThread(input))), - dispatch: (command) => - (command.type === "provider.switch" || command.type === "thread.model-selection.set" - ? updateModesBeforeWrite(command.threadId) - : Effect.void - ).pipe(Effect.andThen(real.dispatch(command))), - }); - return yield* body(childId).pipe( - // The declaration reads the caller through the calling context's service. - Effect.provideService(ThreadManagement.ThreadManagementService, threads), + expect(moved.isError).toBe(false); + const resumed = yield* callAsParent("t3_thread_send", { + threadId: childId, + message: "Go.", + }); + expect(resumed.isError).toBe(false); + const child = yield* orchestrator.getThreadProjection(childId); + expect(child.thread).toMatchObject({ + providerInstanceId: accountB, + runtimeMode: "full-access", + interactionMode: "default", + }); + expect(child.runs.at(-1)?.modelSelection.instanceId).toBe(accountB); + }).pipe( + Effect.provide(layerToolkits), Effect.provide( Layer.mergeAll( - layerThreadToolkit, - layerOrchestratorToolkit, - McpHttpServer.layerAttachmentToolkit.pipe( - Layer.provide(AssetSigner.layer.pipe(Layer.provideMerge(ServerSecretStore.layer))), - ), - ).pipe( - Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), - Layer.provide( - Layer.mergeAll( - Layer.succeed(ThreadManagement.ThreadManagementService, threads), - layerSettings, - layerAdapters, - layerProviders, - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(RemoteDelegation.RemoteDelegation)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - Layer.mock(PeerForwarding.PeerForwarding)({}), - Layer.mock(PeerLinkRequests.PeerLinkRequests)({}), - Layer.mock(PeerLinks.PeerLinks)({}), - NodeServices.layer, - NodeCrypto.layer, - ), + layerOrchestrator, + layerThreads, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-own-child-" }).pipe( + Layer.provideMerge(NodeServices.layer), ), ), ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerOrchestrator, - layerThreads, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-own-child-" }).pipe( - Layer.provideMerge(NodeServices.layer), - ), - Layer.mock(DocumentText.DocumentText)({ ensure: () => Effect.undefined }), - ), - ), - ); - - it.effect.each(["t3_thread_send", "t3_thread_send_attachments"])( - "%s moves it to another account and resumes it at its own modes", - (tool) => - withOwnChild({ parentRuntimeMode: "auto" }, (childId) => - Effect.gen(function* () { - const moved = yield* callAsParent("t3_thread_configure", { - threadId: childId, - modelSelection: { instanceId: accountB, model: "gpt-5" }, - }); - expect(moved.isError).toBe(false); - const resumed = yield* writeAsParent(tool, childId); - expect(resumed.isError).toBe(false); - const child = yield* (yield* Orchestrator.OrchestratorV2).getThreadProjection(childId); - expect(child.thread).toMatchObject({ - providerInstanceId: accountB, - runtimeMode: "full-access", - interactionMode: "default", - snoozedUntil: null, - lineage: { parentThreadId: parentId, relationshipToParent: "subagent" }, - }); - expect(child.runs.at(-1)?.modelSelection.instanceId).toBe(accountB); - if (tool === "t3_thread_send_attachments") - expect(child.messages.at(-1)?.attachments).toMatchObject([{ name: "test.png" }]); - }), ), ); - - for (const tool of ["t3_thread_send", "t3_thread_send_attachments", "t3_thread_configure"]) - it.effect.each([ - { - name: "an unrelated Full-access thread once its caller drops to Auto", - setup: { parentRuntimeMode: "full-access", narrow: { runtimeMode: "auto" } }, - target: "unrelated", - code: "runtime_mode_escalation_denied", - }, - { - name: "its own child once its caller drops to plan", - setup: { parentRuntimeMode: "auto", narrow: { interactionMode: "plan" } }, - target: "child", - code: "interaction_mode_escalation_denied", - }, - { - name: "its own child once its caller drops to plan as the write is dispatched", - setup: { parentRuntimeMode: "auto", parentBeforeWrite: { interactionMode: "plan" } }, - target: "child", - code: "interaction_mode_escalation_denied", - }, - { - name: "its own plan child raised to default once its caller drops to plan", - setup: { - parentRuntimeMode: "auto", - narrow: { interactionMode: "plan" }, - child: { interactionMode: "plan" }, - childBeforeWrite: { interactionMode: "default" }, - }, - target: "child", - code: "interaction_mode_escalation_denied", - }, - { - name: "its own Auto child raised to Full access once its caller drops to Auto", - setup: { - parentRuntimeMode: "full-access", - narrow: { runtimeMode: "auto" }, - child: { runtimeMode: "auto" }, - childBeforeWrite: { runtimeMode: "full-access" }, - }, - target: "child", - code: "runtime_mode_escalation_denied", - }, - ] as const)(`${tool} refuses $name`, ({ setup, target, code }) => - withOwnChild(setup, (childId) => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = target === "child" ? childId : unrelatedId; - const before = yield* orchestrator.getThreadProjection(threadId); - const result = yield* writeAsParent(tool, threadId); - expect(declaredFailure(result)).toMatchObject({ code }); - const after = yield* orchestrator.getThreadProjection(threadId); - expect(after.runs).toHaveLength(before.runs.length); - expect(after.thread.modelSelection).toEqual(before.thread.modelSelection); - expect(after.thread.providerInstanceId).toBe(before.thread.providerInstanceId); - }), - ), - ); }); it.effect("discovers workflow stop targets and requires orchestration capability", () => { diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 8842253e619de..80485b2fa2f31 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -270,7 +270,7 @@ const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send to a thread within your modes, or your delegated child at its runtime mode. `mode:'auto'`: start if idle, steer if steerable, otherwise queue. `queue`: new turn. `steer`: mid-turn update. `restart`: interrupt and restart. Sending to a finished child starts a fresh task and returns `taskId`. In your reply, name it as its `link` verbatim.", + "Send a message to any thread. Target modes cannot exceed yours, except your child's runtime mode. `mode:'auto'` starts an idle thread, steers a running turn, or queues. `queue`: new turn. `steer`: mid-turn update. `restart`: interrupt and restart. A send to a finished child thread starts a fresh task and returns its `taskId`. In your reply, name it as its `link` verbatim.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 67f9904faa803..2271ca6f30da3 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -6,7 +6,6 @@ import { type RunId, OrchestratorMcpFailure, type OrchestrationV2Command, - isRuntimeModeAbove, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { modelSelectionCommandType } from "@t3tools/shared/model"; @@ -15,14 +14,11 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { dispatchFailure, - isOwnDelegatedChild, newCommandId, readCaller, readThread, unavailable, } from "../../threadAccess.ts"; -import { resolveInteractionMode, resolveRuntimeMode } from "../../delegatedTaskModes.ts"; -import { DispatchModeLimit } from "../../../orchestration-v2/DispatchModeLimit.ts"; import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts"; import * as ScheduledTasks from "../../../scheduledTasks/ScheduledTaskService.ts"; import { queuedRunsInDeliveryOrder } from "../../../orchestration-v2/QueuedRunOrder.ts"; @@ -221,20 +217,9 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { Effect.gen(function* () { const modelSelection = yield* required(input.modelSelection, "modelSelection"); const { - caller, threads, - limits: callerLimits, projection: { thread }, } = yield* readThread(input.threadId); - const declared = yield* DispatchModeLimit; - const limits = - declared?.ownChildRuntimeMode !== undefined && - isRuntimeModeAbove(declared.ownChildRuntimeMode, callerLimits.runtimeMode) && - (yield* isOwnDelegatedChild(threads, caller?.id, thread)) - ? { ...callerLimits, runtimeMode: declared.ownChildRuntimeMode } - : callerLimits; - yield* resolveRuntimeMode(limits.runtimeMode, thread.runtimeMode); - yield* resolveInteractionMode(limits.interactionMode, thread.interactionMode); const type = modelSelectionCommandType(thread.providerInstanceId, modelSelection); const result = yield* threads .dispatch({ @@ -243,13 +228,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { commandId: yield* newCommandId(), modelSelection, }) - .pipe( - Effect.provideService( - DispatchModeLimit, - declared === undefined ? undefined : { ...declared, ...limits }, - ), - Effect.mapError(dispatchFailure), - ); + .pipe(Effect.mapError(dispatchFailure)); return { sequence: result.sequence }; }), { ownChildren: "at-their-runtime-mode" }, diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.ts index 3c6ac929167fb..0dbeb5bd8d1bf 100644 --- a/apps/server/src/orchestration-v2/DispatchModeLimit.ts +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.ts @@ -23,8 +23,6 @@ export interface DispatchModeLimitValue extends DispatchModes { readonly refused?: Ref.Ref; /** Set when `runtimeMode` was lifted above the caller's to its own delegated child's preflight mode. */ readonly ownChildRuntimeMode?: RuntimeMode; - /** The calling thread. Commands to its app-owned children recheck its interaction mode under its lock. */ - readonly callerThreadId?: ThreadId; } export const DispatchModeLimit = Context.Reference( @@ -44,15 +42,6 @@ export const exceededDispatchModeLimit = ( ? "interaction" : undefined; -/** A limit lowered to the interaction mode its caller has now. */ -export const narrowToInteractionMode = ( - limit: Limit, - interactionMode: ProviderInteractionMode, -): Limit => - interactionModeRank[interactionMode] < interactionModeRank[limit.interactionMode] - ? { ...limit, interactionMode } - : limit; - /** The administrator's `limits.maxRuntimeMode`, which applies to every caller, the user included. */ export interface RuntimeModeCeiling { readonly organization: string; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index b6f13cea6ead5..5d3c4391b1fe8 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -148,7 +148,6 @@ import { DispatchModeLimit, exceededDispatchModeLimit, narrowToCeiling, - narrowToInteractionMode, type RuntimeModeCeiling, runtimeModeCeilingRefusal, threadsStartingWork, @@ -12481,17 +12480,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio * Thread lineage and fork origin are immutable, so this * is safe to read without holding either thread's dispatch lock. */ - const appOwnedParentOf = ( - childThread: Pick, - ): ThreadId | undefined => - childThread.lineage.relationshipToParent === "subagent" && - childThread.lineage.parentThreadId !== null && - childThread.forkedFrom?.type === "node" - ? childThread.lineage.parentThreadId - : undefined; const appOwnedSubagentParentThreadId = Effect.fn("Orchestrator.appOwnedSubagentParentThreadId")( function* (childThreadId: ThreadId) { - return appOwnedParentOf(yield* projectionStore.getThread(childThreadId)); + const childThread = yield* projectionStore.getThread(childThreadId); + const lineage = childThread.lineage; + return lineage.relationshipToParent === "subagent" && + lineage.parentThreadId !== null && + childThread.forkedFrom?.type === "node" + ? lineage.parentThreadId + : undefined; }, ); @@ -14077,43 +14074,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); // Answering a message-mode question can also start a follow-up. - const followup = - command.type === "runtime-request.respond" || - (command.type === "message.dispatch" && mayStartSubagentFollowup(command)); - const limit = yield* DispatchModeLimit; - if (!followup && limit?.callerThreadId === undefined) return yield* dispatch; - const threadId = commandThreadId(command); - const parentThreadId = yield* ( - followup - ? appOwnedSubagentParentThreadId(threadId) - : projectionStore - .getThreadShell(threadId) - .pipe(Effect.map((shell) => (shell === null ? undefined : appOwnedParentOf(shell)))) - ).pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); - if (parentThreadId === undefined) return yield* dispatch; - if (limit === undefined || parentThreadId !== limit.callerThreadId) - return yield* followup ? threadDispatch.withLock(parentThreadId, dispatch) : dispatch; - // Follow-up intake and Stop serialize on the parent before taking the child lock. The parent - // changes its interaction mode only under its lock, so read the one that bounds its child here. - return yield* threadDispatch.withLock( - parentThreadId, - threadDispatch.withLock( - threadId, - projectionStore.getThreadShell(parentThreadId).pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: parentThreadId, cause }), - ), - Effect.flatMap((parent) => - dispatchWithReceiptEffect(command).pipe( - Effect.provideService( - DispatchModeLimit, - narrowToInteractionMode(limit, parent?.interactionMode ?? "plan"), - ), - ), - ), - ), + if ( + command.type !== "runtime-request.respond" && + (command.type !== "message.dispatch" || !mayStartSubagentFollowup(command)) + ) + return yield* dispatch; + const parentThreadId = yield* appOwnedSubagentParentThreadId(command.threadId).pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), ), ); + // Follow-up intake and Stop serialize on the parent before taking the child lock. + return yield* parentThreadId === undefined + ? dispatch + : threadDispatch.withLock(parentThreadId, dispatch); }); const handleRunUpdate = Effect.fn("Orchestrator.handleRunUpdate")( diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index edb0d5ab3da0a..e5ac190700caf 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5366, "codexAdditionalContextBytes": 7096, - "mcpToolListJsonBytes": 48113 + "mcpToolListJsonBytes": 48168 } diff --git a/evals/mcp-tool-use/cases/orchestration.ndjson b/evals/mcp-tool-use/cases/orchestration.ndjson index 41eb03e0939fc..5b4e5517ed193 100644 --- a/evals/mcp-tool-use/cases/orchestration.ndjson +++ b/evals/mcp-tool-use/cases/orchestration.ndjson @@ -35,3 +35,4 @@ {"id": "worker-counts-per-model", "area": "orchestration", "kind": "bad", "pattern": "named-model rule read as a cap", "experience": "Asked for 20 Sol workers and 20 Luna workers, the agent started one worker per model (#25359).", "rule": "Before delegate_task ... One child per named model. (intended: every named model gets a child; read as a cap of one child per model)", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}, "world": {"files": {"tests/flaky/retry.test.ts": "it('retry is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/upload.test.ts": "it('upload is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/session.test.ts": "it('session is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/search.test.ts": "it('search is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/billing.test.ts": "it('billing is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/webhook.test.ts": "it('webhook is flaky', async () => { /* fails 1 in 5 runs */ });\n"}}}, "prompt": "Six tests in tests/flaky/ fail now and then. Start 3 Muse Wonder Full workers and 3 Grok workers, one test file each, to find and fix the flake.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "muse-wonder-full"}}, {"tool": "delegate_task", "args": {"target.model": "grok-4.7"}}], "minCalls": {"delegate_task": 6}}, "notes": "Scaled down from the real 20 Sol + 20 Luna ask. Muse and Grok need delegate_task from every parent, so a native Sol helper cannot pass or fail it."} {"id": "linked-thread-chip-reads-there", "area": "orchestration", "kind": "guard", "pattern": "thread chip from a linked machine read on this one", "experience": "A thread chip from a linked machine names its environmentId; an agent that reads it without passing that id reads this machine, where the thread does not exist.", "rule": "The thread chip names its environmentId; t3_thread_read takes it.", "privacy": "commit-safe", "setup": {"role": "top", "linkedEnvironments": true, "mocks": {"t3_environment_links": {"environmentId": "environment-laptop", "links": [{"environmentId": "environment-vps", "label": "Hetzner VPS", "status": "reachable", "access": "auto-accept-edits", "expiresAt": "2026-12-01T00:00:00.000Z", "lastError": null}], "notListed": "Not listed: not linked yet. Call t3_environment_link with a hint from the user's words."}, "t3_thread_read": {"thread": {"threadId": "thread-deploy-api", "title": "Deploy the API", "status": "idle"}, "items": [{"position": 1, "createdBy": "user", "text": "Deploy the API to the VPS and check the health endpoint."}, {"position": 2, "createdBy": "assistant", "text": "Deployed. /health returns 200, but /health/db times out after 5s: the API can't reach Postgres on 10.0.0.5:5432."}], "nextPosition": 3, "hasMore": false}}}, "prompt": "Pick up where [Deploy the API](t3-context://v1/thread/thread_deploy-api) left off. What is still failing?", "promptContext": {"records": [{"version": 1, "kind": "thread", "contextId": "thread_deploy-api", "label": "Deploy the API", "environmentId": "environment-vps", "threadId": "thread-deploy-api", "title": "Deploy the API"}]}, "expect": {"calls": [{"tool": "t3_thread_read", "args": {"threadId": "thread-deploy-api", "environmentId": "environment-vps"}}], "notCalls": ["t3_environment_link"]}, "source": {"request": "akj on #17130: attach a linked machine's thread without dragging"}} {"id": "thread-send-fresh-context", "area": "orchestration", "kind": "guard", "pattern": "t3_thread_send that should start a fresh provider session", "experience": "The user wanted a confused long-running thread to start over in the same thread without its old context; a plain send would keep feeding it the history.", "rule": "`freshContext: true` on t3_thread_send starts a new provider session in the same thread, like /clear.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "The thread working on the CSV importer keeps reverting to the old column mapping from early in its history."}, {"role": "assistant", "text": "That thread has a long history with three different mappings in it, so the model keeps picking up the stale one."}], "mocks": {"t3_thread_send": {"threadId": "11111111-1111-4111-8111-111111111111", "link": "", "messageId": "msg-1", "runId": "run-1", "status": "starting", "delivery": "started"}}}, "prompt": "Send it the final mapping below and have it start over with a clean context in that same thread, so the old history stops leaking in: thread 11111111-1111-4111-8111-111111111111.\n\nFinal mapping: id -> customer_id, amt -> amount_cents, ts -> created_at.", "expect": {"calls": [{"tool": "t3_thread_send", "args": {"threadId": "11111111-1111-4111-8111-111111111111", "freshContext": true}}], "notCalls": ["delegate_task", "t3_thread_launch", "create_threads"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-11-03: fresh provider sessions in one T3 thread"}, "notes": "Pairs with thread-send-to-named-top-level-thread, where an ordinary send must not set freshContext."} +{"id":"move-own-full-access-child-to-another-account","area":"orchestration","kind":"guard","pattern":"t3_thread_configure then t3_thread_send on own child above the parent's runtime mode","experience":"An Auto parent asked to move its Full-access child off an exhausted account and resume it was refused, so the user started replacements that lost the old context.","source":{"issue":"https://github.com/pingdotgg/t3code/issues/30408"},"rule":"A parent may configure and send to its own delegated child at the child's existing runtime mode.","privacy":"commit-safe","setup":{"role":"top","priorTurns":[{"role":"user","text":"Have an Opus delegate with full access audit the database migrations and fix what it finds."},{"role":"assistant","text":"An Opus delegate on claude-a is auditing the migrations with full access.","toolCalls":[{"tool":"orchestrator_capabilities","input":{},"result":{"parentThreadId":"33333333-3333-4333-8333-333333333333","inheritedProviderInstanceId":"claude-a","inheritedModel":"claude-opus-5-5","inheritedOptions":[{"id":"effort","value":"high"}],"runtimeMode":"full-access","interactionMode":"default","features":{"appOwnedSubagents":true,"asyncPolling":true,"cancellation":true,"batchThreadCreation":true,"threadManagement":true,"incrementalThreadRead":true,"scheduledTasks":true},"providers":[{"providerInstanceId":"claude-a","driverKind":"claudeAgent","displayName":"Claude (work)","models":[{"id":"claude-opus-5-5","label":"Claude Opus 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]}]},{"providerInstanceId":"claude-b","driverKind":"claudeAgent","displayName":"Claude (personal)","models":[{"id":"claude-opus-5-5","label":"Claude Opus 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]}]}]}},{"tool":"delegate_task","input":{"title":"Audit migrations","mode":"async","clientRequestId":"migration-audit","runtimeMode":"full-access","target":{"providerInstanceId":"claude-a","model":"claude-opus-5-5"},"task":"Audit db/migrations for unsafe changes and fix them."},"result":{"taskId":"task-1","childThreadId":"44444444-4444-4444-8444-000000000001","childRunId":"run-child-1","status":"running","workState":"working","providerInstanceId":"claude-a","model":"claude-opus-5-5"}}]},{"role":"user","text":"I switched this thread to Auto for the rest of the session."},{"role":"assistant","text":"Understood, I'm in Auto now."},{"role":"assistant","text":"The migration audit stopped.","toolCalls":[{"tool":"task_status","input":{"taskId":"task-1"},"result":{"taskId":"task-1","childThreadId":"44444444-4444-4444-8444-000000000001","status":"failed","workState":"result_available","providerInstanceId":"claude-a","model":"claude-opus-5-5","summary":"Claude usage limit reached for this account. Resets in 4 days."}}]}],"mocks":{"orchestrator_capabilities":{"parentThreadId":"33333333-3333-4333-8333-333333333333","inheritedProviderInstanceId":"claude-a","inheritedModel":"claude-opus-5-5","inheritedOptions":[{"id":"effort","value":"high"}],"runtimeMode":"auto","interactionMode":"default","features":{"appOwnedSubagents":true,"asyncPolling":true,"cancellation":true,"batchThreadCreation":true,"threadManagement":true,"incrementalThreadRead":true,"scheduledTasks":true},"providers":[{"providerInstanceId":"claude-a","driverKind":"claudeAgent","displayName":"Claude (work)","models":[{"id":"claude-opus-5-5","label":"Claude Opus 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]}]},{"providerInstanceId":"claude-b","driverKind":"claudeAgent","displayName":"Claude (personal)","models":[{"id":"claude-opus-5-5","label":"Claude Opus 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]}]}]},"t3_thread_configure":{"threadId":"44444444-4444-4444-8444-000000000001","sequence":42,"modelSelection":{"instanceId":"claude-b","model":"claude-opus-5-5"},"runtimeMode":"full-access","interactionMode":"default"},"t3_thread_send":{"threadId":"44444444-4444-4444-8444-000000000001","messageId":"msg-1","runId":"run-child-2","taskId":"task-2","status":"running"},"task_status":{"taskId":"task-1","status":"failed","workState":"result_available"},"t3_thread_read":{"thread":{"threadId":"44444444-4444-4444-8444-000000000001","title":"Audit migrations","status":"failed","link":""},"items":[{"position":1,"createdBy":"user","text":"Audit db/migrations for unsafe changes and fix them."},{"position":2,"createdBy":"assistant","text":"Fixed 0007 (added a default before NOT NULL). Reviewing 0008 to 0012 next."},{"position":3,"createdBy":"system","text":"Claude usage limit reached for this account. Resets in 4 days."}],"nextPosition":4,"hasMore":false}}},"prompt":"claude-a is out for the week. Don't start a new delegate, it would lose what it has done. Move that same audit delegate to claude-b and tell it to carry on.","expect":{"calls":[{"tool":"t3_thread_configure","args":{"threadId":"44444444-4444-4444-8444-000000000001","modelSelection.instanceId":"claude-b"}},{"tool":"t3_thread_send","args":{"threadId":"44444444-4444-4444-8444-000000000001"}}],"order":["t3_thread_configure","t3_thread_send"],"notCalls":["delegate_task","t3_thread_launch"]},"notes":"Guards the send and attachment descriptions' exception for a child's runtime mode. Passed on the old text too; the refusal was the server's. Choosing configure-and-resume unasked belongs with the usage-limit guidance."} From 9f94e032512e177296406d9824e4d7f04db9677b Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:29:48 +0000 Subject: [PATCH 08/12] test(server): provide the own-child test layers once; drop the eval guard The guard case passed on the old tool text too, so it does not meet the eval rule that a case fail before the change. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/core.test.ts | 15 +++++++++------ evals/mcp-tool-use/cases/orchestration.ndjson | 1 - 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index d29f1ddfe3c0d..c3122ccd745d9 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1039,13 +1039,16 @@ describe("a thread's own delegated child above its modes", () => { }); expect(child.runs.at(-1)?.modelSelection.instanceId).toBe(accountB); }).pipe( - Effect.provide(layerToolkits), Effect.provide( - Layer.mergeAll( - layerOrchestrator, - layerThreads, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-own-child-" }).pipe( - Layer.provideMerge(NodeServices.layer), + layerToolkits.pipe( + Layer.provideMerge( + Layer.mergeAll( + layerOrchestrator, + layerThreads, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-own-child-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ), ), ), ), diff --git a/evals/mcp-tool-use/cases/orchestration.ndjson b/evals/mcp-tool-use/cases/orchestration.ndjson index 5b4e5517ed193..41eb03e0939fc 100644 --- a/evals/mcp-tool-use/cases/orchestration.ndjson +++ b/evals/mcp-tool-use/cases/orchestration.ndjson @@ -35,4 +35,3 @@ {"id": "worker-counts-per-model", "area": "orchestration", "kind": "bad", "pattern": "named-model rule read as a cap", "experience": "Asked for 20 Sol workers and 20 Luna workers, the agent started one worker per model (#25359).", "rule": "Before delegate_task ... One child per named model. (intended: every named model gets a child; read as a cap of one child per model)", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}, "world": {"files": {"tests/flaky/retry.test.ts": "it('retry is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/upload.test.ts": "it('upload is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/session.test.ts": "it('session is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/search.test.ts": "it('search is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/billing.test.ts": "it('billing is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/webhook.test.ts": "it('webhook is flaky', async () => { /* fails 1 in 5 runs */ });\n"}}}, "prompt": "Six tests in tests/flaky/ fail now and then. Start 3 Muse Wonder Full workers and 3 Grok workers, one test file each, to find and fix the flake.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "muse-wonder-full"}}, {"tool": "delegate_task", "args": {"target.model": "grok-4.7"}}], "minCalls": {"delegate_task": 6}}, "notes": "Scaled down from the real 20 Sol + 20 Luna ask. Muse and Grok need delegate_task from every parent, so a native Sol helper cannot pass or fail it."} {"id": "linked-thread-chip-reads-there", "area": "orchestration", "kind": "guard", "pattern": "thread chip from a linked machine read on this one", "experience": "A thread chip from a linked machine names its environmentId; an agent that reads it without passing that id reads this machine, where the thread does not exist.", "rule": "The thread chip names its environmentId; t3_thread_read takes it.", "privacy": "commit-safe", "setup": {"role": "top", "linkedEnvironments": true, "mocks": {"t3_environment_links": {"environmentId": "environment-laptop", "links": [{"environmentId": "environment-vps", "label": "Hetzner VPS", "status": "reachable", "access": "auto-accept-edits", "expiresAt": "2026-12-01T00:00:00.000Z", "lastError": null}], "notListed": "Not listed: not linked yet. Call t3_environment_link with a hint from the user's words."}, "t3_thread_read": {"thread": {"threadId": "thread-deploy-api", "title": "Deploy the API", "status": "idle"}, "items": [{"position": 1, "createdBy": "user", "text": "Deploy the API to the VPS and check the health endpoint."}, {"position": 2, "createdBy": "assistant", "text": "Deployed. /health returns 200, but /health/db times out after 5s: the API can't reach Postgres on 10.0.0.5:5432."}], "nextPosition": 3, "hasMore": false}}}, "prompt": "Pick up where [Deploy the API](t3-context://v1/thread/thread_deploy-api) left off. What is still failing?", "promptContext": {"records": [{"version": 1, "kind": "thread", "contextId": "thread_deploy-api", "label": "Deploy the API", "environmentId": "environment-vps", "threadId": "thread-deploy-api", "title": "Deploy the API"}]}, "expect": {"calls": [{"tool": "t3_thread_read", "args": {"threadId": "thread-deploy-api", "environmentId": "environment-vps"}}], "notCalls": ["t3_environment_link"]}, "source": {"request": "akj on #17130: attach a linked machine's thread without dragging"}} {"id": "thread-send-fresh-context", "area": "orchestration", "kind": "guard", "pattern": "t3_thread_send that should start a fresh provider session", "experience": "The user wanted a confused long-running thread to start over in the same thread without its old context; a plain send would keep feeding it the history.", "rule": "`freshContext: true` on t3_thread_send starts a new provider session in the same thread, like /clear.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "The thread working on the CSV importer keeps reverting to the old column mapping from early in its history."}, {"role": "assistant", "text": "That thread has a long history with three different mappings in it, so the model keeps picking up the stale one."}], "mocks": {"t3_thread_send": {"threadId": "11111111-1111-4111-8111-111111111111", "link": "", "messageId": "msg-1", "runId": "run-1", "status": "starting", "delivery": "started"}}}, "prompt": "Send it the final mapping below and have it start over with a clean context in that same thread, so the old history stops leaking in: thread 11111111-1111-4111-8111-111111111111.\n\nFinal mapping: id -> customer_id, amt -> amount_cents, ts -> created_at.", "expect": {"calls": [{"tool": "t3_thread_send", "args": {"threadId": "11111111-1111-4111-8111-111111111111", "freshContext": true}}], "notCalls": ["delegate_task", "t3_thread_launch", "create_threads"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-11-03: fresh provider sessions in one T3 thread"}, "notes": "Pairs with thread-send-to-named-top-level-thread, where an ordinary send must not set freshContext."} -{"id":"move-own-full-access-child-to-another-account","area":"orchestration","kind":"guard","pattern":"t3_thread_configure then t3_thread_send on own child above the parent's runtime mode","experience":"An Auto parent asked to move its Full-access child off an exhausted account and resume it was refused, so the user started replacements that lost the old context.","source":{"issue":"https://github.com/pingdotgg/t3code/issues/30408"},"rule":"A parent may configure and send to its own delegated child at the child's existing runtime mode.","privacy":"commit-safe","setup":{"role":"top","priorTurns":[{"role":"user","text":"Have an Opus delegate with full access audit the database migrations and fix what it finds."},{"role":"assistant","text":"An Opus delegate on claude-a is auditing the migrations with full access.","toolCalls":[{"tool":"orchestrator_capabilities","input":{},"result":{"parentThreadId":"33333333-3333-4333-8333-333333333333","inheritedProviderInstanceId":"claude-a","inheritedModel":"claude-opus-5-5","inheritedOptions":[{"id":"effort","value":"high"}],"runtimeMode":"full-access","interactionMode":"default","features":{"appOwnedSubagents":true,"asyncPolling":true,"cancellation":true,"batchThreadCreation":true,"threadManagement":true,"incrementalThreadRead":true,"scheduledTasks":true},"providers":[{"providerInstanceId":"claude-a","driverKind":"claudeAgent","displayName":"Claude (work)","models":[{"id":"claude-opus-5-5","label":"Claude Opus 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]}]},{"providerInstanceId":"claude-b","driverKind":"claudeAgent","displayName":"Claude (personal)","models":[{"id":"claude-opus-5-5","label":"Claude Opus 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]}]}]}},{"tool":"delegate_task","input":{"title":"Audit migrations","mode":"async","clientRequestId":"migration-audit","runtimeMode":"full-access","target":{"providerInstanceId":"claude-a","model":"claude-opus-5-5"},"task":"Audit db/migrations for unsafe changes and fix them."},"result":{"taskId":"task-1","childThreadId":"44444444-4444-4444-8444-000000000001","childRunId":"run-child-1","status":"running","workState":"working","providerInstanceId":"claude-a","model":"claude-opus-5-5"}}]},{"role":"user","text":"I switched this thread to Auto for the rest of the session."},{"role":"assistant","text":"Understood, I'm in Auto now."},{"role":"assistant","text":"The migration audit stopped.","toolCalls":[{"tool":"task_status","input":{"taskId":"task-1"},"result":{"taskId":"task-1","childThreadId":"44444444-4444-4444-8444-000000000001","status":"failed","workState":"result_available","providerInstanceId":"claude-a","model":"claude-opus-5-5","summary":"Claude usage limit reached for this account. Resets in 4 days."}}]}],"mocks":{"orchestrator_capabilities":{"parentThreadId":"33333333-3333-4333-8333-333333333333","inheritedProviderInstanceId":"claude-a","inheritedModel":"claude-opus-5-5","inheritedOptions":[{"id":"effort","value":"high"}],"runtimeMode":"auto","interactionMode":"default","features":{"appOwnedSubagents":true,"asyncPolling":true,"cancellation":true,"batchThreadCreation":true,"threadManagement":true,"incrementalThreadRead":true,"scheduledTasks":true},"providers":[{"providerInstanceId":"claude-a","driverKind":"claudeAgent","displayName":"Claude (work)","models":[{"id":"claude-opus-5-5","label":"Claude Opus 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]}]},{"providerInstanceId":"claude-b","driverKind":"claudeAgent","displayName":"Claude (personal)","models":[{"id":"claude-opus-5-5","label":"Claude Opus 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]}]}]},"t3_thread_configure":{"threadId":"44444444-4444-4444-8444-000000000001","sequence":42,"modelSelection":{"instanceId":"claude-b","model":"claude-opus-5-5"},"runtimeMode":"full-access","interactionMode":"default"},"t3_thread_send":{"threadId":"44444444-4444-4444-8444-000000000001","messageId":"msg-1","runId":"run-child-2","taskId":"task-2","status":"running"},"task_status":{"taskId":"task-1","status":"failed","workState":"result_available"},"t3_thread_read":{"thread":{"threadId":"44444444-4444-4444-8444-000000000001","title":"Audit migrations","status":"failed","link":""},"items":[{"position":1,"createdBy":"user","text":"Audit db/migrations for unsafe changes and fix them."},{"position":2,"createdBy":"assistant","text":"Fixed 0007 (added a default before NOT NULL). Reviewing 0008 to 0012 next."},{"position":3,"createdBy":"system","text":"Claude usage limit reached for this account. Resets in 4 days."}],"nextPosition":4,"hasMore":false}}},"prompt":"claude-a is out for the week. Don't start a new delegate, it would lose what it has done. Move that same audit delegate to claude-b and tell it to carry on.","expect":{"calls":[{"tool":"t3_thread_configure","args":{"threadId":"44444444-4444-4444-8444-000000000001","modelSelection.instanceId":"claude-b"}},{"tool":"t3_thread_send","args":{"threadId":"44444444-4444-4444-8444-000000000001"}}],"order":["t3_thread_configure","t3_thread_send"],"notCalls":["delegate_task","t3_thread_launch"]},"notes":"Guards the send and attachment descriptions' exception for a child's runtime mode. Passed on the old text too; the refusal was the server's. Choosing configure-and-resume unasked belongs with the usage-limit guidance."} From 9d216c08aa5affd3a65369bb50ab2b7e2ea24097 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:23:25 +0000 Subject: [PATCH 09/12] fix(server): keep the send descriptions within main's context budget Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/attachment/tools.ts | 2 +- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 2 +- apps/server/src/provider/agentContextBudget.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts index 6bbcaa3190467..98b3a9f3cfcf0 100644 --- a/apps/server/src/mcp/toolkits/attachment/tools.ts +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -48,7 +48,7 @@ const AttachmentSendTool = Tool.make("t3_thread_send_attachments", { ...shared, dependencies: [...shared.dependencies, DocumentText.DocumentText], description: - "Send uploaded attachments as a new message to a thread. No retry key. Target modes cannot exceed yours, except your child's runtime mode. Success does not mean the provider read them or finished.", + "Send uploaded attachments to a thread within your modes, or your child at its runtime mode. No retry key. Success does not mean the provider read them or finished.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 80485b2fa2f31..a0f75a69d8efb 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -270,7 +270,7 @@ const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send a message to any thread. Target modes cannot exceed yours, except your child's runtime mode. `mode:'auto'` starts an idle thread, steers a running turn, or queues. `queue`: new turn. `steer`: mid-turn update. `restart`: interrupt and restart. A send to a finished child thread starts a fresh task and returns its `taskId`. In your reply, name it as its `link` verbatim.", + "Send to any thread within your modes, or your child at its runtime mode. `mode:'auto'` starts an idle thread, steers a running turn, or queues. `queue`: new turn. `steer`: mid-turn update. `restart`: interrupt and restart. A send to a finished child thread starts a fresh task and returns its `taskId`. In your reply, name it as its `link` verbatim.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index e5ac190700caf..4df8b04b02965 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5366, "codexAdditionalContextBytes": 7096, - "mcpToolListJsonBytes": 48168 + "mcpToolListJsonBytes": 48111 } From 76ab2da000e507cfaa07f12d0adc1a2a031e152a Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:49:47 +0000 Subject: [PATCH 10/12] fix(server): parents keep acting on their own children through t3_thread_send's forwarding wrapper Main wrapped t3_thread_send's writesThreads in afterForwarding, and the merge left the ownChildren option on the outer call, where it was dropped. Pass it to the inner writesThreads again. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/orchestrator/handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 639bd8b2a0002..f18b26688ec39 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -179,6 +179,7 @@ const handlers = { const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.sendToThread(scope, input); }), + { ownChildren: "at-their-runtime-mode" }, ), // There the thread's parent is here, so only this side can make a follow-up a task. (input, environmentId, sent) => @@ -187,7 +188,6 @@ const handlers = { const remote = yield* RemoteDelegation.RemoteDelegation; return yield* remote.followUp(scope, environmentId, input, sent); }), - { ownChildren: "at-their-runtime-mode" }, ), t3_thread_wait: McpToolAccess.reads((input) => Effect.gen(function* () { From 0f74b78f22b9391ac8df5c344b1a9fa10340b41a Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 15 Nov 2026 12:23:43 +0000 Subject: [PATCH 11/12] fix(server): shrink the own-child recovery to one shared limit check A thread's own delegated child is checked at the child's runtime mode by one helper, used by the tool declaration and by the send service. That drops the extra dispatch-limit field, the tool description edits and the context budget change, and replaces two test files' cases with one table on the registered tools. Co-Authored-By: Claude Fable 5.2 --- apps/server/src/mcp/McpToolAccess.test.ts | 59 ---- apps/server/src/mcp/McpToolAccess.ts | 50 ++-- apps/server/src/mcp/OrchestratorMcpService.ts | 24 +- apps/server/src/mcp/threadAccess.ts | 18 +- .../src/mcp/toolkits/attachment/tools.ts | 2 +- apps/server/src/mcp/toolkits/core.test.ts | 279 +++++++----------- .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/mcp/toolkits/thread/handlers.ts | 42 ++- .../src/orchestration-v2/DispatchModeLimit.ts | 2 - .../src/provider/agentContextBudget.json | 2 +- 10 files changed, 164 insertions(+), 316 deletions(-) diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 58f20a940a46e..6cfd86bd3d836 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -17,7 +17,6 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; -import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; import * as PeerForwarding from "../peer/PeerForwarding.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; @@ -34,22 +33,6 @@ const backgroundThreadId = ThreadId.make("thread:background"); const oldProviderThreadId = ThreadId.make("thread:old-provider-background"); const archivedThreadId = ThreadId.make("thread:archived-background"); const unknownOwnerThreadId = ThreadId.make("thread:unknown-background-owner"); -const ownChildThreadId = ThreadId.make("thread:own-child"); -const forgedChildThreadId = ThreadId.make("thread:forged-child"); -const planChildThreadId = ThreadId.make("thread:plan-child"); - -const childShell = (threadId: ThreadId, parentThreadId: ThreadId) => ({ - ...liveThreadShell(threadId), - lineage: { - rootThreadId: parentThreadId, - parentThreadId, - relationshipToParent: "subagent" as const, - }, -}); -const appOwnedChildren = new Map([ - [supervisedThreadId, [ownChildThreadId]], - [planThreadId, [planChildThreadId]], -]); const backgroundShell = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => ({ ...liveThreadShell(threadId, { activeRunId: null }), @@ -82,10 +65,6 @@ const shells = new Map([ pendingBackgroundTasks: [{ taskId: "task:unknown", kind: "subagent" as const }], }, ], - [ownChildThreadId, childShell(ownChildThreadId, supervisedThreadId)], - // Says it is the supervised thread's child, but that thread never delegated it. - [forgedChildThreadId, childShell(forgedChildThreadId, supervisedThreadId)], - [planChildThreadId, childShell(planChildThreadId, planThreadId)], ]); const threadCaller = (threadId: ThreadId): McpInvocationContext.McpInvocationScope => ({ @@ -150,10 +129,6 @@ const ProbeToolkit = Toolkit.make( ...probe, parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), }), - Tool.make("writes_own_children", { - ...probe, - parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), - }), Tool.make("starts_threads", { ...probe, parameters: Schema.Struct({ @@ -174,16 +149,6 @@ const probeHandlers: McpToolAccess.Handlers = { (input) => [input.threadId], () => ran, ), - // Answers with the modes the orchestrator rechecks under its lock. - writes_own_children: McpToolAccess.writesThreads( - (input) => [input.threadId], - () => - Effect.gen(function* () { - const limit = yield* DispatchModeLimit; - return { ran: `${limit?.runtimeMode}/${limit?.interactionMode}` }; - }), - { ownChildren: "at-their-runtime-mode" }, - ), starts_threads: McpToolAccess.startsThreads( (input) => input, (_, modes) => Effect.succeed({ ran: `${modes.runtimeMode}/${modes.interactionMode}` }), @@ -227,7 +192,6 @@ export const refusedAtCompileTime = () => { writes: unchecked, writes_with_capability: unchecked, writes_threads: unchecked, - writes_own_children: unchecked, starts_threads: unchecked, writes_environment: unchecked, }), @@ -342,27 +306,6 @@ it.effect.each([ ], ["writes_threads", supervised, { threadId: "thread:missing" }, "ran"], - // A thread moves and resumes its own delegated children at the runtime mode they already have. - ["writes_own_children", supervised, { threadId: ownChildThreadId }, "full-access/default"], - [ - "writes_own_children", - plan, - { threadId: planChildThreadId }, - "interaction_mode_escalation_denied", - ], - [ - "writes_own_children", - supervised, - { threadId: forgedChildThreadId }, - "runtime_mode_escalation_denied", - ], - [ - "writes_own_children", - supervisedClient, - { threadId: ownChildThreadId }, - "runtime_mode_escalation_denied", - ], - ["starts_threads", supervised, {}, "approval-required/default"], ["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"], ["starts_threads", plan, {}, "approval-required/plan"], @@ -385,8 +328,6 @@ it.effect.each([ probeServer( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: (threadId) => Effect.succeed(shells.get(threadId) ?? null), - listAppOwnedChildThreadIds: (threadId) => - Effect.succeed(appOwnedChildren.get(threadId) ?? []), }), ), ), diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 882a4201c2bb9..0a71cd8773573 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -25,17 +25,13 @@ import { import * as PeerForwarding from "../peer/PeerForwarding.ts"; import { assertNotLinked, assertSameLink, callerLinkOrigin } from "./linkOrigin.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; -import { - resolveInteractionMode, - resolveRuntimeMode, - runtimeModeRank, -} from "./delegatedTaskModes.ts"; +import { resolveInteractionMode, resolveRuntimeMode } from "./delegatedTaskModes.ts"; import { assertFullAccess, assertLiveCaller, assertTargetWithinLimits, type Caller, - isOwnDelegatedChild, + limitsForOwnChild, loadCaller, readCaller, unavailable, @@ -204,50 +200,38 @@ export const writesWithCapability = (handle: (params: P) => Effect.E export const writesThreads = ( threads: (params: P) => ReadonlyArray, handle: (params: P) => Effect.Effect, - options?: { - /** - * Whether the caller may act on its own delegated children whose runtime - * mode is above its own, at that mode, as moving one to another account - * and resuming it need. Interaction modes stay within the caller's. - */ - readonly ownChildren?: "at-their-runtime-mode"; - }, + /** `ownChildren` lets a thread reach a child it delegated that runs above its runtime mode. */ + options?: { readonly ownChildren?: "at-their-runtime-mode" }, ) => declare( (params: P) => Effect.gen(function* () { const caller = yield* writingCaller; - let runtimeMode = caller.limits.runtimeMode; + let dispatchLimits = caller.limits; for (const threadId of threads(params)) { if (threadId === undefined || threadId === caller.scope.thread?.threadId) continue; const target = yield* caller.threads .getThreadShell(threadId) .pipe(Effect.mapError(unavailable)); if (target !== null && target.deletedAt === null) { - const ownChild = - options?.ownChildren === "at-their-runtime-mode" && - runtimeModeRank(target.runtimeMode) > runtimeModeRank(caller.limits.runtimeMode) && - (yield* isOwnDelegatedChild(caller.threads, caller.caller?.id, target)); - if (ownChild && runtimeModeRank(target.runtimeMode) > runtimeModeRank(runtimeMode)) - runtimeMode = target.runtimeMode; - yield* assertTargetWithinLimits( - ownChild ? { ...caller.limits, runtimeMode } : caller.limits, - target, - ); + const limits = + options?.ownChildren === undefined + ? caller.limits + : yield* limitsForOwnChild( + caller.threads, + caller.caller?.id, + caller.limits, + target, + ); + yield* assertTargetWithinLimits(limits, target); + if (limits !== caller.limits) dispatchLimits = limits; yield* assertSameLink(caller, target); } } // Modes can rise after this check; the orchestrator rechecks under lock and records refusal before handlers wrap errors. const refused = yield* Ref.make(undefined); return yield* handle(params).pipe( - Effect.provideService(DispatchModeLimit, { - ...caller.limits, - runtimeMode, - ...(runtimeMode === caller.limits.runtimeMode - ? {} - : { ownChildRuntimeMode: runtimeMode }), - refused, - }), + Effect.provideService(DispatchModeLimit, { ...dispatchLimits, refused }), Effect.catch((error) => Effect.flatMap(Ref.get(refused), (refusal) => Effect.fail( diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index c0b2a1b5a1667..7932cceda2d3e 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -132,7 +132,12 @@ import { runtimeModeRank, taskPrompt, } from "./delegatedTaskModes.ts"; -import { assertTargetWithinLimits, isLiveCallerShell, threadNotFound } from "./threadAccess.ts"; +import { + assertTargetWithinLimits, + isLiveCallerShell, + limitsForOwnChild, + threadNotFound, +} from "./threadAccess.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as PeerForwarding from "../peer/PeerForwarding.ts"; import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; @@ -3387,19 +3392,12 @@ const make = Effect.gen(function* () { } satisfies OrchestratorMcpThreadReadResult; }), sendToThread: Effect.fn("OrchestratorMcpService.sendToThread")(function* (scope, input) { - const { - parent, - limits: callerLimits, - target, - } = yield* loadScopedThread(scope, input.threadId, ["runs"]); + const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId, ["runs"]); yield* assertLiveCallerForOtherThread(scope, parent, target); - // The declaration lifts only the runtime limit, and only to its own child's preflight mode. - const ownChildRuntimeMode = (yield* DispatchModeLimit)?.ownChildRuntimeMode; - const limits = - ownChildRuntimeMode === undefined - ? callerLimits - : { ...callerLimits, runtimeMode: ownChildRuntimeMode }; - yield* assertTargetWithinLimits(limits, target.thread); + yield* assertTargetWithinLimits( + yield* limitsForOwnChild(threadManagement, parent?.thread.id, limits, target.thread), + target.thread, + ); const mode = input.mode ?? "auto"; const key = yield* requestKey(input.clientRequestId); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 2dd45bbadc0fa..f3f694e077f71 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -4,6 +4,7 @@ import type { } from "../orchestration-v2/ProjectionStore.ts"; import { CommandId, + isRuntimeModeAbove, OrchestratorMcpFailure, type ProjectId, type ProviderInstanceId, @@ -158,22 +159,27 @@ export const assertTargetWithinLimits = ( ), ); -/** A thread the calling thread itself delegated here, as `delegate_task` records it on the caller. */ -export const isOwnDelegatedChild = Effect.fn("mcp.isOwnDelegatedChild")(function* ( +/** + * A thread acts on a child it delegated at the runtime mode that child already has, so it can + * move the child to another account and resume it after its own mode was lowered. + */ +export const limitsForOwnChild = Effect.fn("mcp.limitsForOwnChild")(function* ( threads: Pick, callerThreadId: ThreadId | undefined, - target: Pick, + limits: CallerLimits, + target: Pick, ) { if ( callerThreadId === undefined || + !isRuntimeModeAbove(target.runtimeMode, limits.runtimeMode) || target.lineage.parentThreadId !== callerThreadId || target.lineage.relationshipToParent !== "subagent" ) - return false; - const children = yield* threads + return limits; + const delegated = yield* threads .listAppOwnedChildThreadIds(callerThreadId) .pipe(Effect.mapError(unavailable)); - return children.includes(target.id); + return delegated.includes(target.id) ? { ...limits, runtimeMode: target.runtimeMode } : limits; }); /** Background subagents retain credentials until provider exit; archived threads and other instances cannot act. */ diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts index d727099e5f33f..59cecace84386 100644 --- a/apps/server/src/mcp/toolkits/attachment/tools.ts +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -51,7 +51,7 @@ const AttachmentSendTool = Tool.make("t3_thread_send_attachments", { ...shared, dependencies: [...shared.dependencies, DocumentText.DocumentText], description: - "Send uploaded attachments to a thread within your modes, or your child at its runtime mode. No retry key. Success does not mean the provider read them or finished.", + "Send uploaded attachments as a new message to a thread. No retry key. Target modes cannot exceed yours. Success does not mean the provider read them or finished.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 80be60fe5e45c..14789331e957a 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -2,13 +2,12 @@ import * as AssetSigner from "../../assets/AssetSigner.ts"; import * as DocumentText from "../../provider/DocumentText.ts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, ChatImageAttachment, CommandId, EnvironmentId, - MessageId, NodeId, OrchestratorMcpThreadReadResult, ProjectId, @@ -44,13 +43,8 @@ import { } from "../../orchestration-v2/Orchestrator.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; -import { CodexProviderCapabilitiesV2 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; -import * as CommandReceiptStore from "../../orchestration-v2/CommandReceiptStore.ts"; -import * as Orchestrator from "../../orchestration-v2/Orchestrator.ts"; -import * as ProviderReplayHarness from "../../orchestration-v2/testkit/ProviderReplayHarness.ts"; -import * as SqlitePersistence from "../../persistence/Sqlite.ts"; -import * as ProviderRegistryMock from "../../provider/testUtils/providerRegistryMock.ts"; import * as ServerConfig from "../../config.ts"; +import { DispatchModeLimit } from "../../orchestration-v2/DispatchModeLimit.ts"; import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; @@ -1269,181 +1263,110 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( ), ); -describe("a thread's own delegated child above its modes", () => { - const codex = ProviderDriverKind.make("codex"); - const accountA = ProviderInstanceId.make("account-a"); - const accountB = ProviderInstanceId.make("account-b"); - const parentId = ThreadId.make("thread:own-child-parent"); - const layerDatabase = SqlitePersistence.layerMemory; - const layerSettings = ServerSettings.layerTest({ - providerInstances: { - [accountA]: { driver: codex, enabled: true }, - [accountB]: { driver: codex, enabled: true }, - }, - }); - const layerAdapters = ProviderAdapterRegistry.layerFromAdapters( - [accountA, accountB].map((instanceId) => ({ - instanceId, - driver: codex, - getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), - planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), - openSession: () => Effect.die("No provider process runs in these tests"), - })), - ); - const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( - { name: "mcp-own-child" }, - layerAdapters, - { databaseLayer: layerDatabase, runEffectWorker: false }, - ); - const layerThreads = ThreadManagement.layer.pipe( - Layer.provide(layerOrchestrator), - Layer.provide(layerSettings), - Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), - ); - const layerProviders = ProviderRegistryMock.layer( - [accountA, accountB].map((instanceId) => ({ - instanceId, - driver: codex, - enabled: true, - installed: true, - version: "test", - status: "ready" as const, - auth: { status: "authenticated" as const }, - checkedAt: "2026-11-03T00:00:00.000Z", - models: [{ slug: "gpt-5", name: "gpt-5", isCustom: false, capabilities: null }], - slashCommands: [], - skills: [], - })), - ); - const layerToolkits = Layer.mergeAll(layerThreadToolkit, layerOrchestratorToolkit).pipe( - Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), - Layer.provide( - Layer.mergeAll( - layerSettings, - layerAdapters, - layerProviders, - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(RemoteDelegation.RemoteDelegation)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - Layer.mock(PeerForwarding.PeerForwarding)({}), - Layer.mock(PeerLinkRequests.PeerLinkRequests)({}), - Layer.mock(PeerLinks.PeerLinks)({}), - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ); - - const callAsParent = (name: string, args: Record) => - McpServer.McpServer.pipe( - Effect.flatMap((server) => server.callTool({ name, arguments: args })), - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...scope, - thread: { - threadId: parentId, - providerSessionId: "own-child", - providerInstanceId: accountA, - }, - }), - Effect.provideService(McpSchema.McpServerClient, client), +it.effect.each([ + ["t3_thread_configure", "default", true, "full-access/default"], + ["t3_thread_send", "default", true, "full-access/default"], + ["t3_thread_send_attachments", "default", true, "full-access/default"], + ["t3_thread_send", "default", false, "runtime_mode_escalation_denied"], + ["t3_thread_configure", "plan", true, "interaction_mode_escalation_denied"], +] as const)( + "%s from an Auto/%s parent to its Full-access child (delegated: %s) gives %s", + ([toolName, interactionMode, delegated, expected]) => { + const childId = ThreadId.make("mcp-core-own-child"); + const parent = McpToolAccessTestkit.liveThreadShell(threadId, { + runtimeMode: "auto", + interactionMode, + }); + const child = { + ...McpToolAccessTestkit.liveThreadShell(childId), + lineage: { + rootThreadId: threadId, + parentThreadId: threadId, + relationshipToParent: "subagent" as const, + }, + }; + const sent = { type: "image", id: "sent", name: "a.png", mimeType: "image/png", sizeBytes: 1 }; + // The limit the orchestrator rechecks the child against under its lock. + const dispatchLimit = DispatchModeLimit.pipe( + Effect.map((limit) => `${limit?.runtimeMode}/${limit?.interactionMode}`), ); - - it.effect( - "an Auto parent moves its stopped Full-access child to another account and resumes it", - () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - let commands = 0; - const commandId = () => CommandId.make(`own-child:${commands++}`); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: commandId(), - threadId: parentId, - projectId: ProjectId.make("project:own-child"), - title: "parent", - modelSelection: { instanceId: accountA, model: "gpt-5" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdBy: "user", - creationSource: "web", - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: commandId(), - threadId: parentId, - messageId: MessageId.make("message:own-child-parent"), - text: "Coordinate the child.", - attachments: [], - dispatchMode: { type: "start_immediately" }, - createdBy: "user", - creationSource: "web", - }); - const run = (yield* orchestrator.getThreadProjection(parentId)).runs.at(-1)!; - yield* orchestrator.dispatch({ - type: "delegated_task.request", - commandId: commandId(), - parentThreadId: parentId, - parentRunId: run.id, - parentNodeId: run.rootNodeId!, - task: "Keep the delegated context.", - modelSelection: { instanceId: accountA, model: "gpt-5" }, - runtimeMode: "full-access", - interactionMode: "default", - createdBy: "agent", - creationSource: "mcp", - }); - const childId = (yield* orchestrator.getThreadProjection(parentId)).subagents[0]! - .childThreadId!; - yield* orchestrator.dispatch({ - type: "thread.stop", - commandId: commandId(), - threadId: childId, - reason: "Account A reached its usage limit.", - }); - yield* orchestrator.dispatch({ - type: "thread.runtime-mode.set", - commandId: commandId(), - threadId: parentId, - runtimeMode: "auto", - }); - - const moved = yield* callAsParent("t3_thread_configure", { - threadId: childId, - modelSelection: { instanceId: accountB, model: "gpt-5" }, - }); - expect(moved.isError).toBe(false); - const resumed = yield* callAsParent("t3_thread_send", { - threadId: childId, - message: "Go.", - }); - expect(resumed.isError).toBe(false); - const child = yield* orchestrator.getThreadProjection(childId); - expect(child.thread).toMatchObject({ - providerInstanceId: accountB, - runtimeMode: "full-access", - interactionMode: "default", - }); - expect(child.runs.at(-1)?.modelSelection.instanceId).toBe(accountB); - }).pipe( - Effect.provide( - layerToolkits.pipe( - Layer.provideMerge( - Layer.mergeAll( - layerOrchestrator, - layerThreads, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-own-child-" }).pipe( - Layer.provideMerge(NodeServices.layer), - ), - ), + let limit: string | undefined; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: toolName, + arguments: { + threadId: childId, + ...(toolName === "t3_thread_configure" + ? { modelSelection: { instanceId: "codex-b", model: "gpt-5" } } + : toolName === "t3_thread_send" + ? { message: "Go." } + : { attachments: [sent] }), + }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(declaredFailure(result)?.code ?? limit).toBe(expected); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerThreadToolkit, + layerOrchestratorToolkit, + McpHttpServer.layerAttachmentToolkit, + ).pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(id === childId ? child : parent), + getThreadRecords: () => + Effect.succeed(McpToolAccessTestkit.idleThreadProjection(parent) as never), + getProjectThreadRecords: () => + Effect.succeed({ + ...McpToolAccessTestkit.idleThreadProjection(child), + messages: [{ attachments: [sent] }], + } as never), + listAppOwnedChildThreadIds: () => Effect.succeed(delegated ? [childId] : []), + dispatch: () => + dispatchLimit.pipe( + Effect.map((seen) => { + limit = seen; + return { sequence: 1, storedEvents: [] }; + }), + ), + sendToThread: () => + dispatchLimit.pipe( + Effect.map((seen) => { + limit = seen; + return { + run: { id: RunId.make("run:own-child"), status: "running" }, + message: { attachments: [] }, + delivery: "started", + } as never; + }), + ), + }), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(DocumentText.DocumentText)({}), + AssetSigner.layer.pipe(Layer.provide(ServerSecretStore.layer)), ), ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-own-child-" })), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), - ); -}); + ); + }, +); it.effect("discovers workflow stop targets and requires orchestration capability", () => { const threadId = ThreadId.make("workflow-parent"); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 5c925ea56ac1d..629a5352587f0 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -283,7 +283,7 @@ const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send to any thread within your modes, or your child at its runtime mode. `mode:'auto'` starts an idle thread, steers a running turn, or queues. `queue`: new turn. `steer`: mid-turn update. `restart`: interrupt and restart. A send to a finished child thread starts a fresh task and returns its `taskId`. In your reply, name it as its `link` verbatim.", + "Send a message to any thread. The target cannot run with broader modes than yours. `mode:'auto'` starts an idle thread, steers a running turn, or queues. `queue`: new turn. `steer`: mid-turn update. `restart`: interrupt and restart. A send to a finished child thread starts a fresh task and returns its `taskId`. In your reply, name it as its `link` verbatim.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 900459576a852..a0a1b0ff79500 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -104,8 +104,13 @@ const required = (value: A | undefined, name: string) => : Effect.succeed(value); const writesThread =

( handle: (params: P) => Effect.Effect, - options?: Parameters[2], -) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle, options); +) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); +const writesOwnChildThread =

( + handle: (params: P) => Effect.Effect, +) => + McpToolAccess.writesThreads((params: P) => [params.threadId], handle, { + ownChildren: "at-their-runtime-mode", + }); export const layer = McpToolAccess.toLayer(ThreadToolkit, { run_scheduled_task_now: McpToolAccess.writesEnvironment((input) => @@ -230,26 +235,19 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { }; }), ), - writesThread( - (input) => - Effect.gen(function* () { - const modelSelection = yield* required(input.modelSelection, "modelSelection"); - const { - threads, - projection: { thread }, - } = yield* readThread(input.threadId); - const type = modelSelectionCommandType(thread.providerInstanceId, modelSelection); - const result = yield* threads - .dispatch({ - type, - threadId: thread.id, - commandId: yield* newCommandId(), - modelSelection, - }) - .pipe(Effect.mapError(dispatchFailure)); - return { sequence: result.sequence }; - }), - { ownChildren: "at-their-runtime-mode" }, + writesOwnChildThread((input) => + Effect.gen(function* () { + const modelSelection = yield* required(input.modelSelection, "modelSelection"); + const { + threads, + projection: { thread }, + } = yield* readThread(input.threadId); + const type = modelSelectionCommandType(thread.providerInstanceId, modelSelection); + const result = yield* threads + .dispatch({ type, threadId: thread.id, commandId: yield* newCommandId(), modelSelection }) + .pipe(Effect.mapError(dispatchFailure)); + return { sequence: result.sequence }; + }), ), ), t3_pending_request: McpToolAccess.either( diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.ts index 0dbeb5bd8d1bf..627806347c5ff 100644 --- a/apps/server/src/orchestration-v2/DispatchModeLimit.ts +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.ts @@ -21,8 +21,6 @@ export interface DispatchModeRefusal extends DispatchModes { /** Recheck target modes under the command lock; the user can raise them after preflight. */ export interface DispatchModeLimitValue extends DispatchModes { readonly refused?: Ref.Ref; - /** Set when `runtimeMode` was lifted above the caller's to its own delegated child's preflight mode. */ - readonly ownChildRuntimeMode?: RuntimeMode; } export const DispatchModeLimit = Context.Reference( diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index b0c95875d26d3..5b9a0efd649d9 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5930, "codexAdditionalContextBytes": 7324, - "mcpToolListJsonBytes": 49730 + "mcpToolListJsonBytes": 49738 } From 7a75744c58e1b7b41c940d7c67a7262536d365aa Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 15 Nov 2026 12:41:33 +0000 Subject: [PATCH 12/12] test(server): provide the peer and secret services the own-child table needs Co-Authored-By: Claude Fable 5.2 --- apps/server/src/mcp/toolkits/core.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 14789331e957a..de6c023421322 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1356,8 +1356,11 @@ it.effect.each([ Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(PeerForwarding.PeerForwarding)({}), + Layer.mock(PeerLinkRequests.PeerLinkRequests)({}), + Layer.mock(PeerLinks.PeerLinks)({}), Layer.mock(DocumentText.DocumentText)({}), - AssetSigner.layer.pipe(Layer.provide(ServerSecretStore.layer)), + AssetSigner.layer.pipe(Layer.provideMerge(ServerSecretStore.layer)), ), ), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-own-child-" })),