Skip to content

fix: skip incomplete tool calls in transcript repair [AI-assisted]#3194

Closed
koriyoshi2041 wants to merge 1 commit intoopenclaw:mainfrom
koriyoshi2041:fix/sanitize-terminated-tool-calls
Closed

fix: skip incomplete tool calls in transcript repair [AI-assisted]#3194
koriyoshi2041 wants to merge 1 commit intoopenclaw:mainfrom
koriyoshi2041:fix/sanitize-terminated-tool-calls

Conversation

@koriyoshi2041
Copy link

@koriyoshi2041 koriyoshi2041 commented Jan 28, 2026

Summary

When a streaming response is terminated mid-tool-call (e.g. stopReason: "error", errorMessage: "terminated"), the assistant message contains tool call blocks with partialJson but no valid arguments. The existing repairToolUseResultPairing() treats these as complete tool calls and inserts synthetic error toolResult messages. On the next API call, Anthropic rejects the request with "unexpected tool_use_id" — corrupting the session permanently.

This PR adds an isIncompleteToolCall() check that detects terminated tool calls by verifying:

  1. partialJson is present (streaming was in progress), AND
  2. arguments is missing, null, or an empty {} object

Incomplete tool calls are skipped during extraction so no synthetic result is generated, while the block itself remains in the assistant message — preserving conversational context for the model.

How this differs from existing approaches

