Skip to content

Commit 04469ac

Browse files
committed
fix(agents): keep missing tool results on current model
1 parent 6e5f4d6 commit 04469ac

3 files changed

Lines changed: 105 additions & 6 deletions

File tree

src/agents/failover-error.test.ts

Lines changed: 21 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,6 +1316,13 @@ 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
});

src/agents/failover-error.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -344,15 +344,66 @@ function hasEmbeddedAttemptSessionTakeover(err: unknown, seen: Set<object> = new
344344
);
345345
}
346346

347+
function isMissingToolResultText(value: string): boolean {
348+
return (
349+
value.includes("missing_tool_result") ||
350+
/native Codex tool\.call without a matching tool\.result/i.test(value)
351+
);
352+
}
353+
354+
function hasMissingToolResultFailure(err: unknown, seen: Set<object> = new Set()): boolean {
355+
if (typeof err === "string") {
356+
return isMissingToolResultText(err);
357+
}
358+
if (!err || typeof err !== "object") {
359+
return false;
360+
}
361+
if (seen.has(err)) {
362+
return false;
363+
}
364+
seen.add(err);
365+
const candidate = err as {
366+
cause?: unknown;
367+
code?: unknown;
368+
detail?: unknown;
369+
error?: unknown;
370+
message?: unknown;
371+
output?: unknown;
372+
reason?: unknown;
373+
result?: unknown;
374+
status?: unknown;
375+
};
376+
const directValues = [
377+
candidate.code,
378+
candidate.message,
379+
candidate.output,
380+
candidate.reason,
381+
candidate.status,
382+
];
383+
if (directValues.some((value) => typeof value === "string" && isMissingToolResultText(value))) {
384+
return true;
385+
}
386+
return [
387+
candidate.error,
388+
candidate.cause,
389+
candidate.reason,
390+
candidate.result,
391+
candidate.detail,
392+
].some((value) => value !== err && hasMissingToolResultFailure(value, seen));
393+
}
394+
347395
/**
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.
396+
* True when the error is a local runtime coordination/tool-execution error
397+
* rather than a provider/model failure. The model fallback chain must abort on
398+
* these instead of consuming candidate slots — retrying any model would hit the
399+
* same local condition. See #83510 and #95474.
353400
*/
354401
export function isNonProviderRuntimeCoordinationError(err: unknown): boolean {
355-
if (!hasSessionWriteLockContention(err) && !hasEmbeddedAttemptSessionTakeover(err)) {
402+
if (
403+
!hasSessionWriteLockContention(err) &&
404+
!hasEmbeddedAttemptSessionTakeover(err) &&
405+
!hasMissingToolResultFailure(err)
406+
) {
356407
return false;
357408
}
358409
if (isFailoverError(err)) {

src/agents/model-fallback.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,33 @@ describe("runWithModelFallback", () => {
746746
expect(result.attempts[0].reason).toBe("unknown");
747747
});
748748

749+
it("does not treat Codex missing tool-result failures as model fallback candidates", async () => {
750+
const cfg = makeCfg({
751+
agents: {
752+
defaults: {
753+
model: {
754+
primary: "openai/gpt-5.4",
755+
fallbacks: ["anthropic/claude-sonnet-4-6"],
756+
},
757+
},
758+
},
759+
});
760+
const missingToolResultError = new Error(
761+
"OpenClaw recorded a native Codex tool.call without a matching tool.result before the turn completed.",
762+
);
763+
const run = vi.fn().mockRejectedValue(missingToolResultError);
764+
765+
await expect(
766+
runWithModelFallback({
767+
cfg,
768+
provider: "openai",
769+
model: "gpt-5.4",
770+
run,
771+
}),
772+
).rejects.toBe(missingToolResultError);
773+
expect(run).toHaveBeenCalledTimes(1);
774+
});
775+
749776
it("falls back on a Zhipu GLM 1305 overload body and classifies it as overloaded", async () => {
750777
const cfg = makeCfg();
751778
const glmOverload = new Error("[1305][该模型当前访问量过大,请您稍后再试]");

0 commit comments

Comments
 (0)