Skip to content

fix(agents): preserve prompt and images on failure-mode-aware fallback retry#43331

Closed
mitchmcalister wants to merge 4 commits intoopenclaw:mainfrom
mitchmcalister:fix/fallback-retry-preserve-context
Closed

fix(agents): preserve prompt and images on failure-mode-aware fallback retry#43331
mitchmcalister wants to merge 4 commits intoopenclaw:mainfrom
mitchmcalister:fix/fallback-retry-preserve-context

Conversation

@mitchmcalister
Copy link
Copy Markdown
Contributor

Summary

When a model fallback retry fires (rate limit, timeout, auth failure, format error), the retry callback previously:

  1. Replaced the user's prompt with a hardcoded "Continue where you left off..." string — always wrong for thrown-error retries, and destructive for subagent spawns where the prompt is the task
  2. Unconditionally stripped images — even when the failure had nothing to do with image support (e.g. rate limiting)

This PR fixes both behaviors and plumbs the failure reason so the retry callback can make informed decisions.

Behavioral changes

1. Preserve original prompt on fallback retry

Before: resolveFallbackRetryPrompt replaced the user's message body with "Continue where you left off. The previous model attempt failed or timed out." on every fallback retry.

After: The original body is forwarded verbatim. Fallback retries only fire on thrown errors (not partial streaming results), so the original prompt is always safe to re-send. The orphaned-user-message repair in attempt.ts handles any residual duplicate user turns in session history.

Why: The continuation prompt was actively harmful — subagents lost their task text, and main agents got a content-free prompt that degraded response quality on the fallback model.

2. Failure-mode-aware image handling

Before: Images were stripped on every fallback retry (images: params.isFallbackRetry ? undefined : params.opts.images).

After: Images are only stripped when previousFailureReason === "format" — indicating the model can't handle the image modality. Rate limit, timeout, and auth failures preserve images for the fallback model.

Why: Unconditional stripping threw away valid image context on transient failures. Format-only stripping is the correct policy: if a model can't handle images, it should signal that through a format error, not a rate limit.

3. previousFailureReason plumbing

ModelFallbackRunOptions now includes an optional previousFailureReason: FailoverReason field, populated from the error classification of the previous attempt. This lets the run callback make failure-mode-aware decisions (currently used for image handling, available for future use).

4. Helper cleanup

  • Deleted resolveFallbackRetryPrompt (was a no-op wrapper after the behavioral fix)
  • Simplified resolveRetryImages to a 2-line function
  • Replaced an IIFE with a direct conditional

Supersedes

Related open PRs (same fallback area)

Issues addressed

Directly fixes:

  • Subagent task text dropped on model failover (subagent receives "Continue where you left off" instead of its task)

Partially addresses:

Related (not fixed here):

