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.
This commit is contained in:
Shakker
2026-08-23 04:05:36 +01:00
committed by GitHub
parent 672ac118c3
commit 32bb08dbbf
27 changed files with 421 additions and 23 deletions
@@ -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({
@@ -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<ReturnType<typeof loadSharedRunIntegrationHarness>>;
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"),
});
});
});
@@ -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";
@@ -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,
@@ -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);
@@ -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<typeof getPluginToolMeta>[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,
@@ -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,
@@ -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) },
},
@@ -64,6 +64,7 @@ import type { submitEmbeddedAttemptPrompt } from "./attempt-prompt-submit.js";
type PromptPhaseInput = Parameters<typeof runEmbeddedAttemptPromptPhase>[0];
type PromptPhaseState = ReturnType<PromptPhaseInput["lifecycle"]["readState"]>;
type AssemblyCall = {
applyPromptBuildToolsAllow: (toolsAllow: string[] | undefined) => string[];
setLeasedSteering: (lease: { leaseId: string; runIds: string[] }) => void;
};
type PromptPreflightCall = Parameters<typeof prepareEmbeddedAttemptPromptPreflight>[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";
@@ -122,6 +122,7 @@ export async function runEmbeddedAttemptPromptPhase(input: {
tools: Array<{ name: string }>;
toolSearchCatalogRef?: Parameters<typeof applyPromptBuildToolsAllow>[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) => {
@@ -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,
@@ -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"] });
@@ -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,
@@ -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,
};
@@ -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 = {
@@ -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,
@@ -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) },
},
@@ -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<Parameters<typeof createOpenClawCodingTools>[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`,
@@ -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<typeof eligibleAttempt>,
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"]);
});
});
@@ -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;
}
@@ -66,9 +66,12 @@ function completedResult(): AgentToolResult<unknown> {
};
}
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 () => {
@@ -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,
@@ -178,6 +178,8 @@ export type RunEmbeddedAgentParams = {
swarmOutputSchema?: Record<string, unknown>;
/** 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. */
@@ -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,
@@ -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,
};
}
@@ -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. */
+6 -1
View File
@@ -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,