Skip to content

Commit b9c6414

Browse files
authored
fix(agents): keep missing tool results on current model (#95543)
1 parent 84bcd50 commit b9c6414

3 files changed

Lines changed: 147 additions & 6 deletions

File tree

src/agents/failover-error.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,6 +1288,20 @@ describe("failover-error", () => {
12881288
expect(isNonProviderRuntimeCoordinationError(wrappedTakeover)).toBe(true);
12891289
});
12901290

1291+
it("returns true for Codex missing tool-result local execution failures", () => {
1292+
const missingToolResultMessage =
1293+
"OpenClaw recorded a native Codex tool.call without a matching tool.result before the turn completed.";
1294+
expect(isNonProviderRuntimeCoordinationError(new Error(missingToolResultMessage))).toBe(true);
1295+
expect(isNonProviderRuntimeCoordinationError({ reason: "missing_tool_result" })).toBe(true);
1296+
expect(
1297+
isNonProviderRuntimeCoordinationError({
1298+
message: "codex app-server turn failed",
1299+
cause: { result: { reason: "missing_tool_result" } },
1300+
}),
1301+
).toBe(true);
1302+
expect(resolveFailoverReasonFromError(new Error(missingToolResultMessage))).toBeNull();
1303+
});
1304+
12911305
it("returns false for plain timeouts and provider errors", () => {
12921306
const timeoutErr = Object.assign(new Error("operation timed out"), { name: "TimeoutError" });
12931307
expect(isNonProviderRuntimeCoordinationError(timeoutErr)).toBe(false);
@@ -1302,9 +1316,25 @@ describe("failover-error", () => {
13021316
cause: makeSessionLockError(),
13031317
}),
13041318
).toBe(false);
1319+
expect(
1320+
isNonProviderRuntimeCoordinationError({
1321+
status: 503,
1322+
message: "upstream overloaded",
1323+
cause: { result: { reason: "missing_tool_result" } },
1324+
}),
1325+
).toBe(false);
13051326
expect(isNonProviderRuntimeCoordinationError(null)).toBe(false);
13061327
expect(isNonProviderRuntimeCoordinationError(undefined)).toBe(false);
13071328
});
1329+
1330+
it("does not suppress provider fallback for unrelated free text mentioning the marker", () => {
1331+
expect(isNonProviderRuntimeCoordinationError("reason=missing_tool_result")).toBe(false);
1332+
expect(
1333+
isNonProviderRuntimeCoordinationError(
1334+
new Error("provider returned diagnostic text: reason=missing_tool_result"),
1335+
),
1336+
).toBe(false);
1337+
});
13081338
});
13091339
});
13101340

src/agents/failover-error.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { isSessionWriteLockAcquireError } from "./session-write-lock-error.js";
2020

2121
const ABORT_TIMEOUT_RE = /request was aborted|request aborted/i;
2222
const MAX_FAILOVER_CAUSE_DEPTH = 25;
23+
const MISSING_TOOL_RESULT_REASON = "missing_tool_result";
24+
const MISSING_TOOL_RESULT_TEXT_RE = /native Codex tool\.call without a matching tool\.result/i;
2325

2426
/** Structured error used to carry model fallback/failover metadata across layers. */
2527
export class FailoverError extends Error {
@@ -344,15 +346,65 @@ function hasEmbeddedAttemptSessionTakeover(err: unknown, seen: Set<object> = new
344346
);
345347
}
346348

