diff --git a/apps/server/src/features/agents/composition/__tests__/agent-service-container.test.ts b/apps/server/src/features/agents/composition/__tests__/agent-service-container.test.ts index 8a3f876c9..cf300a612 100644 --- a/apps/server/src/features/agents/composition/__tests__/agent-service-container.test.ts +++ b/apps/server/src/features/agents/composition/__tests__/agent-service-container.test.ts @@ -34,6 +34,7 @@ import { PlanTurnService } from "../../planning/plan-turn-service.js"; import { PlanRepo } from "../../planning/persistence/plan-repo.js"; import { ProviderTurnEventApplication } from "../../turns/provider-turn-event-application.js"; import { ProviderAvailabilityService } from "../../../providers/availability/provider-availability-service.js"; +import { McodeSessionAuthority } from "../../../mcode-mcp/mcode-session-authority.js"; import type { WorkerOwnedProviderEventResult } from "../../../providers/composition/provider-host-ports.js"; import { addClient, removeClient, subscribeClientToThread } from "../../../../application/transport/push.js"; @@ -206,7 +207,7 @@ describe("AgentService container composition", () => { }); }); - it("admits a provider-started turn with its opening notice and releases it after Ended", async () => { + it("admits a provider-started turn with its opening notice and active lease, then releases it after Ended", async () => { const sendTurn = vi.fn(async (request: TurnRequest) => { await submitCodexEvent(workerRuntime!, request, 1, { type: "turnComplete", threadId: request.threadId, turnExecutionId: request.turnExecutionId, providerId: "codex", reason: "end_turn", costUsd: null, @@ -222,8 +223,19 @@ describe("AgentService container composition", () => { await container.resolve(AgentService).sendMessage({ threadId: thread.id, content: "start a background task", provider: "codex", model: "gpt-5.5", permissionMode: "full" }); await waitFor(() => workerRuntime?.owner.current(thread.id) === undefined); + const authority = container.resolve(McodeSessionAuthority); + const { token } = authority.attach({ providerId: "codex", sessionId: `mcode-${thread.id}`, threadId: thread.id, threadControl: true }); + const session = authority.resolve(token); + if (!session) throw new Error("Provider session was not attached"); + expect(() => authority.authorizeThreadControl(session, "before-opening")) + .toThrow(expect.objectContaining({ reason: "no_active_turn" })); + const opening = await container.resolve(TurnRuntimeController).openProviderTurn({ threadId: thread.id, notice: "Background command finished" }); if (opening.kind !== "opened") throw new Error(`Provider turn was declined: ${opening.reason}`); + expect(authority.authorizeThreadControl(session, "provider-continuation")).toMatchObject({ + sourceThreadId: thread.id, + sourceTurnId: opening.routing.turnId, + }); const adopted = { threadId: opening.routing.threadId, turnId: opening.routing.turnId, turnExecutionId: opening.routing.executionId } as TurnRequest; await submitCodexEvent(workerRuntime!, adopted, 1, { type: "textDelta", threadId: thread.id, turnExecutionId: adopted.turnExecutionId, delta: "the task finished", isFinalResponse: true }); @@ -239,6 +251,8 @@ describe("AgentService container composition", () => { expect(notice).toMatchObject({ role: "user", systemNotice: { kind: "provider-turn" } }); expect(container.resolve(TurnRuntimeController).snapshot(thread.id)).toMatchObject({ phase: "completed", turnExecutionId: adopted.turnExecutionId }); expect(container.resolve(ThreadRepo).findById(thread.id)?.model).toBe("gpt-5.5"); + expect(() => authority.authorizeThreadControl(session, "after-ended")) + .toThrow(expect.objectContaining({ reason: "no_active_turn" })); }); it("admits a provider-started turn that opens while the ended turn is still releasing", async () => { diff --git a/apps/server/src/features/agents/orchestration/__tests__/agent-service-gate.test.ts b/apps/server/src/features/agents/orchestration/__tests__/agent-service-gate.test.ts index 63bc63998..0aed63b67 100644 --- a/apps/server/src/features/agents/orchestration/__tests__/agent-service-gate.test.ts +++ b/apps/server/src/features/agents/orchestration/__tests__/agent-service-gate.test.ts @@ -87,6 +87,7 @@ function buildService({ assertUsable = vi.fn(), resolveProvider = vi.fn(), threadStatus = "idle", + threadPermissionMode = null, approvalReviewSupport, threadControlMcp = { activate: vi.fn(), @@ -96,10 +97,11 @@ function buildService({ assertUsable?: ReturnType; resolveProvider?: ReturnType; threadStatus?: PersistedThreadStatus; + threadPermissionMode?: Thread["permission_mode"]; approvalReviewSupport?: ApprovalReviewSupport; threadControlMcp?: { activate: ReturnType; revoke: ReturnType }; } = {}) { - const thread = makeThread({ status: threadStatus }); + const thread = makeThread({ status: threadStatus, permission_mode: threadPermissionMode }); const database = createOwnedTestDatabase(); const { db, writer } = database; db.run("INSERT INTO workspaces (id, name, path) VALUES (?, ?, ?)", ["ws-1", "Fixture", process.cwd()]); @@ -395,10 +397,11 @@ describe("AgentService internal MCP provider allowlist", () => { }); it.each([ - "Spawn exactly one nested provider-native child named leaf_probe", - "Delegate this task to a provider-native subagent", - ])("revokes thread control at turn start for child-agent wording: %s", async (content) => { - const { svc, threadControlMcp } = buildService(); + "Farm this out to Codex", + "Call delegate_task exactly once to hand this review to Codex. Do not call any other tool.", + "Do not create an Mcode thread", + ])("activates thread control whatever the message says: %s", async (content) => { + const { svc, threadControlMcp, providerStub } = buildService(); await svc.sendMessage({ threadId: THREAD_ID, @@ -409,26 +412,33 @@ describe("AgentService internal MCP provider allowlist", () => { provider: "codex", }); - expect(threadControlMcp.activate).not.toHaveBeenCalled(); - expect(threadControlMcp.revoke).toHaveBeenCalledWith(`mcode-${THREAD_ID}`); + expect(threadControlMcp.revoke).not.toHaveBeenCalled(); + expect(threadControlMcp.activate).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: `mcode-${THREAD_ID}`, + sourceThreadId: THREAD_ID, + permissionMode: "supervised", + })); + expect(providerStub.sendTurn).toHaveBeenCalledWith(expect.objectContaining({ + threadControlEligible: true, + })); }); - it("activates thread control only for an explicit Mcode thread request", async () => { - const { svc, threadControlMcp } = buildService(); + it("activates full thread control for a default command on a persisted full thread", async () => { + const { svc, threadControlMcp } = buildService({ threadPermissionMode: "full" }); await svc.sendMessage({ threadId: THREAD_ID, - content: "Create one Mcode thread named leaf_probe", + content: "Farm this out to Codex", permissionMode: "default", model: "claude-sonnet-4-6", attachments: [], provider: "codex", }); - expect(threadControlMcp.revoke).not.toHaveBeenCalled(); expect(threadControlMcp.activate).toHaveBeenCalledWith(expect.objectContaining({ sessionId: `mcode-${THREAD_ID}`, - eligible: true, + sourceThreadId: THREAD_ID, + permissionMode: "full", })); }); }); diff --git a/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts b/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts index 16a1505e0..e122dc6fe 100644 --- a/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts +++ b/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts @@ -1067,12 +1067,7 @@ export class TurnRuntimeController implements TurnLifecycleControl, TurnRuntimeE /** Apply the provider-neutral thread-control directive selected during admission. */ private applyThreadControlLease(directive: ThreadControlLeaseDirective | null): void { - if (!directive) return; - if (directive.kind === "revoke") { - this.threadControlMcp?.revoke(directive.sessionId); - return; - } - this.threadControlMcp?.activate({ ...directive, eligible: true }); + if (directive) this.threadControlMcp?.activate(directive); } /** Send one attempt and hand only retryable failures to the existing retry owner. */ @@ -2198,6 +2193,7 @@ export class TurnRuntimeController implements TurnLifecycleControl, TurnRuntimeE /** Record an adopted turn's dispatch state without sending a prompt the provider never received. */ private adoptPreparedProviderDispatch(prepared: PreparedTurnDispatch): void { + this.applyThreadControlLease(prepared.threadControl); const dispatch = this.createRetryDispatch(prepared); dispatch.dispatchStarted = true; this.turnRetryDispatchByThread.set(prepared.lease.threadId, dispatch); diff --git a/apps/server/src/features/agents/turns/turn-admission-dispatch-coordinator.ts b/apps/server/src/features/agents/turns/turn-admission-dispatch-coordinator.ts index 86278bc6d..d66dd6756 100644 --- a/apps/server/src/features/agents/turns/turn-admission-dispatch-coordinator.ts +++ b/apps/server/src/features/agents/turns/turn-admission-dispatch-coordinator.ts @@ -23,7 +23,6 @@ import { type TurnRuntimeSnapshot, } from "@mcode/contracts"; import { logger, validateBranchName } from "@mcode/shared"; -import { isExplicitMcodeThreadRequest } from "@mcode/thread-orchestration"; import { broadcast } from "../../../application/transport/push.js"; import { publishSynthesizedAgentEvents } from "../canonical/synthesized-agent-event-publication.js"; import { AttachmentService } from "../../attachments/storage/attachment-service.js"; @@ -176,16 +175,13 @@ export interface TurnParentStartOwner { } /** The thread-control action selected during generic dispatch preparation. */ -export type ThreadControlLeaseDirective = - | { - readonly kind: "activate"; - readonly sessionId: string; - readonly sourceThreadId: string; - readonly sourceTurnId: string; - readonly sourceProviderId: ProviderId; - readonly permissionMode: "supervised" | "full"; - } - | { readonly kind: "revoke"; readonly sessionId: string }; +export interface ThreadControlLeaseDirective { + readonly sessionId: string; + readonly sourceThreadId: string; + readonly sourceTurnId: string; + readonly sourceProviderId: ProviderId; + readonly permissionMode: "supervised" | "full"; +} /** A provider-ready turn package with no persistence or provider-selection work left. */ export interface PreparedTurnDispatch { @@ -1199,7 +1195,7 @@ export class TurnAdmissionDispatchCoordinator { attachments: attachments.persisted.length > 0 ? attachments.persisted : undefined, reasoningLevel: prepared.command.reasoningLevel, ...guardrails, - threadControlEligible: this.isThreadControlEligible(prepared), + threadControlEligible: supportsInternalThreadControl(prepared.providerId), resumeFrom: this.resumeFrom(prepared, attachments), ...(prepared.command.resumeRequired ? { resumeRequired: true } : {}), providerOptions: this.providerOptions(prepared, settings), @@ -1297,21 +1293,14 @@ export class TurnAdmissionDispatchCoordinator { return thread.sdk_session_id ?? undefined; } - private isThreadControlEligible(prepared: PreparedCommand): boolean { - return supportsInternalThreadControl(prepared.providerId) && isExplicitMcodeThreadRequest(prepared.command.content); - } - private threadControlDirective(prepared: PreparedCommand, sourceTurnId: string): ThreadControlLeaseDirective | null { if (!supportsInternalThreadControl(prepared.providerId)) return null; - const sessionId = `mcode-${prepared.command.threadId}`; - if (!this.isThreadControlEligible(prepared)) return { kind: "revoke", sessionId }; return { - kind: "activate", - sessionId, + sessionId: `mcode-${prepared.command.threadId}`, sourceThreadId: prepared.command.threadId, sourceTurnId, sourceProviderId: prepared.providerId, - permissionMode: prepared.command.permissionMode === "full" ? "full" : "supervised", + permissionMode: this.effectivePermissionMode(prepared.command.permissionMode, prepared.thread.permission_mode ?? "supervised"), }; } diff --git a/apps/server/src/features/mcode-mcp/__tests__/mcode-session-authority.test.ts b/apps/server/src/features/mcode-mcp/__tests__/mcode-session-authority.test.ts index 3d8301680..e34360f6f 100644 --- a/apps/server/src/features/mcode-mcp/__tests__/mcode-session-authority.test.ts +++ b/apps/server/src/features/mcode-mcp/__tests__/mcode-session-authority.test.ts @@ -55,20 +55,6 @@ describe("McodeSessionAuthority", () => { expect(authority.signal("mcode-source")).toBeUndefined(); }); - it("denies an ineligible turn from the start without inheriting a prior grant", () => { - const authority = new McodeSessionAuthority(); - authority.activate(turn({ sourceTurnId: "child-turn", eligible: false })); - expect(() => authority.authorizeThreadControl(session, "child-mcp-call")).toThrow(McodeThreadControlDeniedError); - - authority.activate(turn({ sourceTurnId: "explicit-turn", eligible: true })); - expect(authority.authorizeThreadControl(session, "parent-mcp-call")).toMatchObject({ - sourceTurnId: "explicit-turn", - }); - - authority.activate(turn({ sourceTurnId: "next-child-turn", eligible: false })); - expect(() => authority.authorizeThreadControl(session, "next-child-mcp-call")).toThrow(McodeThreadControlDeniedError); - }); - it("rejects unequal credential lengths before constant-time comparison", () => { expect(constantTimeCredentialEqual("credential", "credential")).toBe(true); expect(constantTimeCredentialEqual("credential", "credential-extra")).toBe(false); @@ -146,18 +132,19 @@ describe("McodeSessionAuthority", () => { expect(authority.signal("mcode-source")?.aborted).toBe(false); }); - it("refuses thread control without an active eligible lease and bounds the tool call id", () => { + it("refuses thread control without an active lease and bounds the tool call id", () => { const authority = new McodeSessionAuthority(); const { token } = authority.attach(attachRequest()); const session = authority.resolve(token)!; - expect(() => authority.authorizeThreadControl(session, "call")).toThrow(McodeThreadControlDeniedError); + expect(() => authority.authorizeThreadControl(session, "call")) + .toThrow(expect.objectContaining({ reason: "no_active_turn" })); authority.activate(turn()); expect(authority.authorizeThreadControl(session, "call")).toMatchObject({ sourceThreadId: "source" }); - expect(() => authority.authorizeThreadControl(session, "")).toThrow(McodeThreadControlDeniedError); - expect(() => authority.authorizeThreadControl(session, "x".repeat(129))).toThrow(McodeThreadControlDeniedError); - authority.activate(turn({ eligible: false })); - expect(() => authority.authorizeThreadControl(session, "call")).toThrow(McodeThreadControlDeniedError); + expect(() => authority.authorizeThreadControl(session, "")) + .toThrow(expect.objectContaining({ reason: "invalid_tool_call" })); + expect(() => authority.authorizeThreadControl(session, "x".repeat(129))) + .toThrow(expect.objectContaining({ reason: "invalid_tool_call" })); }); it("scopes thread-control authority to the resolving session", () => { diff --git a/apps/server/src/features/mcode-mcp/__tests__/thread-control-toolkit.test.ts b/apps/server/src/features/mcode-mcp/__tests__/thread-control-toolkit.test.ts index 7be4d28a1..37ec8a493 100644 --- a/apps/server/src/features/mcode-mcp/__tests__/thread-control-toolkit.test.ts +++ b/apps/server/src/features/mcode-mcp/__tests__/thread-control-toolkit.test.ts @@ -21,7 +21,7 @@ const session: McodeSession = { threadControl: true, }; -const DENIED = "Internal thread-control MCP request denied"; +const NO_ACTIVE_TURN = /^Mcode thread control is unavailable because no user turn is active for this session\./; const thread = { workspaceId: "workspace-1", @@ -58,17 +58,15 @@ function createService() { } satisfies ThreadControlToolService; } -function setup(options: { lease?: "active" | "ineligible" | "none"; threadControl?: boolean } = {}) { +function setup(options: { lease?: "active" | "none"; threadControl?: boolean } = {}) { const authority = new McodeSessionAuthority(); - const lease = options.lease ?? "active"; - if (lease !== "none") { + if (options.lease !== "none") { authority.activate({ sessionId: session.sessionId, sourceThreadId: session.threadId, sourceTurnId: "source-turn", sourceProviderId: "claude", permissionMode: "full", - eligible: lease === "active", }); } const service = createService(); @@ -157,12 +155,12 @@ describe("thread-control toolkit", () => { }); }); - it.each(["none", "ineligible"] as const)("refuses every call when the turn lease is %s", async (lease) => { - const { call, service } = setup({ lease }); + it("refuses every call without a turn lease and says why", async () => { + const { call, service } = setup({ lease: "none" }); await expect(call("workspace_search", { query: "mcode" })).resolves.toMatchObject({ isError: true, - content: [{ type: "text", text: DENIED }], + content: [{ type: "text", text: expect.stringMatching(NO_ACTIVE_TURN) }], }); expect(service.workspaceSearch).not.toHaveBeenCalled(); }); @@ -188,7 +186,7 @@ describe("thread-control toolkit", () => { await expect(call("thread_search", { statuses: [] })).resolves.toMatchObject({ isError: true, - content: [{ type: "text", text: DENIED }], + content: [{ type: "text", text: expect.stringMatching(NO_ACTIVE_TURN) }], }); }); diff --git a/apps/server/src/features/mcode-mcp/mcode-session-authority.ts b/apps/server/src/features/mcode-mcp/mcode-session-authority.ts index 03ddca40f..17ee7fcde 100644 --- a/apps/server/src/features/mcode-mcp/mcode-session-authority.ts +++ b/apps/server/src/features/mcode-mcp/mcode-session-authority.ts @@ -9,8 +9,6 @@ interface ActiveLease { sourceProviderId: string; permissionMode: "supervised" | "full"; active: boolean; - /** Whether the current user request explicitly authorized Mcode thread control. */ - eligible: boolean; controller: AbortController; } @@ -38,8 +36,6 @@ export interface ActivateMcodeSessionLease { sourceTurnId: string; sourceProviderId: string; permissionMode: "supervised" | "full"; - /** Explicit per-turn eligibility derived from the original user request. */ - eligible?: boolean; } /** Opaque credential and stable session binding supplied to a provider transport. */ @@ -48,10 +44,18 @@ export interface McodeSessionCredential { sessionId: string; } -/** Refuses a thread-control call that has no active, eligible turn lease. */ +/** Why a thread-control call was refused. */ +export type McodeThreadControlDenial = "invalid_tool_call" | "no_active_turn"; + +const DENIAL_MESSAGES: Record = { + invalid_tool_call: "Mcode thread control denied this call because its tool call id is empty or longer than 128 characters.", + no_active_turn: "Mcode thread control is unavailable because no user turn is active for this session. Thread-control tools work only while a user turn runs.", +}; + +/** Refuses a thread-control call that has no active turn lease. */ export class McodeThreadControlDeniedError extends Error { - constructor() { - super("Internal thread-control MCP request denied"); + constructor(readonly reason: McodeThreadControlDenial) { + super(DENIAL_MESSAGES[reason]); } } @@ -110,7 +114,6 @@ export class McodeSessionAuthority { sourceTurnId: input.sourceTurnId, sourceProviderId: input.sourceProviderId, permissionMode: input.permissionMode, - eligible: input.eligible ?? true, active: true, controller: new AbortController(), }; @@ -146,12 +149,10 @@ export class McodeSessionAuthority { /** * Builds the thread-control authority from the session's turn lease. - * @throws McodeThreadControlDeniedError when the lease is missing, revoked, or ineligible. + * @throws McodeThreadControlDeniedError when the lease is missing or revoked. */ authorizeThreadControl(session: McodeSession, sourceToolCallId: string): InternalThreadControlAuthority { - const authority = authorizeEntry(this.entries.get(session.sessionId), sourceToolCallId); - if (!authority) throw new McodeThreadControlDeniedError(); - return authority; + return authorizeEntry(this.entries.get(session.sessionId), sourceToolCallId); } /** Returns the revocation signal for a session while its turn lease is active. */ @@ -182,10 +183,12 @@ function activeSignal(entry: SessionEntry | undefined): AbortSignal | undefined function authorizeEntry( entry: SessionEntry | undefined, sourceToolCallId: string, -): InternalThreadControlAuthority | undefined { - if (sourceToolCallId.length < 1 || sourceToolCallId.length > 128) return undefined; +): InternalThreadControlAuthority { + if (sourceToolCallId.length < 1 || sourceToolCallId.length > 128) { + throw new McodeThreadControlDeniedError("invalid_tool_call"); + } const lease = entry?.active; - if (!lease?.active || !lease.eligible) return undefined; + if (!lease?.active) throw new McodeThreadControlDeniedError("no_active_turn"); return { type: "internal", userId: "local-user", diff --git a/packages/contracts/src/providers/interfaces.ts b/packages/contracts/src/providers/interfaces.ts index 23f8d7236..6783498ed 100644 --- a/packages/contracts/src/providers/interfaces.ts +++ b/packages/contracts/src/providers/interfaces.ts @@ -129,7 +129,7 @@ export interface TurnRequest

{ maxBudgetUsd?: number; /** Max agent turns for this Turn. Undefined or 0 disables. */ maxTurns?: number; - /** Whether this turn's user request explicitly authorizes Mcode thread control. */ + /** Whether the server grants this turn Mcode thread control. */ threadControlEligible?: boolean; /** * SDK session id to resume from. When defined the Provider resumes that diff --git a/packages/providers/src/__tests__/codex/codex-provider-first-turn.test.ts b/packages/providers/src/__tests__/codex/codex-provider-first-turn.test.ts index e536306a1..31e82e1c4 100644 --- a/packages/providers/src/__tests__/codex/codex-provider-first-turn.test.ts +++ b/packages/providers/src/__tests__/codex/codex-provider-first-turn.test.ts @@ -599,7 +599,7 @@ describe("CodexProvider first turn on new session", () => { await new Promise((resolve) => setImmediate(resolve)); expect(server.options.instructionSections?.find((section) => section.key === "mcode_provider_subagents")?.text).toContain("provider-native collaboration"); expect(server.options.instructionSections?.find((section) => section.key === "mcode_provider_subagents")?.text).toContain("preserve the exact requested brief"); - expect(server.options.instructionSections?.find((section) => section.key === "mcode_provider_subagents")?.text).toContain("does not authorize Mcode thread control"); + expect(server.options.instructionSections?.find((section) => section.key === "mcode_provider_subagents")?.text).toContain("only when native collaboration cannot run the requested provider or model"); expect(sendTurnMock).toHaveBeenCalledWith( [{ type: "text", text: childBrief }], { model, approvalsReviewer: "user" }, diff --git a/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts b/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts index 38a20577e..cdf12eb11 100644 --- a/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts +++ b/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts @@ -2,7 +2,6 @@ import * as NodeBuffer from "node:buffer"; import { describe, expect, it } from "vitest"; import { buildMcodeInstructionSections, - isExplicitMcodeThreadRequest, MCODE_INSTRUCTION_SECTION_MAX_BYTES, renderMcodeInstructionSections, } from "../index.js"; @@ -15,27 +14,6 @@ const toolkitCombinations = [ ]; describe("Mcode runtime instruction sections", () => { - it.each([ - "Spawn exactly one nested provider-native child named leaf_probe", - "Delegate this task to a provider-native subagent", - ])("does not grant Mcode thread control to provider-native child wording: %s", (request) => { - expect(isExplicitMcodeThreadRequest(request)).toBe(false); - }); - - it.each([ - "Create one Mcode thread named leaf_probe", - "Use thread_create_batch for the requested Mcode thread", - "Use the mcode delegate_task tool to hand this to Claude", - "Call task_status for the delegated task", - "Cancel it with task_cancel", - ])("recognizes explicit Mcode thread requests: %s", (request) => { - expect(isExplicitMcodeThreadRequest(request)).toBe(true); - }); - - it("fails closed for negated Mcode thread requests", () => { - expect(isExplicitMcodeThreadRequest("Do not create an Mcode thread")).toBe(false); - }); - it.each(toolkitCombinations)("selects guidance for attached toolkits: %j", (toolkits) => { const sections = buildMcodeInstructionSections({ toolkits }); const keys = sections.map((section) => section.key); @@ -90,7 +68,7 @@ describe("Mcode runtime instruction sections", () => { const sections = buildMcodeInstructionSections({ toolkits: { threadControl: false, browser: false } }); expect(renderMcodeInstructionSections(sections)).toBe( "You are operating inside an Mcode-managed session. Mcode runtime guidance is advisory; MCP authorization and lease state remain authoritative.\n\n" - + "A child-agent or sub-agent request uses provider-native collaboration in the current turn. Use collaboration tools, preserve the exact requested brief, and keep direct and nested parent relationships intact. A child-agent request does not authorize Mcode thread control; use thread tools only for an explicit Mcode thread request.\n\n" + + "A child-agent or sub-agent request uses provider-native collaboration in the current turn. Use collaboration tools, preserve the exact requested brief, and keep direct and nested parent relationships intact. Use Mcode delegation for a child-agent request only when native collaboration cannot run the requested provider or model.\n\n" + "For broad repository searches, first list files with rg -l. Exclude fixtures and captured traces (*.ndjson) unless the task targets them, and bound line output with rg --max-columns 240 before reading matches.", ); }); diff --git a/packages/thread-orchestration/src/index.ts b/packages/thread-orchestration/src/index.ts index 20449f1da..57c32a7d4 100644 --- a/packages/thread-orchestration/src/index.ts +++ b/packages/thread-orchestration/src/index.ts @@ -63,7 +63,6 @@ export function isInternalThreadTargetAllowed( export { MCODE_INSTRUCTION_SECTION_MAX_BYTES, buildMcodeInstructionSections, - isExplicitMcodeThreadRequest, renderMcodeInstructionSections, } from "./mcode-instructions.js"; export type { diff --git a/packages/thread-orchestration/src/mcode-instructions.test.ts b/packages/thread-orchestration/src/mcode-instructions.test.ts index ab57bfe02..4c98df892 100644 --- a/packages/thread-orchestration/src/mcode-instructions.test.ts +++ b/packages/thread-orchestration/src/mcode-instructions.test.ts @@ -1,16 +1,8 @@ import * as NodeBuffer from "node:buffer"; import { describe, expect, it } from "vitest"; -import { buildMcodeInstructionSections, isExplicitMcodeThreadRequest, MCODE_INSTRUCTION_SECTION_MAX_BYTES } from "./mcode-instructions.js"; +import { buildMcodeInstructionSections, MCODE_INSTRUCTION_SECTION_MAX_BYTES } from "./mcode-instructions.js"; describe("thread launch requests", () => { - it("recognizes a request naming thread_launch", () => { - expect(isExplicitMcodeThreadRequest("Use thread_launch to start a separate conversation")).toBe(true); - }); - - it("rejects a negated thread_launch request", () => { - expect(isExplicitMcodeThreadRequest("Don't use thread_launch")).toBe(false); - }); - it("keeps launch guidance within the provider context budget", () => { const sections = buildMcodeInstructionSections({ toolkits: { threadControl: true, browser: false } }); for (const section of sections) { diff --git a/packages/thread-orchestration/src/mcode-instructions.ts b/packages/thread-orchestration/src/mcode-instructions.ts index 2f7ab9e99..bf3e01ec4 100644 --- a/packages/thread-orchestration/src/mcode-instructions.ts +++ b/packages/thread-orchestration/src/mcode-instructions.ts @@ -3,10 +3,6 @@ /** Codex silently cuts the middle out of an additionalContext value above ~1,000 tokens (UTF-8 bytes / 4). */ export const MCODE_INSTRUCTION_SECTION_MAX_BYTES = 4_000; -const EXPLICIT_MCODE_THREAD_TOKEN = /\b(?:mcode(?:[-_\s]+managed)?[-_\s]+threads?|mcode[-_\s]+thread[-_\s]?control|thread_(?:target_list|create_batch|launch|search|get|send|stop|wait)|delegate_task|task_(?:status|cancel))\b/i; -const NEGATED_MCODE_THREAD_REQUEST = /\b(?:do\s+not|don['’]?t|dont|never|without|no)\b[\s\S]{0,96}\b(?:mcode(?:[-_\s]+managed)?[-_\s]+threads?|mcode[-_\s]+thread[-_\s]?control|thread_(?:target_list|create_batch|launch|search|get|send|stop|wait)|delegate_task|task_(?:status|cancel))\b/i; -const MCODE_THREAD_REQUEST_ACTION = /\b(?:create|start|open|launch|use|manage|list|search|send|stop|wait|get|target|delegate|call|check|cancel)\b/i; - /** Stable section names used as Codex context tags. */ export type McodeInstructionSectionKey = | "mcode_identity" @@ -35,21 +31,6 @@ export interface BuildMcodeInstructionSectionsInput { readonly nestedDelegationModel?: string; } -/** - * Returns true only when the user's current request explicitly names Mcode - * thread control or one of its thread operations. - * - * Provider-native child-agent language intentionally does not satisfy this - * boundary. Negated requests fail closed so a model cannot inherit a lease - * from wording such as "do not create an Mcode thread". - */ -export function isExplicitMcodeThreadRequest(content: string): boolean { - const request = content.trim(); - if (!request || NEGATED_MCODE_THREAD_REQUEST.test(request)) return false; - if (!EXPLICIT_MCODE_THREAD_TOKEN.test(request)) return false; - return MCODE_THREAD_REQUEST_ACTION.test(request); -} - const IDENTITY_BLOCK = [ "You are operating inside an Mcode-managed session.", "Mcode runtime guidance is advisory; MCP authorization and lease state remain authoritative.", @@ -58,7 +39,8 @@ const IDENTITY_BLOCK = [ // The thread-control and Browser tool descriptions repeat these rules on purpose. With deferred tool loading, a description arrives only after the model has chosen a tool. const THREAD_CONTROL_BLOCK = [ "Mcode thread control", - "- Use the mcode MCP thread_* tools only when the user explicitly asks for Mcode threads. An Mcode thread is a persistent user-visible conversation, different from provider-native child agents.", + "- The mcode MCP tools manage Mcode threads: persistent, user-visible conversations. Use delegate_task when the user asks to hand work to another provider or model, even if they never say \"Mcode thread\".", + "- A sub-agent request uses your provider-native collaboration tool when it can run the requested model. Never launch an Mcode thread just because the user said \"sub-agent\".", "- If no Project is named, use the active source thread's Project without discovery. Resolve an explicitly named Project with workspace_search. Use worktree_list only to resolve an explicit worktree or reference.", "- Delegate child work with delegate_task. Preserve the user's title and task in the prompt. Choose isolation shared for the Coordinator's checkout or worktree for a new checkout at its local commit. Retry the same input with the same clientRequestId to return the existing task.", "- thread_launch starts a separate user-owned thread only when the user asks for one; use delegate_task for child work whose result you need.", @@ -71,7 +53,7 @@ const THREAD_CONTROL_BLOCK = [ const PROVIDER_SUBAGENT_BLOCK = [ "A child-agent or sub-agent request uses provider-native collaboration in the current turn.", "Use collaboration tools, preserve the exact requested brief, and keep direct and nested parent relationships intact.", - "A child-agent request does not authorize Mcode thread control; use thread tools only for an explicit Mcode thread request.", + "Use Mcode delegation for a child-agent request only when native collaboration cannot run the requested provider or model.", ].join(" "); const REPOSITORY_SEARCH_BLOCK = [