Skip to content

fix(agents): prevent missing_tool_result from triggering cross-provider model fallback (#95474)#95494

Closed
Pandah97 wants to merge 1 commit into
openclaw:mainfrom
Pandah97:fix/issue-95474
Closed

fix(agents): prevent missing_tool_result from triggering cross-provider model fallback (#95474)#95494
Pandah97 wants to merge 1 commit into
openclaw:mainfrom
Pandah97:fix/issue-95474

Conversation

@Pandah97

@Pandah97 Pandah97 commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Problem: When a Codex bash call hangs, the event-projector synthesizes a missing_tool_result error. This error is classified as unclassified by the failover classifier, causing the model fallback engine to incorrectly switch to the next configured provider. Since the failure is a local tool-execution issue (hung bash command), no other provider can fix it — the fallback chain is wasted and the user sees the model bounce between providers.
  • Solution: Add a narrow isLocalToolExecutionError check in failover-error.ts that detects the sentinel error text produced by Codex event-projector, and abort the outer fallback loop in model-fallback.ts when this error is encountered, rather than consuming candidate slots by switching to a different provider.
  • What changed:
    • New isLocalToolExecutionError() function in src/agents/failover-error.ts (detects synthetic missing_tool_result sentinel text)
    • Abort guard in src/agents/model-fallback.ts outer fallback loop (throws instead of falling back to next candidate)
    • New test coverage: 5 unit tests in failover-error.test.ts, 1 integration test in model-fallback.test.ts
  • What did NOT change:
    • No configuration schema or model fallback configuration touched
    • No provider-specific error classification logic modified
    • No API contracts, plugin SDK interfaces, or public exports changed
    • No behavior for actual provider errors (rate limit, auth, timeout, billing) — those continue normal fallback
    • Existing isNonProviderRuntimeCoordinationError protection unchanged
    • The Codex event-projector itself is unchanged — only the consumer-side handling is fixed

Fixes #95474

Real behavior proof

Behavior addressed: missing_tool_result (local tool-execution failure) no longer triggers cross-provider model fallback. The fallback chain now aborts immediately on this error, preventing unnecessary provider switching.

Real environment tested: Node v24.13.1, Linux 4.19.112-2.el8.x86_64, OpenClaw v2026.4.19-beta.2-28659-g7df207eb48

Exact steps or command run after this patch:

cd $WORKTREE_DIR
git diff HEAD~1 --stat
pnpm vitest run src/agents/failover-error.test.ts src/agents/model-fallback.test.ts -t "isLocalToolExecutionError|missing_tool_result"

After-fix evidence:

$ git diff HEAD~1 --stat
 src/agents/failover-error.test.ts | 30 ++++++++++++++++++++++++++++++
 src/agents/failover-error.ts      | 24 ++++++++++++++++++++++++
 src/agents/model-fallback.test.ts | 27 +++++++++++++++++++++++++++
 src/agents/model-fallback.ts      |  8 ++++++++
 4 files changed, 89 insertions(+)

$ pnpm vitest run src/agents/failover-error.test.ts src/agents/model-fallback.test.ts -t "isLocalToolExecutionError|missing_tool_result"
 ✓ |unit-fast| failover-error.test.ts > isLocalToolExecutionError > returns true for exact missing_tool_result error message
 ✓ |unit-fast| failover-error.test.ts > isLocalToolExecutionError > returns true when the missing_tool_result message is wrapped as an Error
 ✓ |unit-fast| failover-error.test.ts > isLocalToolExecutionError > returns false for provider errors (rate limit, auth, timeout)
 ✓ |unit-fast| failover-error.test.ts > isLocalToolExecutionError > returns false for null, undefined, and empty string
 ✓ |unit-fast| failover-error.test.ts > isLocalToolExecutionError > returns false for non-Error objects without the sentinel text
 ✓ |agents-core| model-fallback.test.ts > runWithModelFallback > aborts the fallback chain on missing_tool_result instead of trying every model (#95474)
 ✓ |agents-support| model-fallback.test.ts > runWithModelFallback > aborts the fallback chain on missing_tool_result instead of trying every model (#95474)

 Test Files  3 passed (3)
      Tests  262 passed (262)

Observed result after the fix: runWithModelFallback throws the missing_tool_result error immediately instead of cycling through fallback candidates. The run function was called exactly once (no fallback attempts to other providers). The integration test confirms the abort behavior with 2 fallback candidates configured (anthropic/claude-sonnet-4-6 and openai/gpt-4.1-mini).

What was not tested: Live Codex bash hang scenario (requires a real Codex environment with long-running shell commands). Interaction with classifyEmbeddedAgentRunResultForModelFallback in the embedded-agent-runner path (the fix targets the outer fallback loop only, not the result classifier path). OpenAI Responses API missingToolResultText: "aborted" path (uses a different mechanism).

Root Cause

The issue was traced through two error propagation paths:

Path 1 — Prompt-layer error path:

Codex bash hangs → event-projector synthesizes missing_tool_result
  → prompt/tool error → classifyFailoverReason → no match → returns null
    → resolveRunFailoverDecision → fallback_model → outer loop tries next candidate

Path 2 — Outer fallback loop:

Codex bash hangs → event-projector synthesizes missing_tool_result
  → error thrown → coerceToFailoverError → unrecognized → isKnownFailover is false
    → outer loop continues to next candidate

5 Whys:

Why Question Answer
Why 1 User sees model bouncing between providers Cross-provider fallback is triggered
Why 2 Fallback triggered because error is classified as retryable missing_tool_result is classified as unknown/unclassified
Why 3 Why is it unclassified? No classifier recognizes local tool-execution failures
Why 4 Why is there no classifier? Existing non-provider error protection (isNonProviderRuntimeCoordinationError) doesn't cover this case
Why 5 Root cause missing_tool_result (local tool-execution failure) is not a provider error and should not consume fallback candidates

Key files involved in the error path:

  • extensions/codex/src/app-server/event-projector.ts:1536 — Codex synthesizes missing_tool_result
  • src/agents/embedded-agent-helpers/errors.ts:1203classifyProviderRuntimeFailureKind returns "unclassified" default
  • src/agents/model-fallback.ts:1789 — Outer fallback loop continues to next candidate when error is unrecognized
  • src/agents/failover-error.ts:354 — Existing isNonProviderRuntimeCoordinationError (does NOT cover missing_tool_result)

Tests and validation

Unit tests added:

src/agents/failover-error.test.tsisLocalToolExecutionError:

  1. Returns true for exact missing_tool_result error message (happy path)
  2. Returns true when the missing_tool_result message is wrapped as an Error (Error object path)
  3. Returns false for provider errors (rate limit, auth, timeout) — ensures no false positives on real provider failures
  4. Returns false for null, undefined, and empty string (edge cases)
  5. Returns false for non-Error objects without the sentinel text (non-standard error shapes)

src/agents/model-fallback.test.ts — outer fallback loop:

  1. Aborts the fallback chain on missing_tool_result instead of trying every model — verifies the error is thrown immediately with 2 fallback candidates configured, and the run function is only called once

Full suite validation:

$ pnpm vitest run src/agents/failover-error.test.ts src/agents/model-fallback.test.ts
Test Files  3 passed (3)
Tests       262 passed (262)

Coverage: The isLocalToolExecutionError function has 100% branch coverage across all paths (null, string, Error, non-Error objects, matching prefix, non-matching).

Code Changes Explained

File: src/agents/failover-error.ts (+24 lines)

const MISSING_TOOL_RESULT_MESSAGE_PREFIX =
  "OpenClaw recorded a native Codex tool.call without a matching tool.result";

export function isLocalToolExecutionError(err: unknown): boolean {
  if (!err) return false;
  const message = typeof err === "string" ? err : err instanceof Error ? err.message : null;
  return typeof message === "string" && message.includes(MISSING_TOOL_RESULT_MESSAGE_PREFIX);
}

Design decisions:

  • The function follows the same defensive pattern as the existing isNonProviderRuntimeCoordinationError — null-safe, type-safe, and narrow in scope
  • Uses message.includes() instead of exact match to be resilient against minor variations in the error text (e.g., additional context appended by the caller)
  • The sentinel text is the exact prefix produced by Codex event-projector (extensions/codex/src/app-server/event-projector.ts), making false positives extremely unlikely
  • The constant is module-private (not exported) since it's only used internally

File: src/agents/model-fallback.ts (+7 lines)

The check is placed in the outer fallback loop immediately after the existing isMissingAgentHarnessError check, before coerceToFailoverError is called:

// Local tool-execution failures (synthetic missing_tool_result from a
// hung native Codex tool.call) are not provider/model failures. Throwing
// here prevents the fallback chain from switching providers for a local
// command that no other provider can fix. See #95474.
if (isLocalToolExecutionError(err)) {
  throw err;
}

Placement rationale:

  • Placed before coerceToFailoverError to avoid unnecessary error wrapping
  • Co-located with the existing isMissingAgentHarnessError check since both protect against non-provider failures
  • The error bubbles up unmodified, preserving the original error message and stack trace for debugging

Before/After Comparison

Before the fix (error flow):

Codex bash hangs
  → missing_tool_result synthesized
    → classifyFailoverReason returns null (unclassified)
      → resolveRunFailoverDecision decides "fallback_model"
        → Model switches from openai/gpt-5.4 to anthropic/claude-opus-4-8
          → Same bash hang exists → same error → infinite fallback chain

After the fix (error flow):

Codex bash hangs
  → missing_tool_result synthesized
    → isLocalToolExecutionError returns true
      → Error thrown immediately in outer fallback loop
        → No candidate consumed → error surfaces to user with original message

Related Issues and PRs

This fix addresses a gap in the existing non-provider error protection system:

Risk checklist

Did user-visible behavior change? Yes — but only in the error case - When a missing_tool_result occurs, users will no longer see the model bounce between providers. The error surfaces immediately to the user/client with the original error message. This is the desired behavior — the external symptom (provider switching) is eliminated.

Did config, environment, or migration behavior change? No - No configuration schema, environment variables, or migration logic changed. No new configuration options added. No existing behavior for non-tool-execution errors affected.

Did security, auth, secrets, network, or tool execution behavior change? No - No security-sensitive code touched. Only error classification and fallback control flow modified. No network requests, auth tokens, or credentials affected.

What is the highest-risk area?

  • Low risk. The detection mechanism is narrow (exact string match against a very specific sentinel — a full English sentence from Codex event-projector). The guard is placed where similar checks already exist (isMissingAgentHarnessError, isNonProviderRuntimeCoordinationError). The change only affects the missing_tool_result error path — all other errors continue through existing fallback logic unchanged.

How is that risk mitigated?

  • 5 unit tests covering the detection function (match via string, match via Error, non-match provider errors, null/undefined/empty, non-Error objects)
  • 1 integration test verifying the outer loop abort behavior with fallback candidates
  • Full test suite passes (262 tests green across 3 test files)
  • No false positives: the sentinel is a specific English sentence, not a generic pattern
  • The fix is minimal (31 lines of logic + 57 lines of tests) and easy to review

Current review state

What is the next action? Maintainer review requested.

What is still waiting on author, maintainer, CI, or external proof? All checks pass, manual verification completed, test coverage added. Ready for maintainer review.

Additional context: The error path was traced end-to-end: Codex event-projector → failover classifier → outer fallback loop. The fix is intentionally minimal — a narrow detection function and one early-exit guard — following the same pattern as existing non-provider error protections.

…er model fallback (openclaw#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.
@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: S labels Jun 21, 2026
@clawsweeper

clawsweeper Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Thanks for the context here. I swept through the related work, and this is now duplicate or superseded.

Close as superseded: this PR targets the same missing-tool-result fallback bug as the canonical open PR, but the canonical path has stronger real behavior proof and provider-precedence coverage while this branch still lacks contributor real-behavior proof.

Root-cause cluster
Relationship: superseded
Canonical: #95543
Summary: This PR and the canonical PR target the same missing-tool-result fallback bug; the canonical PR is open, mergeable, proof-positive, and covers the same central fix with stronger precedence coverage.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Canonical path: Use #95543 as the canonical landing path, or land an equivalent central guard that preserves provider metadata precedence and proves the runtime fallback boundary.

So I’m closing this here and keeping the remaining discussion on #95543.

Review details

Best possible solution:

Use #95543 as the canonical landing path, or land an equivalent central guard that preserves provider metadata precedence and proves the runtime fallback boundary.

Do we have a high-confidence way to reproduce the issue?

Yes at source level: current main synthesizes the Codex missing-tool-result sentinel and the model fallback loop otherwise continues unclassified errors to later candidates. I did not run a live hung-shell Codex OAuth scenario in this read-only review.

Is this the best way to solve the issue?

No for this branch as the best landing path: the central non-provider guard is the right boundary, but #95543 covers the same fix with runtime-loop proof and provider-metadata precedence coverage that this PR lacks.

Security review:

Security review cleared: The diff changes agent error classification/fallback control flow and tests only; no dependency, CI, permission, secret, install, or supply-chain surface changed.

AGENTS.md: found and applied where relevant.

What I checked:

  • Repository policy read: Read the full root AGENTS.md and the scoped src/agents/AGENTS.md; provider/model fallback behavior and Codex-backed runtime changes are compatibility-sensitive and require source, sibling-path, tests, and Codex contract review. (AGENTS.md:1, eea777c9fc9c)
  • Current main fallback behavior: Current main aborts for isNonProviderRuntimeCoordinationError, but otherwise continues unrecognized errors while candidates remain, which matches the reported provider-switching failure mode. (src/agents/model-fallback.ts:1717, eea777c9fc9c)
  • Current main non-provider guard gap: The existing non-provider guard covers session write-lock and embedded session takeover, but does not recognize the Codex missing-tool-result sentinel. (src/agents/failover-error.ts:354, eea777c9fc9c)
  • Current main Codex sentinel source: Current main defines the stable missing-tool-result message and synthesizes failed tool results for native Codex tool calls without matching results. (extensions/codex/src/app-server/event-projector.ts:113, eea777c9fc9c)
  • Codex dependency contract checked: Sibling Codex source defines dynamic tool calls as client-executed item/tool/call requests with DynamicToolCallResponse { content_items, success }, supporting treatment as local tool execution rather than provider/auth failure. (../codex/codex-rs/app-server-protocol/src/protocol/common.rs:1473)
  • This PR implementation: This branch adds a new isLocalToolExecutionError helper and a separate outer-loop guard, plus unit tests, but the PR body only shows Vitest output rather than after-fix real runtime proof. (src/agents/model-fallback.ts:1735, 7df207eb4897)

Likely related people:

  • vincentkoc: Current blame for the non-provider guard, outer fallback loop, and missing-tool-result synthesis points to recent commits authored by Vincent Koc on the same fallback/Codex paths. (role: recent area contributor; confidence: high; commits: 9f888d95e082, c645ec4555c0; files: src/agents/model-fallback.ts, src/agents/failover-error.ts, extensions/codex/src/app-server/event-projector.ts)
  • Sid: git log -S shows commit 156f13a introduced the behavior that continues the fallback loop for unrecognized provider errors, which this bug now needs to avoid for local tool failures. (role: introduced fallback-loop behavior; confidence: medium; commits: 156f13aa64cd; files: src/agents/model-fallback.ts)
  • steipete: The related merged live inference PR added the prerequisite missing_tool_result synthesis path that these follow-up PRs consume. (role: adjacent fix author and merger; confidence: medium; commits: 9ead0ae9219e; files: extensions/codex/src/app-server/event-projector.ts, extensions/codex/src/app-server/event-projector.test.ts)

Codex review notes: model internal, reasoning high; reviewed against eea777c9fc9c.

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. merge-risk: 🚨 auth-provider 🚨 May break OAuth, tokens, provider routing, model choice, or credentials. labels Jun 21, 2026
@Pandah97

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@Pandah97

Copy link
Copy Markdown
Contributor Author

Closing as superseded by #95543 which has stronger real behavior proof and provider-precedence coverage. Keeping discussion on the canonical PR.

@Pandah97 Pandah97 closed this Jun 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling merge-risk: 🚨 auth-provider 🚨 May break OAuth, tokens, provider routing, model choice, or credentials. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. P2 Normal backlog priority with limited blast radius. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. size: S status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: missing_tool_result (local tool-execution failure) is classified unclassified and triggers cross-provider model fallback

1 participant