349+
function readField(value: unknown, key: string): unknown {
350+
if (!value || typeof value !== "object") {
351+
return undefined;
352+
}
353+
return (value as Record<string, unknown>)[key];
354+
}
355+
356+
function readStringField(value: unknown, key: string): string | undefined {
357+
const field = readField(value, key);
358+
return typeof field === "string" ? field : undefined;
359+
}
360+
361+
function isMissingToolResultMessage(value: string): boolean {
362+
return MISSING_TOOL_RESULT_TEXT_RE.test(value);
363+
}
364+
365+
function isMissingToolResultMarker(value: string): boolean {
366+
return value.trim() === MISSING_TOOL_RESULT_REASON;
367+
}
368+
369+
function readMissingToolResultMarker(err: unknown): true | undefined {
370+
const message = readDirectErrorMessage(err);
371+
if (message && isMissingToolResultMessage(message)) {
372+
return true;
373+
}
374+
for (const key of ["code", "reason", "status"] as const) {
375+
const value = readStringField(err, key);
376+
if (value && isMissingToolResultMarker(value)) {
377+
return true;
378+
}
379+
}
380+
const output = readStringField(err, "output");
381+
if (output && isMissingToolResultMessage(output)) {
382+
return true;
383+
}
384+
const resultReason = readStringField(readField(err, "result"), "reason");
385+
const detailReason = readStringField(readField(err, "detail"), "reason");
386+
if (resultReason === MISSING_TOOL_RESULT_REASON || detailReason === MISSING_TOOL_RESULT_REASON) {
387+
return true;
388+
}
389+
return undefined;
390+
}
391+
392+
function hasMissingToolResultFailure(err: unknown): boolean {
393+
return findErrorProperty(err, readMissingToolResultMarker) === true;
394+
}
395+
347396
/**
348-
* True when the error is a local runtime coordination error (session write-lock
349-
* timeout or embedded attempt session takeover) rather than a provider/model
350-
* failure. The model fallback chain must abort on these instead of consuming
351-
* candidate slots — retrying any model would hit the same local condition.
352-
* See #83510.
397+
* True when the error is a local runtime coordination/tool-execution error
398+
* rather than a provider/model failure. The model fallback chain must abort on
399+
* these instead of consuming candidate slots — retrying any model would hit the
400+
* same local condition. See #83510 and #95474.
353401
*/
354402
export function isNonProviderRuntimeCoordinationError(err: unknown): boolean {
355-
if (!hasSessionWriteLockContention(err) && !hasEmbeddedAttemptSessionTakeover(err)) {
403+
if (
404+
!hasSessionWriteLockContention(err) &&
405+
!hasEmbeddedAttemptSessionTakeover(err) &&
406+
!hasMissingToolResultFailure(err)
407+
) {
356408
return false;
357409
}
358410
if (isFailoverError(err)) {

src/agents/model-fallback.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,65 @@ describe("runWithModelFallback", () => {
752752
expect(result.attempts[0].reason).toBe("unknown");
753753
});
754754

755+
it("does not treat Codex missing tool-result failures as model fallback candidates", async () => {
756+
const cfg = makeCfg({
757+
agents: {
758+
defaults: {
759+
model: {
760+
primary: "openai/gpt-5.4",
761+
fallbacks: ["anthropic/claude-sonnet-4-6"],
762+
},
763+
},
764+
},
765+
});
766+
const missingToolResultError = new Error(
767+
"OpenClaw recorded a native Codex tool.call without a matching tool.result before the turn completed.",
768+
);
769+
const run = vi.fn().mockRejectedValue(missingToolResultError);
770+
771+
await expect(
772+
runWithModelFallback({
773+
cfg,
774+
provider: "openai",
775+
model: "gpt-5.4",
776+
run,
777+
}),
778+
).rejects.toBe(missingToolResultError);
779+
expect(run).toHaveBeenCalledTimes(1);
780+
});
781+
782+
it("still falls back on unstructured provider text that merely mentions missing_tool_result", async () => {
783+
const cfg = makeCfg({
784+
agents: {
785+
defaults: {
786+
model: {
787+
primary: "openai/gpt-5.4",
788+
fallbacks: ["anthropic/claude-sonnet-4-6"],
789+
},
790+
},
791+
},
792+
});
793+
const run = vi
794+
.fn()
795+
.mockRejectedValueOnce(new Error("provider diagnostic reason=missing_tool_result"))
796+
.mockResolvedValueOnce("ok");
797+
798+
const result = await runWithModelFallback({
799+
cfg,
800+
provider: "openai",
801+
model: "gpt-5.4",
802+
run,
803+
});
804+
805+
expect(result.result).toBe("ok");
806+
expect(run).toHaveBeenCalledTimes(2);
807+
expect(requireMockCall(run, 1, "fallback run")).toEqual([
808+
"anthropic",
809+
"claude-sonnet-4-6",
810+
{ isFinalFallbackAttempt: true },
811+
]);
812+
});
813+
755814
it("falls back on a Zhipu GLM 1305 overload body and classifies it as overloaded", async () => {
756815
const cfg = makeCfg();
757816
const glmOverload = new Error("[1305][该模型当前访问量过大,请您稍后再试]");

0 commit comments

Comments
 (0)