Skip to content

Comments

fix(agents): drop orphan tool results#3622

Open
mickobizzle wants to merge 2 commits intoopenclaw:mainfrom
mickobizzle:fix/orphan-toolresults-guard
Open

fix(agents): drop orphan tool results#3622
mickobizzle wants to merge 2 commits intoopenclaw:mainfrom
mickobizzle:fix/orphan-toolresults-guard

Conversation

@mickobizzle
Copy link

@mickobizzle mickobizzle commented Jan 28, 2026

Summary

Drops orphan toolResult messages that arrive without a matching pending tool call, preventing corrupted transcripts that can trigger repeated "No tool call found" loops.

Details

  • Adds a guard option (default enabled) to ignore orphan toolResult messages.
  • Wires the option through the guard wrapper.
  • Adds a test to cover the orphan-drop behavior.

Why

A session can end up with a toolResult persisted without its toolCall (e.g., interrupted tool call). Replaying that session repeatedly hits the error and can burn tokens. This prevents new corruption at persistence time.

Tests

  • pnpm vitest src/agents/session-tool-result-guard.test.ts

Issue

Closes #3621

Greptile Overview

Greptile Summary

This PR extends the session tool-result guard to optionally drop “orphan” toolResult messages (default enabled) that don’t correspond to a currently-pending tool call, and threads the option through the guard wrapper. It also adds a vitest case asserting that an orphan toolResult is ignored.

Overall this fits the existing guard’s responsibility (normalizing transcript/tool-call pairing and optionally synthesizing missing results) and should help prevent persistence-time corruption loops when replaying sessions.

Confidence Score: 4/5

  • This PR looks safe to merge and is narrowly scoped to transcript persistence behavior.
  • Changes are small and localized (an additional guard branch plus wiring and a targeted test). Main functional concern is that malformed toolResult messages without an id are still persisted, which may still allow corruption in some edge cases, but the default orphan-drop should mitigate the reported loop for id-bearing orphans.
  • src/agents/session-tool-result-guard.ts

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

Context used:

  • Context from dashboard - CLAUDE.md (source)
  • Context from dashboard - AGENTS.md (source)

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

1 file reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines 96 to +101
if (role === "toolResult") {
const id = extractToolResultId(message as Extract<AgentMessage, { role: "toolResult" }>);
const toolName = id ? pending.get(id) : undefined;
if (id && !pending.has(id) && dropOrphanToolResults) {
return undefined as never;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

[P1] toolResult messages missing an id are still persisted, even though they're effectively "orphan".

The new orphan-drop guard only runs when extractToolResultId() returns a non-null id (if (id && !pending.has(id) && dropOrphanToolResults) ...). If a provider emits a malformed toolResult without toolCallId/toolUseId, it will still be appended and can still corrupt transcripts / trigger downstream "no tool call found" behavior. If the intent is “drop any toolResult that can’t be matched to a pending tool call”, consider also dropping when id === null (or at least when dropOrphanToolResults is enabled).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/session-tool-result-guard.ts
Line: 96:101

Comment:
[P1] `toolResult` messages missing an id are still persisted, even though they're effectively "orphan".

The new orphan-drop guard only runs when `extractToolResultId()` returns a non-null id (`if (id && !pending.has(id) && dropOrphanToolResults) ...`). If a provider emits a malformed `toolResult` without `toolCallId`/`toolUseId`, it will still be appended and can still corrupt transcripts / trigger downstream "no tool call found" behavior. If the intent is “drop any toolResult that can’t be matched to a pending tool call”, consider also dropping when `id === null` (or at least when `dropOrphanToolResults` is enabled).


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

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 3, 2026

Additional Comments (1)

src/agents/session-tool-result-guard.ts
[P2] pending.get(id) before the orphan check is redundant and can be misleading.

toolName is read from pending even when id isn’t pending (the orphan-drop branch returns early anyway). It’s minor, but moving the orphan check before reading toolName makes the control flow clearer and avoids accidental future use of a toolName that didn’t come from a real pending call.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/session-tool-result-guard.ts
Line: 98:103

Comment:
[P2] `pending.get(id)` before the orphan check is redundant and can be misleading.

`toolName` is read from `pending` even when `id` isn’t pending (the orphan-drop branch returns early anyway). It’s minor, but moving the orphan check before reading `toolName` makes the control flow clearer and avoids accidental future use of a `toolName` that didn’t come from a real pending call.

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

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

@rodbland2021
Copy link
Contributor

We've been investigating the underlying cause of the tool result loss bug and submitted a fix in #13746.

The root cause is a race condition: pi-agent-core's _resolveRetry() fires on message_end (before tool execution completes), causing waitForRetry() to resolve early. The attempt's finally block then calls flushPendingToolResults() while tools are still in flight — inserting the synthetic errors.

This PR addresses a symptom but not the timing issue that causes tool results to be lost in the first place. The fix in #13746 adds waitForIdle() before the flush to ensure the retry loop fully completes. We're testing it on a production VPS now.

See detailed analysis in our comment on #8643.

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

Labels

agents Agent runtime and tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Orphan toolResult persisted without matching toolCall causes repeated loop / token burn

2 participants