Approach Behavior Trade-off
Skip entire assistant msg on stopReason: "error" (#1859) Drops all text content from the turn Too coarse — loses legitimate text
Remove incomplete block from content array (#2253) Mutates the assistant message Loses debugging context
This PR: skip from tool call extraction only Block stays, no synthetic result generated Surgical — preserves context, no mutation

Changes

  • session-transcript-repair.ts: Added isIncompleteToolCall() function; modified extractToolCallsFromAssistant() to skip incomplete tool calls; exported isIncompleteToolCall for testing
  • session-transcript-repair.test.ts: Added 9 new tests (5 for sanitizeToolUseResultPairing, 4 for isIncompleteToolCall)

Test plan

  • All 13 tests pass (4 existing + 9 new)
  • Verified against real corrupted session data from production — incomplete tool calls correctly skipped, no toolResult emitted
  • Complete tool calls with partialJson still work normally
  • Mixed complete + incomplete tool calls in same assistant message handled correctly
  • Linter passes (npm run lint — 0 warnings, 0 errors)
  • Reviewers: verify no regressions in transcript repair for normal tool call flows

AI Disclosure

🤖 This PR was AI-assisted (Claude Opus 4.5 via Claude Code). The contributor understands what the code does — the fix was designed after analyzing 4 existing open PRs (#1859, #2253, #2557, #2806) and real corrupted session data. Fully tested — 9 new tests + verified against production session files.

Greptile Overview

Greptile Summary

This PR updates the transcript repair logic so that tool-call blocks that were cut off mid-stream (identified by partialJson present but arguments missing/empty) are ignored during tool-call extraction. That prevents generating synthetic toolResult entries for tool calls that never actually executed, which avoids downstream provider errors like “unexpected tool_use_id”. The change is localized to extractToolCallsFromAssistant() in src/agents/session-transcript-repair.ts and is covered by new Vitest cases in src/agents/session-transcript-repair.test.ts for incomplete vs complete tool calls (including mixed messages).

Confidence Score: 3/5

  • This PR is probably safe to merge, but the new incompleteness heuristic may drop legitimate tool calls in some schemas.
  • The change is small and well-tested for the targeted “partialJson + missing args” termination scenario, but treating arguments: {} as incomplete based solely on partialJson risks false positives for tools that legitimately accept empty objects (and the existing tests commonly model tool calls that way). That could cause real tool results to be dropped as orphans in repaired transcripts.
  • src/agents/session-transcript-repair.ts (incompleteness heuristic); src/agents/session-transcript-repair.test.ts (ensure coverage for tools where empty args are valid).

(3/5) Reply to the agent's comments like "Can you suggest a fix for this @greptileai?" or ask follow-up questions!

When a streaming response is terminated mid-tool-call, the assistant
message contains tool call blocks with `partialJson` but no valid
`arguments`. The existing repair logic treats these as complete tool
calls and inserts synthetic error `toolResult` messages, which causes
Anthropic's API to reject the request with "unexpected tool_use_id"
on subsequent turns — corrupting the session.

Add `isIncompleteToolCall()` to detect these terminated tool calls by
checking for `partialJson` present AND `arguments` missing/empty.
Incomplete tool calls are skipped during extraction so no synthetic
result is generated, while the block itself remains in the assistant
message preserving conversational context.

This is more surgical than alternative approaches:
- Unlike skipping the entire assistant message on stopReason "error",
  this preserves text content from the same turn.
- Unlike deleting the incomplete block, this keeps the partial tool
  call visible for debugging/context.

Includes 9 new tests covering incomplete, complete-with-partialJson,
mixed, and empty-arguments scenarios.
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, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +24 to +33
function isIncompleteToolCall(block: Record<string, unknown>): boolean {
if (typeof block.partialJson !== "string" || !block.partialJson) return false;

const args = block.arguments;
// No arguments at all → incomplete
if (args === undefined || args === null) return true;
// Empty object {} → incomplete (arguments were not parsed)
if (typeof args === "object" && !Array.isArray(args) && Object.keys(args as object).length === 0)
return true;

Copy link
Contributor

Choose a reason for hiding this comment

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

isIncompleteToolCall() treats arguments: {} as incomplete whenever partialJson is non-empty. This can misclassify valid tool calls whose schema allows an empty object (several existing tests use arguments: {} as a “complete” call), causing their real toolResult to be dropped as an orphan and potentially corrupting otherwise-valid transcripts.

If the intent is “provider wrote {} as a placeholder before parsing args”, consider only treating {} as incomplete when it’s missing required fields for that tool (if known), or gate the {} case behind an additional signal (e.g. stopReason/error context) instead of partialJson alone.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/session-transcript-repair.ts
Line: 24:33

Comment:
`isIncompleteToolCall()` treats `arguments: {}` as incomplete whenever `partialJson` is non-empty. This can misclassify valid tool calls whose schema allows an empty object (several existing tests use `arguments: {}` as a “complete” call), causing their real `toolResult` to be dropped as an orphan and potentially corrupting otherwise-valid transcripts.

If the intent is “provider wrote `{}` as a placeholder before parsing args”, consider only treating `{}` as incomplete when it’s *missing* required fields for that tool (if known), or gate the `{}` case behind an additional signal (e.g. stopReason/error context) instead of `partialJson` alone.

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

@sebslight
Copy link
Member

Closing as duplicate of #5822. If this is incorrect, comment and we can reopen.

@sebslight sebslight closed this Feb 13, 2026
@koriyoshi2041
Copy link
Author

Hi @sebslight — thanks for the review. I see this was closed as a duplicate of #5822, which itself was closed in favor of #4598 (merged Feb 6).

However, our approach is materially different from #4598 and handles an edge case it misses:

#4598 (merged) This PR (#3194)
Detection Checks stopReason === "error" || "aborted" on the entire message Checks partialJson + missing arguments on each individual block
Granularity Skips all tool calls from the message Skips only incomplete tool calls
Edge case If an assistant message has 2 tool calls — one complete, one terminated mid-stream — both are skipped Only the incomplete one is skipped; the complete one still gets its synthetic result

Real-world scenario: A streaming response completes toolCall A (with valid arguments), then starts toolCall B but gets terminated. With #4598, both are skipped — toolCall A loses its result pairing. With our approach, only toolCall B is skipped.

Additionally, this PR has 9 tests (vs 4 in #4598) covering mixed complete/incomplete scenarios and empty arguments: {} edge cases.

Would you consider reopening, or should I open a new PR targeting the gap?

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.

2 participants