CLI session concern (from #43303 review)

Codex review flagged that forwarding params.body on CLI retries could append a duplicate user turn to a resumed CLI session. This is pre-existing — the old continuation prompt also appended a user turn, just a misleading one. The orphaned-user-message repair in attempt.ts handles this for the embedded runner flow. Full CLI session dedup is tracked in the related issues above.

Test plan

  • Existing resolveRetryImages tests cover all failure-reason branches (format strips, others preserve)
  • previousFailureReason forwarding tested in model-fallback.ts unit tests (undefined on first attempt, set on retry)
  • pnpm test passes
  • pnpm check passes
  • pnpm build passes

🤖 Generated with Claude Code

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 11, 2026

Greptile Summary

This PR fixes two bugs in the model fallback retry path: (1) the original user prompt is now forwarded verbatim to the fallback model instead of being replaced with a hardcoded continuation string, and (2) images are only stripped when the previous failure reason is "format" (indicating the prior model couldn't handle the modality) rather than on every fallback retry.

Key changes:

  • resolveFallbackRetryPrompt is deleted; params.body is forwarded as-is — correct because fallback retries only fire on thrown errors, not partial streams.
  • New resolveRetryImages helper gates image stripping on previousFailureReason === "format", preserving images on rate_limit, timeout, auth, and unknown failures.
  • ModelFallbackRunOptions gains previousFailureReason?: FailoverReason; the fallback loop classifies each thrown error and injects the reason into the next attempt's options.
  • _testInternals export exposes resolveRetryImages for direct unit testing.

Notable observations:

  • "format" errors are not exclusively image-related (they can also stem from unsupported tool schemas, parameter shape mismatches, etc.). When a non-image format error triggers the branch, images will be stripped for the fallback model even if it supports them and even though the root cause is unrelated — this is a known limitation acknowledged in the PR description.
  • The most impactful behavioral change (prompt body forwarding) has no dedicated regression test; only the image-handling helper is directly tested.

Confidence Score: 4/5

  • Safe to merge — the core logic is correct and well-tested; the two noted concerns are pre-existing edge cases or missing-test gaps, not regressions.
  • The prompt-preservation fix is clearly correct (fallback retries only fire on thrown errors, so the original body is always safe to re-send). The failure-mode-aware image handling is a strict improvement over unconditional stripping. The previousFailureReason plumbing is well-tested with 10+ updated call-site assertions plus a dedicated describe block. Score is 4 rather than 5 because: (a) the "format" → strip-images heuristic can fire on non-image format errors and silently discard valid visual context on the fallback model, and (b) there is no explicit test asserting that the fallback call receives the original body verbatim, leaving the most behaviorally significant change without a regression guard.
  • src/commands/agent.ts — the resolveRetryImages format-error heuristic and the absence of a prompt-forwarding regression test are the areas most worth a second look before merge.

Comments Outside Diff (1)

  1. src/commands/agent.ts, line 985-1016 (link)

    No test for verbatim prompt preservation on fallback

    The most impactful behavioral change in this PR — forwarding params.body verbatim instead of the "Continue where you left off…" string — has no dedicated test. The resolveRetryImages helper gets thorough coverage (6 new cases), but there's no assertion like:

    // pseudocode
    const run = vi.fn()
      .mockRejectedValueOnce(new FailoverError(...))
      .mockResolvedValueOnce("ok");
    
    await agentCommand({ body: "Summarise this document", ... });
    
    // Verify fallback received original body, not the continuation string
    expect(run).toHaveBeenNthCalledWith(2, expect.objectContaining({ prompt: "Summarise this document" }));

    Without an explicit regression test, a future refactor could inadvertently reintroduce the old behaviour (or introduce a different substitution) without any test catching it.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/commands/agent.ts
    Line: 985-1016
    
    Comment:
    **No test for verbatim prompt preservation on fallback**
    
    The most impactful behavioral change in this PR — forwarding `params.body` verbatim instead of the "Continue where you left off…" string — has no dedicated test. The `resolveRetryImages` helper gets thorough coverage (6 new cases), but there's no assertion like:
    
    ```ts
    // pseudocode
    const run = vi.fn()
      .mockRejectedValueOnce(new FailoverError(...))
      .mockResolvedValueOnce("ok");
    
    await agentCommand({ body: "Summarise this document", ... });
    
    // Verify fallback received original body, not the continuation string
    expect(run).toHaveBeenNthCalledWith(2, expect.objectContaining({ prompt: "Summarise this document" }));
    ```
    
    Without an explicit regression test, a future refactor could inadvertently reintroduce the old behaviour (or introduce a different substitution) without any test catching it.
    
    How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 2935806

Comment thread src/commands/agent.ts
@openclaw-barnacle openclaw-barnacle Bot added cli CLI command changes commands Command implementations agents Agent runtime and tooling size: M labels Mar 11, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2935806eaf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/commands/agent.ts
mitchmcalister and others added 4 commits March 11, 2026 16:58
Delete no-op resolveFallbackRetryPrompt (inline body directly), simplify
resolveRetryImages to positional args with a single guard, and replace
the effectiveOptions IIFE with a conditional spread one-liner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mitchmcalister mitchmcalister force-pushed the fix/fallback-retry-preserve-context branch 2 times, most recently from 5e4181e to 5e98007 Compare March 11, 2026 18:54
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Mar 11, 2026

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟠 High User-supplied images are forwarded on fallback retries (including cross-provider), enabling unintended third-party disclosure
2 🟡 Medium Model fallback retries resend original prompt, risking duplicate non-idempotent side effects

1. 🟠 User-supplied images are forwarded on fallback retries (including cross-provider), enabling unintended third-party disclosure

Property Value
Severity High
CWE CWE-359
Location src/commands/agent.ts:143-154

Description

The agent fallback retry logic no longer strips user-supplied images except for format failures.

  • resolveRetryImages() returns the original images for fallback retries unless previousFailureReason === "format".
  • runWithModelFallback() can retry with a different provider/model based on configured fallbacks.
  • As a result, if the first provider fails due to rate_limit, timeout, auth, or unknown, the same user image payloads can be sent to a different external provider on the next attempt.

This is a privacy/data-exfiltration regression vs. the prior behavior (where images were stripped on any fallback retry) and may violate user expectations/consent boundaries when fallbacks cross providers.

Vulnerable code:

function resolveRetryImages(images, isFallbackRetry, previousFailureReason?) {
  if (isFallbackRetry && previousFailureReason === "format") {
    return undefined;
  }
  return images;
}

Recommendation

Treat image forwarding on fallback as a privacy-sensitive operation.

Mitigations (prefer safest default):

  1. Default to stripping images on any fallback retry, unless explicitly opted-in.
  2. If images must be preserved, only forward images when staying on the same provider (or same configured “trust zone”).
  3. Add an explicit config flag (opt-in) such as agents.defaults.model.forwardImagesOnCrossProviderFallback: true.

Example safer implementation (strip on cross-provider retries):

function resolveRetryImages(
  images: AgentCommandOpts["images"],
  isFallbackRetry: boolean,
  previousFailureReason: FailoverReason | undefined,
  prevProvider: string | undefined,
  nextProvider: string | undefined,
): AgentCommandOpts["images"] {
  if (!isFallbackRetry) return images;

  const isCrossProvider = Boolean(prevProvider && nextProvider && prevProvider !== nextProvider);// Always strip on cross-provider retry unless explicit opt-in.
  if (isCrossProvider) return undefined;// Optionally still strip on format.
  if (previousFailureReason === "format") return undefined;

  return images;
}

Additionally, document this behavior prominently so users understand that enabling cross-provider fallbacks can cause attachments to be shared with other providers.


2. 🟡 Model fallback retries resend original prompt, risking duplicate non-idempotent side effects

Property Value
Severity Medium
CWE CWE-703
Location src/commands/agent.ts:356-360

Description

runAgentAttempt() now always reuses the original user prompt (params.body) when a model fallback retry occurs.

This is risky because fallback retries are triggered by thrown errors (via runWithModelFallback()), and the embedded runner can throw after partial progress within the turn, including after tool calls or other side effects:

  • runEmbeddedPiAgent() can throw FailoverError to trigger model fallback on assistant-side failures/timeouts when fallbacks are configured (e.g. timeout aborts, later-call rate limits, etc.).
  • Those errors can happen after the agent has already executed tool calls (e.g. messaging send, cron add, filesystem/shell tools), so repeating the full original instruction can cause the fallback attempt to repeat the same side effects.
  • The referenced “orphaned-user-message repair” in attempt.ts only removes a trailing orphaned user message to maintain role ordering; it does not provide idempotency for tool executions or external side effects.

Security/impact scenarios:

  • Duplicate outbound messages/notifications (spam or sensitive info sent twice)
  • Duplicate external API calls/actions triggered by tools (potential cost, integrity issues)
  • Repeating destructive operations requested by the prompt (e.g., deleting/modifying files, re-running shell commands)

Vulnerable code:

// Fallback retries only fire on thrown errors (not partial streaming results),// so the original prompt is always safe to re-send. The orphaned-user-message// repair in attempt.ts handles any residual duplicate user turns.
const effectivePrompt = params.body;

Recommendation

Treat fallback retries as potentially non-idempotent.

Recommended mitigations (choose one or combine):

  1. Reintroduce a “resume” prompt on fallback retries (or include a strict no-repeat instruction):
const effectivePrompt = params.isFallbackRetry
  ? "Continue where you left off. Do NOT repeat any actions already completed; inspect the transcript/tool results first."
  : params.body;
  1. Make the retry conditional: only resend the original prompt when you can guarantee no side effects occurred (e.g., failures that happen before any model output/tool calls). Otherwise, use a resume prompt.

  2. Add idempotency/deduplication at the tool layer (preferred for strong guarantees):

  • Include an idempotency key (e.g., runId + tool call id) in tool executions
  • Have tools reject or no-op repeated calls with the same idempotency key

These changes reduce the chance that fallback attempts will duplicate external side effects when recovering from timeouts or mid-turn failures.


Analyzed PR: #43331 at commit 5e98007

Last updated on: 2026-03-11T19:18:03Z

@openclaw-barnacle openclaw-barnacle Bot removed the cli CLI command changes label Mar 11, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5e98007ca4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/commands/agent.ts
@mitchmcalister
Copy link
Copy Markdown
Contributor Author

mitchmcalister commented Mar 11, 2026

Re: Aisle Security Analysis

Both flagged issues are pre-existing architectural gaps in the fallback logic, not regressions introduced by this PR. That said, the security review surfaced real improvement opportunities worth tracking:

1. Cross-provider image forwarding (High)

Pre-existing: the old behavior stripped images as a compatibility hack, not a privacy boundary. Users who configure cross-provider fallbacks have already opted into sending their prompts (text included) to multiple providers. This PR doesn't change the trust model.

However, there's a real gap: users may not expect image data to follow text to a different provider. Filed #43481 to add provider-level trust boundaries for images/attachments on cross-provider fallback.

2. Non-idempotent prompt replay (Medium)

Pre-existing: the old "Continue where you left off" prompt also triggered a new model turn that could repeat side effects, just with less context about what was originally requested. This PR doesn't make it worse.

However, there's a real gap: fallback retries can fire after partial tool execution, and neither the old nor new behavior accounts for that. Filed #43482 to track detecting partial tool execution before deciding replay strategy.

mitchmcalister added a commit to mitchmcalister/openclaw that referenced this pull request Mar 11, 2026
…pt replay

When a model fallback retry fires after partial execution (tool calls
completed before timeout/rate-limit), warn the fallback model about
already-executed actions to prevent replaying non-idempotent operations
like sending messages or running shell commands.

- Add partialExecution field to FailoverError with sanitized tool names
- Thread partial execution context through model-fallback → agent.ts
- Prepend [System: ...] context block to retry prompt listing completed
  tools and warning against re-sending messages
- Log partial execution metadata in fallback decision observations

Closes openclaw#43482
Related: openclaw#43331 (security review that flagged this gap)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mitchmcalister mitchmcalister marked this pull request as draft March 11, 2026 23:28
@mitchmcalister
Copy link
Copy Markdown
Contributor Author

Converting to draft — rethinking the approach based on reviewer feedback across this PR and its follow-ups (#43485, #43507).

The current approach works around the orphaned user message repair in attempt.ts by re-injecting params.body on every retry. Review feedback (human and bot) consistently pointed out that this creates seams: user-turn injection instead of system context, image stripping at the wrong layer, and prompt-detected images bypassing the guard.

Planning a unified PR that coordinates fallback retries with the session manager's existing retry/branching mechanisms instead of working around them. Issues #43481 and #43492 remain valid and will be addressed in the new approach.

@mitchmcalister
Copy link
Copy Markdown
Contributor Author

Superseded by #44188 (unified fallback retry safety PR).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling commands Command implementations size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant