Skip to content

fix(agents): detect partial tool execution before fallback retry#43507

Closed
mitchmcalister wants to merge 2 commits intoopenclaw:mainfrom
mitchmcalister:fix/fallback-tool-replay-guard
Closed

fix(agents): detect partial tool execution before fallback retry#43507
mitchmcalister wants to merge 2 commits intoopenclaw:mainfrom
mitchmcalister:fix/fallback-tool-replay-guard

Conversation

@mitchmcalister
Copy link
Copy Markdown
Contributor

Summary

  • Detect when a model fallback retry follows partial tool execution (tool calls completed before timeout/rate-limit) and warn the fallback model about already-executed actions to prevent replaying non-idempotent operations (sending messages, shell commands, API calls)
  • Add partialExecution field to FailoverError with sanitized tool names (capped at 20 entries, alphanumeric+_- only, 100 char limit per name)
  • Thread partial execution context through model-fallback.tsagent.ts and prepend a [System: ...] context block to the retry prompt listing completed tools

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

Test plan

  • pnpm build — type-check passes
  • pnpm check — lint/format passes
  • New unit tests for FailoverError.sanitizeToolNames (capping, stripping, truncation, empty filtering)
  • New unit tests for partialExecution preservation through FailoverError construction and describeFailoverError
  • New unit tests for runWithModelFallback forwarding previousPartialExecution to next run call, omitting when absent, and storing in attempts[]

🤖 Generated with Claude Code

…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>
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Mar 11, 2026

🔒 Aisle Security Analysis

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

# Severity Title
1 🔵 Low Tool execution metadata disclosure to fallback LLM providers via injected system prompt

1. 🔵 Tool execution metadata disclosure to fallback LLM providers via injected system prompt

Property Value
Severity Low
CWE CWE-201
Location src/commands/agent.ts:153-163

Description

The fallback retry prompt now includes tool execution metadata (tool names + whether a messaging tool already sent messages) and injects it into the next model attempt’s prompt.

Data flow introduced by this diff:

  • src/agents/pi-embedded-runner/run.ts captures executed tool names (attempt.toolMetas[].toolName) and sets FailoverError.partialExecution.
  • src/agents/model-fallback.ts persists this as lastPartialExecution and forwards it to subsequent fallback attempts as runOptions.previousPartialExecution.
  • src/commands/agent.ts interpolates previousPartialExecution.toolNames directly into a system prompt that is then sent to the fallback provider/model.

Why this is a problem:

  • This sends internal execution details across trust boundaries to a potentially different third-party LLM provider during fallback.
  • Even though pi-embedded-runner currently sanitizes names, FailoverError accepts arbitrary partialExecution and resolveFallbackRetryPrompt does not re-sanitize/escape. If any future caller constructs FailoverError with unsanitized toolNames, this becomes a prompt-injection primitive (newlines/markup) in addition to a disclosure.

Vulnerable code (prompt construction):

const toolList = params.previousPartialExecution.toolNames.join(", ");
...
`It completed these tool calls: ${toolList}.${messagingWarning} ` +

Recommendation

Minimize and harden what crosses the provider boundary.

  • Default to redaction: avoid sending specific tool names to the fallback model; send only a boolean / count (and the didSendViaMessagingTool safety flag).
  • Re-sanitize at the sink (resolveFallbackRetryPrompt) even if sanitized earlier, because FailoverError.partialExecution can be constructed elsewhere.

Example safer implementation:

import { FailoverError } from "../agents/failover-error.js";

function resolveFallbackRetryPrompt(params: {
  body: string;
  isFallbackRetry: boolean;
  previousPartialExecution?: { toolNames: string[]; didSendViaMessagingTool: boolean };
}): string {
  if (!params.isFallbackRetry) return params.body;
  if (!params.previousPartialExecution) {
    return "Continue where you left off. The previous model attempt failed or timed out.";
  }

  const safeToolNames = FailoverError.sanitizeToolNames(params.previousPartialExecution.toolNames);
  const toolCount = safeToolNames.length;
  const messagingWarning = params.previousPartialExecution.didSendViaMessagingTool
    ? " Messages were already sent to the user — do NOT re-send them."
    : "";

  return [
    `[System: The previous model attempt partially executed before failing. ` +
      `It executed ${toolCount} tool call(s).${messagingWarning} ` +
      `Do not repeat actions that have already been performed. ` +
      `Review the conversation history and continue from where the previous attempt left off.]`,
    params.body,
  ].join("\n\n");
}

Optionally gate inclusion of tool names behind an explicit debug flag/config that is off by default.


Analyzed PR: #43507 at commit f36dc8e

Last updated on: 2026-03-11T23:32:54Z

@openclaw-barnacle openclaw-barnacle Bot added commands Command implementations agents Agent runtime and tooling size: M labels Mar 11, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 11, 2026

Greptile Summary

This PR adds detection of partial tool execution before model fallback retries, threading sanitized tool names from FailoverError through model-fallback.ts into agent.ts where they are prepended as a [System: ...] context block — a meaningful safety improvement for non-idempotent operations like messaging and shell commands.

Two logic issues were found:

  • Partial execution context lost on multi-hop fallback (model-fallback.ts:733): lastPartialExecution is unconditionally reassigned to described.partialExecution after each failed attempt. If a second fallback candidate fails without executing any tools, this overwrites lastPartialExecution with undefined, causing subsequent candidates to never learn about tools that the primary model already executed. Fix: use ?? lastPartialExecution to preserve earlier context.

  • Empty toolNames produces a malformed system prompt (pi-embedded-runner/run.ts:1421): The guard attempt.toolMetas.length > 0 checks raw tool count but does not account for sanitizeToolNames reducing the list to []. This propagates to agent.ts where [].join(", ") produces an empty string, yielding the confusing prompt "It completed these tool calls: .". The sanitized names should be computed first and checked for length before constructing the partialExecution object.

Confidence Score: 3/5

  • The core idea is sound, but two logic bugs can cause the fallback model to either lose partial execution context mid-chain or receive a malformed empty tool list in the system prompt.
  • The sanitization approach and prompt injection prevention are well-designed. However, lastPartialExecution being unconditionally overwritten is a correctness bug in the scenario this PR is explicitly trying to fix (multi-model fallback chains with prior tool execution), and the empty-toolNames edge case produces a misleading prompt. Both should be addressed before merge.
  • src/agents/model-fallback.ts (line 733) and src/agents/pi-embedded-runner/run.ts (lines 1421–1430) require fixes before merging.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/model-fallback.ts
Line: 733

Comment:
**Partial execution context lost on multi-hop fallback failure**

`lastPartialExecution` is unconditionally overwritten here. If a secondary fallback attempt fails *without* executing any tools (e.g. it hits a rate limit immediately), `described.partialExecution` is `undefined`, which clobbers the context from the original attempt. Any further fallback candidates will then receive no `previousPartialExecution` — meaning they won't know that the primary model already sent a message or ran a shell command.

Example scenario: Attempt 1 runs `send_message` then times out → `lastPartialExecution = { toolNames: ["send_message"], ... }`. Attempt 2 hits a rate limit before executing anything → `lastPartialExecution = undefined`. Attempt 3 never learns about the message sent in Attempt 1 and may re-send it.

```suggestion
      lastPartialExecution = described.partialExecution ?? lastPartialExecution;
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run.ts
Line: 1421-1430

Comment:
**Empty `toolNames` after sanitization produces a malformed system prompt**

`attempt.toolMetas.length > 0` only guarantees that there were tool calls before sanitization. If every tool name consists solely of characters stripped by `sanitizeToolNames` (e.g. only emoji, CJK, or punctuation), the resulting `toolNames` array will be `[]`. This gets threaded all the way to `resolveFallbackRetryPrompt` in `agent.ts`, where `[].join(", ")` produces an empty string, resulting in the nonsensical fallback prompt:

> *"It completed these tool calls: . Do not repeat actions that have already been performed."*

This could confuse the fallback model. The `partialExecution` object should only be created when the sanitized names are non-empty:

```
const sanitizedToolNames = FailoverError.sanitizeToolNames(
  attempt.toolMetas.map((t) => t.toolName),
);
partialExecution:
  sanitizedToolNames.length > 0
    ? {
        hadToolExecution: true,
        toolNames: sanitizedToolNames,
        didSendViaMessagingTool: attempt.didSendViaMessagingTool,
      }
    : undefined,
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 399026c

Comment thread src/agents/model-fallback.ts Outdated

lastError = isKnownFailover ? normalized : err;
const described = describeFailoverError(normalized);
lastPartialExecution = described.partialExecution;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Partial execution context lost on multi-hop fallback failure

lastPartialExecution is unconditionally overwritten here. If a secondary fallback attempt fails without executing any tools (e.g. it hits a rate limit immediately), described.partialExecution is undefined, which clobbers the context from the original attempt. Any further fallback candidates will then receive no previousPartialExecution — meaning they won't know that the primary model already sent a message or ran a shell command.

Example scenario: Attempt 1 runs send_message then times out → lastPartialExecution = { toolNames: ["send_message"], ... }. Attempt 2 hits a rate limit before executing anything → lastPartialExecution = undefined. Attempt 3 never learns about the message sent in Attempt 1 and may re-send it.

Suggested change
lastPartialExecution = described.partialExecution;
lastPartialExecution = described.partialExecution ?? lastPartialExecution;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/model-fallback.ts
Line: 733

Comment:
**Partial execution context lost on multi-hop fallback failure**

`lastPartialExecution` is unconditionally overwritten here. If a secondary fallback attempt fails *without* executing any tools (e.g. it hits a rate limit immediately), `described.partialExecution` is `undefined`, which clobbers the context from the original attempt. Any further fallback candidates will then receive no `previousPartialExecution` — meaning they won't know that the primary model already sent a message or ran a shell command.

Example scenario: Attempt 1 runs `send_message` then times out → `lastPartialExecution = { toolNames: ["send_message"], ... }`. Attempt 2 hits a rate limit before executing anything → `lastPartialExecution = undefined`. Attempt 3 never learns about the message sent in Attempt 1 and may re-send it.

```suggestion
      lastPartialExecution = described.partialExecution ?? lastPartialExecution;
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in f36dc8elastPartialExecution = described.partialExecution ?? lastPartialExecution preserves earlier context when later attempts fail without tool execution. Added a multi-hop test covering this scenario.

Comment thread src/agents/pi-embedded-runner/run.ts Outdated
Comment on lines +1421 to +1430
partialExecution:
attempt.toolMetas.length > 0
? {
hadToolExecution: true,
toolNames: FailoverError.sanitizeToolNames(
attempt.toolMetas.map((t) => t.toolName),
),
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
}
: undefined,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Empty toolNames after sanitization produces a malformed system prompt

attempt.toolMetas.length > 0 only guarantees that there were tool calls before sanitization. If every tool name consists solely of characters stripped by sanitizeToolNames (e.g. only emoji, CJK, or punctuation), the resulting toolNames array will be []. This gets threaded all the way to resolveFallbackRetryPrompt in agent.ts, where [].join(", ") produces an empty string, resulting in the nonsensical fallback prompt:

"It completed these tool calls: . Do not repeat actions that have already been performed."

This could confuse the fallback model. The partialExecution object should only be created when the sanitized names are non-empty:

const sanitizedToolNames = FailoverError.sanitizeToolNames(
  attempt.toolMetas.map((t) => t.toolName),
);
partialExecution:
  sanitizedToolNames.length > 0
    ? {
        hadToolExecution: true,
        toolNames: sanitizedToolNames,
        didSendViaMessagingTool: attempt.didSendViaMessagingTool,
      }
    : undefined,
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner/run.ts
Line: 1421-1430

Comment:
**Empty `toolNames` after sanitization produces a malformed system prompt**

`attempt.toolMetas.length > 0` only guarantees that there were tool calls before sanitization. If every tool name consists solely of characters stripped by `sanitizeToolNames` (e.g. only emoji, CJK, or punctuation), the resulting `toolNames` array will be `[]`. This gets threaded all the way to `resolveFallbackRetryPrompt` in `agent.ts`, where `[].join(", ")` produces an empty string, resulting in the nonsensical fallback prompt:

> *"It completed these tool calls: . Do not repeat actions that have already been performed."*

This could confuse the fallback model. The `partialExecution` object should only be created when the sanitized names are non-empty:

```
const sanitizedToolNames = FailoverError.sanitizeToolNames(
  attempt.toolMetas.map((t) => t.toolName),
);
partialExecution:
  sanitizedToolNames.length > 0
    ? {
        hadToolExecution: true,
        toolNames: sanitizedToolNames,
        didSendViaMessagingTool: attempt.didSendViaMessagingTool,
      }
    : undefined,
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in f36dc8e — now checks sanitizedToolNames.length instead of attempt.toolMetas.length before constructing partialExecution, so all-invalid-char tool names produce undefined instead of an empty-list prompt.

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: 399026c2ab

ℹ️ 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
`It completed these tool calls: ${toolList}.${messagingWarning} ` +
`Do not repeat actions that have already been performed. ` +
`Review the conversation history and continue from where the previous attempt left off.]`,
params.body,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid re-injecting original body on partial fallback retries

When previousPartialExecution is present, this path appends params.body into the retry prompt. That prompt is submitted again as a fresh user turn on fallback attempts (activeSession.prompt(...) in src/agents/pi-embedded-runner/run/attempt.ts), so the original request is re-issued exactly in the scenario where tools already ran. This defeats the duplicate-turn protection noted in the fallback flow and increases the chance that the next model restarts work and repeats non-idempotent actions.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentional design — not changing this. The partial-execution path must include the original body because the fallback model needs both the task context AND the warning about what already ran. The no-partial-execution path ("Continue where you left off") can drop the body because the session transcript already contains it and no side-effect context is needed. This follows the same pattern as prependInternalEventContext at agent.ts:149-161.

Dropping the body in the partial-execution case would leave the fallback model with only the system warning and no task to continue, which is worse.

Comment thread src/agents/model-fallback.ts Outdated

lastError = isKnownFailover ? normalized : err;
const described = describeFailoverError(normalized);
lastPartialExecution = described.partialExecution;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve partial-execution state when later errors omit it

This assignment clears lastPartialExecution whenever a failed attempt has no partialExecution metadata. In a 3+ candidate fallback chain, if an early candidate executed tools but a middle candidate fails before reporting partial execution, the next retry loses the safety context and no longer gets previousPartialExecution, allowing already-completed side effects to be replayed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in f36dc8e — same fix as the Greptile comment above (?? lastPartialExecution).

…y sanitized names

- Use `?? lastPartialExecution` so later attempts without tool execution
  don't clobber the context from an earlier partial execution
- Check sanitized tool name length (not pre-sanitization) to avoid
  malformed prompts when all names are stripped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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: f36dc8e7a2

ℹ️ 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".


lastError = isKnownFailover ? normalized : err;
const described = describeFailoverError(normalized);
lastPartialExecution = described.partialExecution ?? lastPartialExecution;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Accumulate partial-execution context across failed attempts

This assignment replaces the previous partial-execution metadata whenever a later candidate also reports partialExecution, so in a 3+ model chain you can lose earlier side-effect context (for example, attempt 1 sent a message, attempt 2 only ran search, attempt 3 no longer gets the “message already sent” warning). That makes fallback retries more likely to replay non-idempotent actions from earlier attempts; runWithModelFallback should merge tool names and OR didSendViaMessagingTool instead of overwriting.

Useful? React with 👍 / 👎.

@byungsker

This comment was marked as spam.

@mitchmcalister
Copy link
Copy Markdown
Contributor Author

Converting to draft — the partial execution warning injection will be redesigned as part of a unified fallback retry approach that uses proper system context rather than user-turn injection.

See #43331 (comment) for context on the rethink.

@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.

Fallback retry should detect partial tool execution before replaying prompt

2 participants