From 32bb08dbbf18ae69fd617836136834d3167e19e8 Mon Sep 17 00:00:00 2001 From: Shakker <165377636+shakkernerd@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:05:36 +0100 Subject: [PATCH] fix: continue code mode with read-only recovery (#128071) Preserve failed Code Mode mutations without replay while allowing one final-policy-authorized core-read reconciliation attempt to report partial application state. Fixes #128028. --- src/agents/embedded-agent-runner/run-loop.ts | 11 +++ ...n.code-mode-reconciliation.test-support.ts | 74 +++++++++++++++++++ .../run.shared-integration.test.ts | 1 + .../run/attempt-bundle-tools.ts | 5 +- .../run/attempt-client-tools.test.ts | 23 ++++++ .../run/attempt-client-tools.ts | 9 +++ .../run/attempt-dispatch-preparation.ts | 4 +- .../run/attempt-execution-settle.test.ts | 3 + .../run/attempt-prompt-phase.test.ts | 36 +++++++++ .../run/attempt-prompt-phase.ts | 4 + .../run/attempt-prompt-support.ts | 5 ++ .../run/attempt-prompt-tool-policy.test.ts | 9 +++ .../run/attempt-result.ts | 2 + .../run/attempt-session-prepare.ts | 15 +++- .../run/attempt-session.test.ts | 35 ++++++++- .../run/attempt-settle.ts | 6 ++ .../run/attempt-stream-finalize.test.ts | 3 + .../run/attempt-tool-prepare.ts | 32 +++++--- .../run/code-mode-reconciliation.test.ts | 60 +++++++++++++++ .../run/code-mode-reconciliation.ts | 63 ++++++++++++++++ .../run/code-mode-repair.test.ts | 17 ++++- .../run/code-mode-repair.ts | 11 ++- .../embedded-agent-runner/run/params.ts | 2 + .../run/run-attempt-dispatch.ts | 1 + .../run/terminal-retry-state.ts | 4 + src/agents/embedded-agent-runner/run/types.ts | 2 + src/agents/tool-surface-plan.ts | 7 +- 27 files changed, 421 insertions(+), 23 deletions(-) create mode 100644 src/agents/embedded-agent-runner/run.code-mode-reconciliation.test-support.ts create mode 100644 src/agents/embedded-agent-runner/run/code-mode-reconciliation.test.ts create mode 100644 src/agents/embedded-agent-runner/run/code-mode-reconciliation.ts diff --git a/src/agents/embedded-agent-runner/run-loop.ts b/src/agents/embedded-agent-runner/run-loop.ts index cc053eef3bbf..1c88af3a2dac 100644 --- a/src/agents/embedded-agent-runner/run-loop.ts +++ b/src/agents/embedded-agent-runner/run-loop.ts @@ -33,6 +33,7 @@ import { normalizeEmbeddedRunAttempt } from "./run/attempt-normalization.js"; import { forgetPromptBuildDrainCacheForRun } from "./run/attempt-prompt-helpers.js"; import { recoverEmbeddedRunAttempt } from "./run/attempt-recovery.js"; import { createMcpAttemptCarryover } from "./run/attempt-result.js"; +import { activateCodeModeReconciliation } from "./run/code-mode-reconciliation.js"; import { hasCodexAppServerRecoveryRetryBudget } from "./run/codex-app-server-recovery.js"; import { createEmbeddedRunCompactionRuntime } from "./run/compaction-runtime.js"; import { createEmbeddedRunContextRecoveryState } from "./run/context-recovery-state.js"; @@ -522,6 +523,16 @@ export async function runPreparedEmbeddedLoop( if (assistantFailureOutcome.action === "retry") { continue; } + if ( + activateCodeModeReconciliation({ + attempt, + hostOwnsToolSurface: !pluginHarnessOwnsTransport, + retryState: terminalRetryState, + activateInternalPrompt: sessionPromptState.activateInternalPrompt, + }) + ) { + continue; + } let assistantProfileFailureReason = assistantFailureOutcome.assistantProfileFailureReason; const terminalToolPresentationText = terminalToolPresentation.read(); const finalizedTerminal = await prepareTerminalWithSettledTurnFinalization({ diff --git a/src/agents/embedded-agent-runner/run.code-mode-reconciliation.test-support.ts b/src/agents/embedded-agent-runner/run.code-mode-reconciliation.test-support.ts new file mode 100644 index 000000000000..319c0ec038ae --- /dev/null +++ b/src/agents/embedded-agent-runner/run.code-mode-reconciliation.test-support.ts @@ -0,0 +1,74 @@ +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { buildEmbeddedRunnerAssistant } from "../test-helpers/embedded-agent-runner-e2e-fixtures.js"; +import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; +import { + mockedClassifyFailoverReason, + mockedRunEmbeddedAttempt, + overflowBaseRunParams, + resetSharedRunIntegrationHarnessMocks, + useOpenAIPlatformAuthFixture, +} from "./run.overflow-compaction.harness.js"; +import { loadSharedRunIntegrationHarness } from "./run.shared-integration-harness.test-support.js"; + +let runEmbeddedAgent: Awaited>; + +describe("runEmbeddedAgent Code Mode reconciliation", () => { + beforeAll(async () => { + runEmbeddedAgent = await loadSharedRunIntegrationHarness(); + }); + + beforeEach(() => { + resetSharedRunIntegrationHarnessMocks(); + mockedClassifyFailoverReason.mockReturnValue(null); + useOpenAIPlatformAuthFixture(); + }); + + it("continues a settled partial mutation with one read-only attempt", async () => { + const mutationAssistant = buildEmbeddedRunnerAssistant({ + stopReason: "toolUse", + content: [ + { + type: "toolCall", + id: "code-mode-mutation", + name: "code_mode", + arguments: { action: "exec" }, + }, + ], + }); + mockedRunEmbeddedAttempt + .mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + lastAssistant: mutationAssistant, + currentAttemptAssistant: mutationAssistant, + currentAttemptCompletedAssistant: mutationAssistant, + codeModeReconciliationCandidate: true, + itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 }, + }), + ) + .mockResolvedValueOnce(makeAttemptResult({ assistantTexts: ["The first hunk applied."] })); + + await runEmbeddedAgent({ + ...overflowBaseRunParams, + config: { + agents: { + defaults: { + models: { "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } } }, + }, + }, + }, + provider: "openai", + model: "gpt-5.5", + runId: "run-code-mode-reconciliation", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expect( + mockedRunEmbeddedAttempt.mock.calls[0]?.[0].forceCodeModeReconciliationTools, + ).toBeFalsy(); + expect(mockedRunEmbeddedAttempt.mock.calls[1]?.[0]).toMatchObject({ + forceCodeModeReconciliationTools: true, + prompt: expect.stringContaining("may have partially applied"), + }); + }); +}); diff --git a/src/agents/embedded-agent-runner/run.shared-integration.test.ts b/src/agents/embedded-agent-runner/run.shared-integration.test.ts index 4d230db213a9..11db3231fa0a 100644 --- a/src/agents/embedded-agent-runner/run.shared-integration.test.ts +++ b/src/agents/embedded-agent-runner/run.shared-integration.test.ts @@ -1,6 +1,7 @@ // The imported scenario modules share one mocked runEmbeddedAgent module graph. import "./run.before-agent-finalize.test-support.js"; import "./run.before-agent-reply-cron.test-support.js"; +import "./run.code-mode-reconciliation.test-support.js"; import "./run.codex-app-server-recovery.test-support.js"; import "./run.codex-server-error-fallback.test-support.js"; import "./run.compaction-loop-guard.test-support.js"; diff --git a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts index a3f86c65a121..3d89dd765096 100644 --- a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts +++ b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts @@ -70,7 +70,8 @@ export async function prepareEmbeddedAttemptBundleTools(params: { toolsEnabled && !params.attempt.disableTools && !params.isRawModelRun && - !params.attempt.forceRestartSafeTools + !params.attempt.forceRestartSafeTools && + !params.attempt.forceCodeModeReconciliationTools ? params.attempt.clientTools : undefined; // Client functions share the attempt's authority; filter before their names @@ -83,6 +84,7 @@ export async function prepareEmbeddedAttemptBundleTools(params: { : providedClientTools; const bundleMcpEnabled = !params.attempt.forceRestartSafeTools && + !params.attempt.forceCodeModeReconciliationTools && shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled, disableTools: params.attempt.disableTools || params.isRawModelRun, @@ -125,6 +127,7 @@ export async function prepareEmbeddedAttemptBundleTools(params: { try { const bundleLspEnabled = !params.attempt.forceRestartSafeTools && + !params.attempt.forceCodeModeReconciliationTools && shouldCreateBundleLspRuntimeForAttempt({ toolsEnabled, disableTools: params.attempt.disableTools || params.isRawModelRun, diff --git a/src/agents/embedded-agent-runner/run/attempt-client-tools.test.ts b/src/agents/embedded-agent-runner/run/attempt-client-tools.test.ts index c2838b5189ff..8e40d8252c7d 100644 --- a/src/agents/embedded-agent-runner/run/attempt-client-tools.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-client-tools.test.ts @@ -2,6 +2,7 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { setPluginToolMeta } from "../../../plugins/tools.js"; +import { setChannelAgentToolMeta } from "../../channel-tool-metadata.js"; import { createCodeModeCatalogProjection } from "../../code-mode-catalog.js"; import { applyCodeModeCatalog, createCodeModeTools } from "../../code-mode.js"; import { runUntilCompleted } from "../../code-mode.test-support.js"; @@ -99,6 +100,28 @@ function prepare(input: { } describe("prepareEmbeddedAttemptClientTools", () => { + it("records core read entitlement without plugin or channel shadows", () => { + const coreRead = createStubTool("read"); + const pluginRead = createStubTool("read"); + const channelRead = createStubTool("read"); + const catalogRef = createToolSearchCatalogRef(); + setPluginToolMeta(pluginRead, { pluginId: "example-plugin", optional: false }); + setChannelAgentToolMeta(channelRead as never, { channelId: "example-channel" }); + + expect( + [coreRead, pluginRead, channelRead].map( + (tool) => + prepare({ + codeModeControlsEnabledForRun: false, + attemptConfig: CATALOGS_DISABLED_CONFIG, + toolSearchRuntimeConfig: CATALOGS_DISABLED_CONFIG, + catalogRef, + uncompactedEffectiveTools: [tool], + }).coreReadAuthorized, + ), + ).toEqual([true, false, false]); + }); + it("hides client tools behind the code-mode catalog when code mode is engaged", () => { const catalogRef = seedCatalog("code-mode", CODE_MODE_CONFIG); diff --git a/src/agents/embedded-agent-runner/run/attempt-client-tools.ts b/src/agents/embedded-agent-runner/run/attempt-client-tools.ts index bf66cbc0b44d..2da6884e62a9 100644 --- a/src/agents/embedded-agent-runner/run/attempt-client-tools.ts +++ b/src/agents/embedded-agent-runner/run/attempt-client-tools.ts @@ -5,8 +5,10 @@ import { toClientToolDefinitions, } from "../../agent-tool-definition-adapter.js"; import { resolveToolLoopDetectionConfig } from "../../agent-tools.js"; +import { getChannelAgentToolMeta } from "../../channel-tools.js"; import { addClientToolsToCodeModeCatalog } from "../../code-mode.js"; import type { AgentTool } from "../../runtime/index.js"; +import { normalizeToolPolicyName } from "../../tool-policy.js"; import { collectReplaySafeToolNames, collectSideEffectToolOwners, @@ -68,6 +70,12 @@ export function prepareEmbeddedAttemptClientTools(params: { isPluginTool: (tool) => Boolean(getPluginToolMeta(tool as Parameters[0])), }); + const coreReadAuthorized = params.uncompactedEffectiveTools.some( + (tool) => + normalizeToolPolicyName(tool.name ?? "") === "read" && + !getPluginToolMeta(tool) && + !getChannelAgentToolMeta(tool), + ); const isReplaySafeTool = (tool: { name?: string }) => isAgentToolReplaySafe(tool, params.replaySafetyOptions); const replaySafeTools = new Set(params.uncompactedEffectiveTools.filter(isReplaySafeTool)); @@ -176,6 +184,7 @@ export function prepareEmbeddedAttemptClientTools(params: { allCustomTools, builtinToolNames, coreBuiltinToolNames, + coreReadAuthorized, clientToolCallSlots, clientToolDefs, clientToolLoopDetection, diff --git a/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts b/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts index fa3dbd7a5706..8312dfe392c5 100644 --- a/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts +++ b/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts @@ -54,7 +54,9 @@ export async function prepareAndDispatchEmbeddedRunAttempt(input: { provider, modelId, } = input; - const params = runInput.runParams; + const params = input.terminalRetryState.forceCodeModeReconciliationTools + ? { ...runInput.runParams, forceCodeModeReconciliationTools: true } + : runInput.runParams; const { workspaceResolution, workspaceDir, diff --git a/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts b/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts index ab147f93943d..d43190292f8b 100644 --- a/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts @@ -173,8 +173,11 @@ function createFixture() { agentSession: { activeSession, clientToolCallSlots: [], + coreReadAuthorized: true, + getCodeModeReconciliationCandidate: vi.fn(() => false), hasDeliveredSourceReply: vi.fn(() => true), hookRunner, + setCodeModeReconciliationReadAuthorized: vi.fn(), setActiveSessionSystemPrompt: vi.fn(), settingsManager: { getCompactionReserveTokens: vi.fn(() => 1_000) }, }, diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-phase.test.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-phase.test.ts index 9f4caa93a4ff..5a59311a7a5b 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-phase.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-phase.test.ts @@ -64,6 +64,7 @@ import type { submitEmbeddedAttemptPrompt } from "./attempt-prompt-submit.js"; type PromptPhaseInput = Parameters[0]; type PromptPhaseState = ReturnType; type AssemblyCall = { + applyPromptBuildToolsAllow: (toolsAllow: string[] | undefined) => string[]; setLeasedSteering: (lease: { leaseId: string; runIds: string[] }) => void; }; type PromptPreflightCall = Parameters[0]; @@ -107,6 +108,7 @@ function createFixture() { prePromptMessageCount = count; }); const setPromptCacheChangesForTurn = vi.fn(); + const setCodeModeReconciliationReadAuthorized = vi.fn(); const setFinalPromptText = vi.fn(); const markBeforeAgentRunBlocked = vi.fn(); const markYieldAborted = vi.fn(() => { @@ -119,6 +121,7 @@ function createFixture() { mocks.preparePromptAssembly.mockImplementation(async (input: AssemblyCall) => { order.push("assembly"); const lease = { leaseId: "lease-1", runIds: ["run-1"] }; + input.applyPromptBuildToolsAllow(undefined); input.setLeasedSteering(lease); return { hookCtx: {}, @@ -237,6 +240,14 @@ function createFixture() { transport: "sse", uncompactedEffectiveTools: [], }, + toolPolicy: { + baseline: { activeToolNames: ["read"], catalogEntries: [] }, + effectiveTools: [{ name: "read" }], + uncompactedEffectiveTools: [{ name: "read" }], + tools: [{ name: "read" }], + codeModeControlsEnabled: false, + coreReadAuthorized: true, + }, preflight: { contextEngineAssemblySucceeded: false, contextEnginePromptAuthority: "assembled", @@ -258,6 +269,7 @@ function createFixture() { setPrePromptMessageCount, setCurrentUserTimestampOverride: vi.fn(), setPromptCacheChangesForTurn, + setCodeModeReconciliationReadAuthorized, setFinalPromptText, markBeforeAgentRunBlocked, markYieldAborted, @@ -275,6 +287,7 @@ function createFixture() { setFinalPromptText, setPrePromptMessageCount, setPromptCacheChangesForTurn, + setCodeModeReconciliationReadAuthorized, state, yieldState, }; @@ -282,6 +295,13 @@ function createFixture() { beforeEach(() => { vi.clearAllMocks(); + mocks.applyPromptToolsAllow.mockReturnValue({ + activeToolNames: ["read"], + coreReadAuthorized: true, + effectiveTools: [{ name: "read" }], + uncompactedEffectiveTools: [{ name: "read" }], + tools: [{ name: "read" }], + }); }); describe("runEmbeddedAttemptPromptPhase", () => { @@ -308,6 +328,7 @@ describe("runEmbeddedAttemptPromptPhase", () => { ]); expect(fixture.setPrePromptMessageCount).toHaveBeenCalledWith(2); expect(fixture.setPromptCacheChangesForTurn).toHaveBeenCalledWith([]); + expect(fixture.setCodeModeReconciliationReadAuthorized).toHaveBeenCalledWith(true); expect(fixture.setFinalPromptText).toHaveBeenCalledWith("hello"); expect(mocks.preparePromptExecution).toHaveBeenCalledWith( expect.objectContaining({ @@ -335,6 +356,21 @@ describe("runEmbeddedAttemptPromptPhase", () => { expect(mocks.releasePendingSteering).not.toHaveBeenCalled(); }); + it("records a final prompt policy that removes core read", async () => { + const fixture = createFixture(); + mocks.applyPromptToolsAllow.mockReturnValueOnce({ + activeToolNames: [], + coreReadAuthorized: false, + effectiveTools: [], + uncompactedEffectiveTools: [], + tools: [], + }); + + await runEmbeddedAttemptPromptPhase(fixture.input); + + expect(fixture.setCodeModeReconciliationReadAuthorized).toHaveBeenCalledWith(false); + }); + it("skips before_agent_run for settled-turn finalization", async () => { const fixture = createFixture(); fixture.input.attempt.operation = "settled-tool-finalization"; diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-phase.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-phase.ts index eb77926f14fe..db0ffa55af22 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-phase.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-phase.ts @@ -122,6 +122,7 @@ export async function runEmbeddedAttemptPromptPhase(input: { tools: Array<{ name: string }>; toolSearchCatalogRef?: Parameters[0]["catalogRef"]; codeModeControlsEnabled: boolean; + coreReadAuthorized: boolean; forceToolNames?: readonly string[]; }; preflight: PromptPreflightPhaseInput; @@ -137,6 +138,7 @@ export async function runEmbeddedAttemptPromptPhase(input: { setPromptCacheChangesForTurn: ( changes: PromptAssemblyResult["promptCacheChangesForTurn"], ) => void; + setCodeModeReconciliationReadAuthorized: (value: boolean) => void; setFinalPromptText: (prompt: string) => void; markBeforeAgentRunBlocked: (outcome: BeforeAgentRunOutcome) => void; markYieldAborted: () => void; @@ -217,8 +219,10 @@ export async function runEmbeddedAttemptPromptPhase(input: { tools: input.toolPolicy.tools, catalogRef: input.toolPolicy.toolSearchCatalogRef, codeModeControlsEnabled: input.toolPolicy.codeModeControlsEnabled, + coreReadAuthorized: input.toolPolicy.coreReadAuthorized, forceToolNames: input.toolPolicy.forceToolNames, }); + input.lifecycle.setCodeModeReconciliationReadAuthorized(promptToolSurface.coreReadAuthorized); return promptToolSurface.activeToolNames; }, setLeasedSteering: (lease) => { diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-support.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-support.ts index 7f367efc21f6..c6939250fbf9 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-support.ts @@ -76,9 +76,11 @@ export function applyPromptBuildToolsAllow< tools: TTool[]; catalogRef?: ToolSearchCatalogRef; codeModeControlsEnabled: boolean; + coreReadAuthorized: boolean; forceToolNames?: readonly string[]; }): { activeToolNames: string[]; + coreReadAuthorized: boolean; effectiveTools: TEffectiveTool[]; uncompactedEffectiveTools: TUncompactedTool[]; tools: TTool[]; @@ -122,6 +124,9 @@ export function applyPromptBuildToolsAllow< return { activeToolNames, + coreReadAuthorized: + params.coreReadAuthorized && + allowedUncompactedTools.some((tool) => normalizeToolPolicyName(tool.name) === "read"), effectiveTools: promptPolicy.tools, uncompactedEffectiveTools: allowedUncompactedTools, tools: allowedTools, diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-tool-policy.test.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-tool-policy.test.ts index 4764ef358736..c1a354960ba6 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-tool-policy.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-tool-policy.test.ts @@ -78,9 +78,11 @@ describe("applyPromptBuildToolsAllow", () => { tools: [{ name: "read" }, { name: "write" }, { name: "message" }], catalogRef, codeModeControlsEnabled: false, + coreReadAuthorized: true, }); expect(result.activeToolNames).toEqual([]); + expect(result.coreReadAuthorized).toBe(false); expect(result.effectiveTools).toEqual([]); expect(result.uncompactedEffectiveTools).toEqual([]); expect(result.tools).toEqual([]); @@ -107,6 +109,7 @@ describe("applyPromptBuildToolsAllow", () => { uncompactedEffectiveTools: [{ name: "message" }, { name: "read" }], tools: [{ name: "message" }, { name: "read" }], codeModeControlsEnabled: false, + coreReadAuthorized: true, }); expect(result.activeToolNames).toEqual(["message"]); @@ -143,9 +146,11 @@ describe("applyPromptBuildToolsAllow", () => { tools: [{ name: "read" }, { name: "write" }, { name: "message" }], catalogRef, codeModeControlsEnabled: false, + coreReadAuthorized: true, }); expect(result.activeToolNames).toEqual(["tool_search"]); + expect(result.coreReadAuthorized).toBe(true); expect(result.effectiveTools).toEqual([{ name: "tool_search" }]); expect(result.uncompactedEffectiveTools).toEqual([{ name: "read" }]); expect(result.tools).toEqual([{ name: "read" }]); @@ -164,9 +169,11 @@ describe("applyPromptBuildToolsAllow", () => { uncompactedEffectiveTools: [{ name: "read" }], tools: [{ name: "read" }], codeModeControlsEnabled: false, + coreReadAuthorized: true, }); expect(result.activeToolNames).toEqual([]); + expect(result.coreReadAuthorized).toBe(false); expect(result.effectiveTools).toEqual([]); expect(result.uncompactedEffectiveTools).toEqual([]); expect(result.tools).toEqual([]); @@ -198,6 +205,7 @@ describe("applyPromptBuildToolsAllow", () => { tools: [pluginTool], catalogRef, codeModeControlsEnabled: false, + coreReadAuthorized: false, }); expect(result.activeToolNames).toEqual(["tool_search"]); @@ -225,6 +233,7 @@ describe("applyPromptBuildToolsAllow", () => { tools: [{ name: "read" }, { name: "write" }], catalogRef, codeModeControlsEnabled: false, + coreReadAuthorized: true, }; applyPromptBuildToolsAllow({ ...params, toolsAllow: ["read"] }); diff --git a/src/agents/embedded-agent-runner/run/attempt-result.ts b/src/agents/embedded-agent-runner/run/attempt-result.ts index 3075109804d2..f5f31c57d032 100644 --- a/src/agents/embedded-agent-runner/run/attempt-result.ts +++ b/src/agents/embedded-agent-runner/run/attempt-result.ts @@ -73,6 +73,7 @@ type EmbeddedAttemptResultState = Pick< | "lastAssistant" | "currentAttemptAssistant" | "currentAttemptCompletedAssistant" + | "codeModeReconciliationCandidate" | "successfulNestedToolNames" | "attemptUsage" | "promptCache" @@ -396,6 +397,7 @@ export function completeEmbeddedAttemptResult( ...state, replayMetadata, currentAttemptReplayMetadata, + codeModeReconciliationCandidate: state.codeModeReconciliationCandidate, itemLifecycle: getItemLifecycle(), assistantTurns: getAssistantTurnCount(), setTerminalLifecycleMeta, diff --git a/src/agents/embedded-agent-runner/run/attempt-session-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-session-prepare.ts index c05204d6d8bd..09791639de35 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-prepare.ts @@ -222,6 +222,8 @@ export async function prepareEmbeddedAttemptAgentSession(input: { }; setActiveSessionSystemPrompt(input.initialSystemPrompt); let didDeliverSourceReplyViaMessageTool = false; + let codeModeReconciliationCandidate = false; + let codeModeReconciliationReadAuthorized = false; const markSourceReplyDelivered = () => { didDeliverSourceReplyViaMessageTool = true; }; @@ -231,7 +233,14 @@ export async function prepareEmbeddedAttemptAgentSession(input: { onDeliveredSourceReply: markSourceReplyDelivered, }); if (input.clientToolPreparation.codeModeControlsEnabledForRun) { - installCodeModeRepairHook({ agent: activeSession.agent }); + installCodeModeRepairHook({ + agent: activeSession.agent, + onReconciliationCandidate: () => { + if (codeModeReconciliationReadAuthorized) { + codeModeReconciliationCandidate = true; + } + }, + }); } input.markStage("agent-session"); @@ -239,9 +248,13 @@ export async function prepareEmbeddedAttemptAgentSession(input: { activeSession, allCustomTools, ...clientToolRuntime, + getCodeModeReconciliationCandidate: () => codeModeReconciliationCandidate, hasDeliveredSourceReply: () => didDeliverSourceReplyViaMessageTool, hookRunner, markSourceReplyDelivered, + setCodeModeReconciliationReadAuthorized: (value: boolean) => { + codeModeReconciliationReadAuthorized = clientToolRuntime.coreReadAuthorized && value; + }, setActiveSessionSystemPrompt, settingsManager, }; diff --git a/src/agents/embedded-agent-runner/run/attempt-session.test.ts b/src/agents/embedded-agent-runner/run/attempt-session.test.ts index 3c04b5a66e0f..f3ff6c65a20a 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session.test.ts @@ -90,6 +90,7 @@ const attempt = { function createInput(options?: { activationError?: Error; codeModeControlsEnabledForRun?: boolean; + coreReadAllowed?: boolean; }) { const events: string[] = []; const settingsManager = { id: "settings" }; @@ -117,6 +118,8 @@ function createInput(options?: { const allCustomTools = [{ name: "custom" }]; const clientToolRuntime = { builtinToolNames: new Set(["read"]), + coreBuiltinToolNames: new Set(options?.coreReadAllowed === false ? [] : ["read"]), + coreReadAuthorized: options?.coreReadAllowed !== false, clientToolCallSlots: [], clientToolDefs: [], clientToolLoopDetection: { enabled: true }, @@ -124,6 +127,7 @@ function createInput(options?: { replaySafeTools: new Set(allCustomTools), }; let onDeliveredSourceReply: (() => void) | undefined; + let onReconciliationCandidate: (() => void) | undefined; hoisted.createPreparedEmbeddedAgentSettingsManager.mockReturnValue(settingsManager); hoisted.resolveEffectiveCompactionMode.mockReturnValue("safeguard"); @@ -149,9 +153,12 @@ function createInput(options?: { onDeliveredSourceReply = input.onDeliveredSourceReply; }, ); - hoisted.installCodeModeRepairHook.mockImplementation(() => { - events.push("install-code-mode-repair"); - }); + hoisted.installCodeModeRepairHook.mockImplementation( + (input: { onReconciliationCandidate?: () => void }) => { + onReconciliationCandidate = input.onReconciliationCandidate; + events.push("install-code-mode-repair"); + }, + ); return { activeSession, @@ -184,6 +191,7 @@ function createInput(options?: { transcriptLifecycle: transcriptLifecycle as never, sessionManager: sessionManager as never, }, + markCodeModeReconciliationCandidate: () => onReconciliationCandidate?.(), onDeliveredSourceReply: () => onDeliveredSourceReply?.(), resourceLoader, setActiveToolsByName, @@ -239,6 +247,10 @@ describe("prepareEmbeddedAttemptAgentSession", () => { expect(result.hasDeliveredSourceReply()).toBe(false); fixture.onDeliveredSourceReply(); expect(result.hasDeliveredSourceReply()).toBe(true); + expect(result.getCodeModeReconciliationCandidate()).toBe(false); + result.setCodeModeReconciliationReadAuthorized(true); + fixture.markCodeModeReconciliationCandidate(); + expect(result.getCodeModeReconciliationCandidate()).toBe(true); }); it("does not install Code Mode repair when the run kept direct tools", async () => { @@ -250,6 +262,23 @@ describe("prepareEmbeddedAttemptAgentSession", () => { expect(fixture.events).not.toContain("install-code-mode-repair"); }); + it.each([ + ["the effective core tools exclude read", false, true], + ["the final prompt policy removes read", true, false], + ])("withholds reconciliation when %s", async (_label, coreReadAllowed, finalReadAllowed) => { + const fixture = createInput({ coreReadAllowed }); + + const result = await prepareEmbeddedAttemptAgentSession(fixture.input); + + expect(hoisted.installCodeModeRepairHook).toHaveBeenCalledWith({ + agent: fixture.activeSession.agent, + onReconciliationCandidate: expect.any(Function), + }); + result.setCodeModeReconciliationReadAuthorized(finalReadAllowed); + fixture.markCodeModeReconciliationCandidate(); + expect(result.getCodeModeReconciliationCandidate()).toBe(false); + }); + it("leaves overflow recovery with the session when no model budget was resolved", async () => { const fixture = createInput(); fixture.input.attempt = { diff --git a/src/agents/embedded-agent-runner/run/attempt-settle.ts b/src/agents/embedded-agent-runner/run/attempt-settle.ts index c4c9fad64d96..f873e978a0e4 100644 --- a/src/agents/embedded-agent-runner/run/attempt-settle.ts +++ b/src/agents/embedded-agent-runner/run/attempt-settle.ts @@ -129,8 +129,11 @@ export async function runEmbeddedAttemptSettledPhase( agentSession: { activeSession, clientToolCallSlots, + coreReadAuthorized, + getCodeModeReconciliationCandidate, hasDeliveredSourceReply, hookRunner, + setCodeModeReconciliationReadAuthorized, setActiveSessionSystemPrompt, settingsManager, }, @@ -279,6 +282,7 @@ export async function runEmbeddedAttemptSettledPhase( uncompactedEffectiveTools, tools, codeModeControlsEnabled: toolBase.codeModeControlsEnabledForRun, + coreReadAuthorized, toolSearchCatalogRef: toolBase.toolSearchCatalogRef, forceToolNames: [ ...(toolBase.forceDirectMessageTool ? ["message"] : []), @@ -326,6 +330,7 @@ export async function runEmbeddedAttemptSettledPhase( setPromptCacheChangesForTurn: (changes) => { promptCacheChangesForTurn = changes; }, + setCodeModeReconciliationReadAuthorized, setFinalPromptText: (prompt) => { finalPromptText = prompt; }, @@ -620,6 +625,7 @@ export async function runEmbeddedAttemptSettledPhase( lastAssistant, currentAttemptAssistant, currentAttemptCompletedAssistant, + codeModeReconciliationCandidate: getCodeModeReconciliationCandidate(), successfulNestedToolNames, attemptUsage, promptCache: sessionRuntimeState.promptCache, diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-finalize.test.ts b/src/agents/embedded-agent-runner/run/attempt-stream-finalize.test.ts index 5177899baad5..1402cd81c105 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-finalize.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-finalize.test.ts @@ -122,8 +122,11 @@ function createFixture(overrides: FixtureOverrides = {}) { agentSession: { activeSession, clientToolCallSlots: [], + coreReadAuthorized: true, + getCodeModeReconciliationCandidate: vi.fn(() => false), hasDeliveredSourceReply: vi.fn(() => false), hookRunner: {}, + setCodeModeReconciliationReadAuthorized: vi.fn(), setActiveSessionSystemPrompt: vi.fn(), settingsManager: { getCompactionReserveTokens: vi.fn(() => 1_000) }, }, diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-prepare.ts index 9dba6f9d0742..52e88fb62591 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-prepare.ts @@ -49,6 +49,7 @@ import { } from "./attempt-tool-construction-plan.js"; import { buildEmbeddedAttemptToolRunContext } from "./attempt-tool-run-context.js"; import { TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES } from "./attempt-tool-search-run-plan.js"; +import { isCodeModeReconciliationTool } from "./code-mode-reconciliation.js"; import type { EmbeddedRunAttemptParams } from "./types.js"; type OpenClawCodingToolsOptions = NonNullable[0]>; @@ -74,15 +75,18 @@ export function prepareEmbeddedAttemptToolBase(params: { toolSearchCatalogExecutor: ToolSearchCatalogToolExecutor; }) { const { attempt } = params; - const forceDirectMessageTool = messageToolOwnsVisibleReply(attempt); - const toolsAllowWithForcedRuntimeTools = mergeForcedEmbeddedAttemptToolsAllow( - attempt.toolsAllow, - { - forceMessageTool: forceDirectMessageTool, - forceToolNames: - attempt.swarmCollector && attempt.swarmOutputSchema ? ["structured_output"] : undefined, - }, - ); + const forceDirectMessageTool = + attempt.forceCodeModeReconciliationTools === true + ? false + : messageToolOwnsVisibleReply(attempt); + const toolsAllowWithForcedRuntimeTools = + attempt.forceCodeModeReconciliationTools === true + ? ["read"] + : mergeForcedEmbeddedAttemptToolsAllow(attempt.toolsAllow, { + forceMessageTool: forceDirectMessageTool, + forceToolNames: + attempt.swarmCollector && attempt.swarmOutputSchema ? ["structured_output"] : undefined, + }); const toolsEnabled = supportsModelTools(attempt.model); const isRawModelRun = attempt.modelRun === true || attempt.promptMode === "none"; const toolConstructionPlan = resolveEmbeddedAttemptToolConstructionPlan({ @@ -108,6 +112,7 @@ export function prepareEmbeddedAttemptToolBase(params: { skillWorkshopProposalOnly: attempt.skillWorkshopProposalOnly, toolsAllow: attempt.toolsAllow, forceCodeModeControls: attempt.forceCodeModeTools, + forceDirectTools: attempt.forceCodeModeReconciliationTools, }); if (isCodeModeDiagnosticEnabled()) { logCodeModeDiagnostic(log, "activation", { @@ -373,9 +378,12 @@ export function prepareEmbeddedAttemptToolBase(params: { params.markCoreToolStage("attempt:tools-allow"); return filteredTools; })(); - const toolsRaw = attempt.forceRestartSafeTools - ? constructedToolsRaw.filter((tool) => isAgentToolRestartSafe(tool, restartSafetyOptions)) - : constructedToolsRaw; + const toolsRaw = + attempt.forceCodeModeReconciliationTools === true + ? constructedToolsRaw.filter(isCodeModeReconciliationTool) + : attempt.forceRestartSafeTools + ? constructedToolsRaw.filter((tool) => isAgentToolRestartSafe(tool, restartSafetyOptions)) + : constructedToolsRaw; if (attempt.forceRestartSafeTools) { log.info( `restart-safe recovery tool policy retained ${toolsRaw.length}/${constructedToolsRaw.length} concrete tools`, diff --git a/src/agents/embedded-agent-runner/run/code-mode-reconciliation.test.ts b/src/agents/embedded-agent-runner/run/code-mode-reconciliation.test.ts new file mode 100644 index 000000000000..79c4cb9d25d6 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/code-mode-reconciliation.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { makeEmbeddedRunnerAttempt } from "../../test-helpers/embedded-agent-runner-e2e-fixtures.js"; +import { + activateCodeModeReconciliation, + isCodeModeReconciliationTool, +} from "./code-mode-reconciliation.js"; +import { createEmbeddedRunTerminalRetryState } from "./terminal-retry-state.js"; + +function eligibleAttempt() { + return makeEmbeddedRunnerAttempt({ + codeModeReconciliationCandidate: true, + itemLifecycle: { startedCount: 2, completedCount: 2, activeCount: 0 }, + }); +} + +function activates(overrides = {}, hostOwnsToolSurface = true) { + return activateCodeModeReconciliation({ + attempt: { ...eligibleAttempt(), ...overrides } as ReturnType, + hostOwnsToolSurface, + retryState: createEmbeddedRunTerminalRetryState(), + activateInternalPrompt: () => undefined, + }); +} + +describe("Code Mode reconciliation", () => { + it("admits one quiescent candidate", () => { + expect(activates()).toBe(true); + }); + + it.each([ + ["active tool", { itemLifecycle: { startedCount: 2, completedCount: 1, activeCount: 1 } }], + ["async work", { toolMetas: [{ toolName: "exec", asyncStarted: true }] }], + ["message delivery", { didSendViaMessagingTool: true }], + ["child session", { acceptedSessionSpawns: [{ runId: "child" }] }], + ["approval", { didSendDeterministicApprovalPrompt: true }], + ["yield", { yieldDetected: true }], + ["plugin-owned transport", {}, false], + ])("rejects a candidate with %s", (_label, overrides, hostOwnsToolSurface = true) => { + expect(activates(overrides, hostOwnsToolSurface)).toBe(false); + }); + + it("exposes only the audited core observation tool", () => { + expect( + [ + "read", + "find", + "glob", + "grep", + "ls", + "search", + "exec", + "write", + "apply_patch", + "message", + "sessions_spawn", + "web_fetch", + ].filter((name) => isCodeModeReconciliationTool({ name })), + ).toEqual(["read"]); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/code-mode-reconciliation.ts b/src/agents/embedded-agent-runner/run/code-mode-reconciliation.ts new file mode 100644 index 000000000000..a09b654bb170 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/code-mode-reconciliation.ts @@ -0,0 +1,63 @@ +import { projectAgentRunAttemptTerminal } from "../../agent-run-terminal-outcome.js"; +import { normalizeToolPolicyName } from "../../tool-policy.js"; +import type { EmbeddedRunTerminalRetryState } from "./terminal-retry-state.js"; +import type { EmbeddedRunAttemptResult } from "./types.js"; + +const CODE_MODE_RECONCILIATION_PROMPT = + "The previous Code Mode mutation may have partially applied. Do not repeat or finish any mutation. Use only the available read-only inspection tools to determine the authoritative current state, then report exactly what applied, what did not, what remains unknown, and what work is still required."; + +const RECONCILIATION_TOOL_NAMES = new Set(["read"]); + +export function isCodeModeReconciliationTool(tool: { name?: string }): boolean { + return RECONCILIATION_TOOL_NAMES.has(normalizeToolPolicyName(tool.name ?? "")); +} + +function shouldRetryCodeModeReconciliation(params: { + attempt: EmbeddedRunAttemptResult; + hostOwnsToolSurface: boolean; + aborted: boolean; + timedOut: boolean; + promptError: unknown; +}): boolean { + const { attempt } = params; + return ( + attempt.codeModeReconciliationCandidate === true && + params.hostOwnsToolSurface && + !params.aborted && + !params.timedOut && + !params.promptError && + attempt.itemLifecycle.activeCount === 0 && + attempt.itemLifecycle.startedCount === attempt.itemLifecycle.completedCount && + !attempt.clientToolCalls && + !attempt.yieldDetected && + !attempt.didSendDeterministicApprovalPrompt && + !attempt.runtimeContinuationStarted && + !attempt.toolMetas.some((entry) => entry.asyncStarted === true) && + (attempt.acceptedSessionSpawns?.length ?? 0) === 0 && + !attempt.didSendViaMessagingTool && + (attempt.successfulCronAdds ?? 0) === 0 + ); +} + +export function activateCodeModeReconciliation(params: { + attempt: EmbeddedRunAttemptResult; + hostOwnsToolSurface: boolean; + retryState: EmbeddedRunTerminalRetryState; + activateInternalPrompt: (prompt: string) => void; +}): boolean { + const terminal = projectAgentRunAttemptTerminal(params.attempt.terminal); + if ( + params.retryState.codeModeReconciliationAttempts >= 1 || + !shouldRetryCodeModeReconciliation({ + attempt: params.attempt, + hostOwnsToolSurface: params.hostOwnsToolSurface, + ...terminal, + }) + ) { + return false; + } + params.retryState.codeModeReconciliationAttempts += 1; + params.retryState.forceCodeModeReconciliationTools = true; + params.activateInternalPrompt(CODE_MODE_RECONCILIATION_PROMPT); + return true; +} diff --git a/src/agents/embedded-agent-runner/run/code-mode-repair.test.ts b/src/agents/embedded-agent-runner/run/code-mode-repair.test.ts index bd78f64013ce..cf046744a5a2 100644 --- a/src/agents/embedded-agent-runner/run/code-mode-repair.test.ts +++ b/src/agents/embedded-agent-runner/run/code-mode-repair.test.ts @@ -66,9 +66,12 @@ function completedResult(): AgentToolResult { }; } -function createAgent(previous?: Agent["afterToolOutcome"]): Agent { +function createAgent( + previous?: Agent["afterToolOutcome"], + onReconciliationCandidate?: () => void, +): Agent { const agent = { afterToolOutcome: previous } as Agent; - installCodeModeRepairHook({ agent }); + installCodeModeRepairHook({ agent, onReconciliationCandidate }); return agent; } @@ -199,7 +202,13 @@ describe("installCodeModeRepairHook", () => { }); it("never offers a retry after bridge dispatch", async () => { - const agent = createAgent(); + const onReconciliationCandidate = vi.fn(); + const agent = createAgent(undefined, onReconciliationCandidate); + const assistantMessage = { + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "exec", arguments: {} }], + timestamp: 1, + } as unknown as AfterToolOutcomeContext["assistantMessage"]; const failure = failedResult({ failurePhase: "bridge", bridgeDispatchStarted: true, @@ -209,6 +218,7 @@ describe("installCodeModeRepairHook", () => { const result = await agent.afterToolOutcome?.( outcome({ + assistantMessage, result: failure, }), ); @@ -225,6 +235,7 @@ describe("installCodeModeRepairHook", () => { }, }); expect(payload.output).toEqual([{ type: "text", text: "before dispatch failure" }]); + expect(onReconciliationCandidate).toHaveBeenCalledOnce(); }); it("offers one repair for an authenticated nested no-start bridge failure", async () => { diff --git a/src/agents/embedded-agent-runner/run/code-mode-repair.ts b/src/agents/embedded-agent-runner/run/code-mode-repair.ts index 2f6bd9e4d844..fb816dc85f5a 100644 --- a/src/agents/embedded-agent-runner/run/code-mode-repair.ts +++ b/src/agents/embedded-agent-runner/run/code-mode-repair.ts @@ -218,7 +218,10 @@ function hookFailure( } /** Installs one bounded, side-effect-aware Code Mode repair opportunity. */ -export function installCodeModeRepairHook(params: { agent: Agent }): void { +export function installCodeModeRepairHook(params: { + agent: Agent; + onReconciliationCandidate?: () => void; +}): void { const previousAfterToolOutcome = params.agent.afterToolOutcome?.bind(params.agent); let repairState: RepairState = "ready"; let repairOfferedBy: AfterToolOutcomeContext["assistantMessage"] | undefined; @@ -295,6 +298,12 @@ export function installCodeModeRepairHook(params: { agent: Agent }): void { effective.toolCall.name === CODE_MODE_WAIT_TOOL_NAME ) { repairState = "consumed"; + if ( + effective.toolCall.name === CODE_MODE_EXEC_TOOL_NAME && + effective.assistantMessage.content.filter((entry) => entry.type === "toolCall").length === 1 + ) { + params.onReconciliationCandidate?.(); + } return renderFailure({ failure, allowed: false, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 0411dca0df65..233f64fa0485 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -178,6 +178,8 @@ export type RunEmbeddedAgentParams = { swarmOutputSchema?: Record; /** Restrict this reconstructed run to restart-safe tools. */ forceRestartSafeTools?: boolean; + /** Restrict one internal post-mutation recovery attempt to audited core reads. */ + forceCodeModeReconciliationTools?: boolean; /** Preserve Code Mode controls for a replay-safe restart recovery turn. */ forceCodeModeTools?: boolean; /** Internal one-shot model probe mode: no tools, no workspace/chat prompt policy. */ diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index f95b8c26ffe6..ad5db0c92187 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -489,6 +489,7 @@ export async function dispatchEmbeddedRunAttempt(input: { swarmCollector: params.swarmCollector, swarmOutputSchema: params.swarmOutputSchema, forceRestartSafeTools: params.forceRestartSafeTools, + forceCodeModeReconciliationTools: params.forceCodeModeReconciliationTools, forceCodeModeTools: params.forceCodeModeTools, forceMessageTool: params.forceMessageTool, enableHeartbeatTool: params.enableHeartbeatTool, diff --git a/src/agents/embedded-agent-runner/run/terminal-retry-state.ts b/src/agents/embedded-agent-runner/run/terminal-retry-state.ts index d14193ccf0b9..c949025afe8e 100644 --- a/src/agents/embedded-agent-runner/run/terminal-retry-state.ts +++ b/src/agents/embedded-agent-runner/run/terminal-retry-state.ts @@ -7,6 +7,8 @@ export type EmbeddedRunTerminalRetryState = { compactionContinuationAttempts: number; compactionContinuationInstruction: string | null; beforeFinalizeRevisionAttempts: number; + codeModeReconciliationAttempts: number; + forceCodeModeReconciliationTools: boolean; }; export function createEmbeddedRunTerminalRetryState(): EmbeddedRunTerminalRetryState { @@ -17,5 +19,7 @@ export function createEmbeddedRunTerminalRetryState(): EmbeddedRunTerminalRetryS compactionContinuationAttempts: 0, compactionContinuationInstruction: null, beforeFinalizeRevisionAttempts: 0, + codeModeReconciliationAttempts: 0, + forceCodeModeReconciliationTools: false, }; } diff --git a/src/agents/embedded-agent-runner/run/types.ts b/src/agents/embedded-agent-runner/run/types.ts index 7376fe6a05a3..aff185f08be2 100644 --- a/src/agents/embedded-agent-runner/run/types.ts +++ b/src/agents/embedded-agent-runner/run/types.ts @@ -346,6 +346,8 @@ export type EmbeddedRunAttemptResult = { * how config-enabled code mode stays visible as a no-op on harness routes. */ codeModeEngaged?: boolean; + /** Host-authenticated request for one bounded post-mutation inspection attempt. */ + codeModeReconciliationCandidate?: boolean; /** Completed assistant round trips observed during this attempt. */ assistantTurns?: number; /** Inner bridge call counts from this attempt's tool-search/code-mode catalog. */ diff --git a/src/agents/tool-surface-plan.ts b/src/agents/tool-surface-plan.ts index f753fb03e007..edce1e4aa875 100644 --- a/src/agents/tool-surface-plan.ts +++ b/src/agents/tool-surface-plan.ts @@ -26,6 +26,7 @@ type AgentToolSurfacePlanParams = { skillWorkshopProposalOnly?: boolean; toolsAllow?: readonly string[]; forceCodeModeControls?: boolean; + forceDirectTools?: boolean; }; export function resolveAgentToolSurfacePlan(params: AgentToolSurfacePlanParams) { @@ -55,12 +56,16 @@ export function resolveAgentToolSurfacePlan(params: AgentToolSurfacePlanParams) ); const codeModeControlsEnabled = toolsAvailable && + params.forceDirectTools !== true && // Restart recovery continues one provider turn. Keep its original control // schema even when the reloaded config disables Code Mode for new turns. (params.forceCodeModeControls === true || isCodeModeEngagedForModel(codeModeConfig, params.model)); const toolSearchControlsEnabled = - toolsAvailable && !codeModeControlsEnabled && toolSearchConfig.enabled; + toolsAvailable && + params.forceDirectTools !== true && + !codeModeControlsEnabled && + toolSearchConfig.enabled; return { codeModeControlsEnabled, toolSearchControlsEnabled,