Skip to content

Commit 7df207e

Browse files
committed
fix(agents): prevent missing_tool_result from triggering cross-provider model fallback (#95474)
When a Codex bash call hangs, the event-projector synthesizes a missing_tool_result error. This is a local tool-execution failure, not a provider/model failure — switching providers cannot fix it. Previously, the error was unclassified and consumed model fallback candidates. Add isLocalToolExecutionError to detect the sentinel error text and abort the outer fallback chain instead of trying the next candidate.
1 parent 0030a19 commit 7df207e

4 files changed

Lines changed: 89 additions & 0 deletions

File tree

src/agents/failover-error.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
coerceToFailoverError,
1111
describeFailoverError,
1212
FailoverError,
13+
isLocalToolExecutionError,
1314
isNonProviderRuntimeCoordinationError,
1415
isSignalTimeoutReason,
1516
isTimeoutError,
@@ -1306,6 +1307,35 @@ describe("failover-error", () => {
13061307
expect(isNonProviderRuntimeCoordinationError(undefined)).toBe(false);
13071308
});
13081309
});
1310+
1311+
describe("isLocalToolExecutionError", () => {
1312+
const MISSING_TOOL_RESULT_MESSAGE =
1313+
"OpenClaw recorded a native Codex tool.call without a matching tool.result before the turn completed.";
1314+
1315+
it("returns true for exact missing_tool_result error message", () => {
1316+
expect(isLocalToolExecutionError(MISSING_TOOL_RESULT_MESSAGE)).toBe(true);
1317+
});
1318+
1319+
it("returns true when the missing_tool_result message is wrapped as an Error", () => {
1320+
expect(isLocalToolExecutionError(new Error(MISSING_TOOL_RESULT_MESSAGE))).toBe(true);
1321+
});
1322+
1323+
it("returns false for provider errors (rate limit, auth, timeout)", () => {
1324+
expect(isLocalToolExecutionError(OPENAI_RATE_LIMIT_MESSAGE)).toBe(false);
1325+
expect(isLocalToolExecutionError(ANTHROPIC_OVERLOADED_PAYLOAD)).toBe(false);
1326+
expect(isLocalToolExecutionError(BEDROCK_THROTTLING_EXCEPTION_MESSAGE)).toBe(false);
1327+
});
1328+
1329+
it("returns false for null, undefined, and empty string", () => {
1330+
expect(isLocalToolExecutionError(null)).toBe(false);
1331+
expect(isLocalToolExecutionError(undefined)).toBe(false);
1332+
expect(isLocalToolExecutionError("")).toBe(false);
1333+
});
1334+
1335+
it("returns false for non-Error objects without the sentinel text", () => {
1336+
expect(isLocalToolExecutionError({ status: 500, message: "internal error" })).toBe(false);
1337+
});
1338+
});
13091339
});
13101340

13111341
describe("buildFailoverRemediationHint", () => {

src/agents/failover-error.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,30 @@ export function isNonProviderRuntimeCoordinationError(err: unknown): boolean {
364364
return resolveFailoverClassificationFromError(err) === null;
365365
}
366366

367+
/**
368+
* The sentinel text that Codex app-server (event-projector.ts) stores when a
369+
* native tool.call finished without a matching tool.result. The model fallback
370+
* chain must not consume candidates on this — no other provider can fix a local
371+
* command that hung or was reaped. See #95474.
372+
*/
373+
const MISSING_TOOL_RESULT_MESSAGE_PREFIX =
374+
"OpenClaw recorded a native Codex tool.call without a matching tool.result";
375+
376+
/**
377+
* True when the error is a local tool-execution failure (synthetic
378+
* missing_tool_result from a hung native Codex tool.call) rather than a true
379+
* provider/model failure. The model fallback chain must abort on these instead
380+
* of consuming candidate slots — switching providers cannot fix a local command.
381+
* See #95474.
382+
*/
383+
export function isLocalToolExecutionError(err: unknown): boolean {
384+
if (!err) {
385+
return false;
386+
}
387+
const message = typeof err === "string" ? err : err instanceof Error ? err.message : null;
388+
return typeof message === "string" && message.includes(MISSING_TOOL_RESULT_MESSAGE_PREFIX);
389+
}
390+
367391
function hasTimeoutHint(err: unknown): boolean {
368392
if (!err) {
369393
return false;

src/agents/model-fallback.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,6 +1271,33 @@ describe("runWithModelFallback", () => {
12711271
expect(run).toHaveBeenCalledTimes(1);
12721272
});
12731273

1274+
it("aborts the fallback chain on missing_tool_result instead of trying every model (#95474)", async () => {
1275+
const cfg = makeCfg({
1276+
agents: {
1277+
defaults: {
1278+
model: {
1279+
primary: "openai/gpt-5.4",
1280+
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-4.1-mini"],
1281+
},
1282+
},
1283+
},
1284+
});
1285+
const missingToolResultError = new Error(
1286+
"OpenClaw recorded a native Codex tool.call without a matching tool.result before the turn completed.",
1287+
);
1288+
const run = vi.fn().mockRejectedValue(missingToolResultError);
1289+
1290+
await expect(
1291+
runWithModelFallback({
1292+
cfg,
1293+
provider: "openai",
1294+
model: "gpt-5.4",
1295+
run,
1296+
}),
1297+
).rejects.toBe(missingToolResultError);
1298+
expect(run).toHaveBeenCalledTimes(1);
1299+
});
1300+
12741301
it("keeps provider failover metadata authoritative over nested session locks", async () => {
12751302
const cfg = makeCfg({
12761303
agents: {

src/agents/model-fallback.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
coerceToFailoverError,
3939
describeFailoverError,
4040
isFailoverError,
41+
isLocalToolExecutionError,
4142
isNonProviderRuntimeCoordinationError,
4243
isTimeoutError,
4344
} from "./failover-error.js";
@@ -1734,6 +1735,13 @@ async function runWithModelFallbackInternal<T>(
17341735
if (isMissingAgentHarnessError(err)) {
17351736
throw err;
17361737
}
1738+
// Local tool-execution failures (synthetic missing_tool_result from a
1739+
// hung native Codex tool.call) are not provider/model failures. Throwing
1740+
// here prevents the fallback chain from switching providers for a local
1741+
// command that no other provider can fix. See #95474.
1742+
if (isLocalToolExecutionError(err)) {
1743+
throw err;
1744+
}
17371745
const normalized =
17381746
coerceToFailoverError(err, {
17391747
provider: candidate.provider,

0 commit comments

Comments
 (0)