fix(agents): repair orphaned tool_use pairs on compaction prune path#93584
fix(agents): repair orphaned tool_use pairs on compaction prune path#93584dolphinsboy wants to merge 4 commits into
Conversation
2001622 to
e8679b6
Compare
Real Behavior Proof — Updated 2026-06-16This comment supersedes the earlier proof. Root cause analysis revealed that The fix was updated to pass Fix (updated)// compaction-safeguard.ts — splitPreservedRecentTurns (no-drop path)
- repairToolUseResultPairing(summarizableMessages).messages
+ repairToolUseResultPairing(summarizableMessages, { erroredAssistantResultPolicy: "drop" }).messages
// compaction-safeguard.ts — prune path (droppedChunks > 0)
- repairToolUseResultPairing(pruned.messages).messages
+ repairToolUseResultPairing(pruned.messages, { erroredAssistantResultPolicy: "drop" }).messages
// compaction-planning.ts — entry + inner loop
- repairToolUseResultPairing(params.messages).messages
+ repairToolUseResultPairing(params.messages, { erroredAssistantResultPolicy: "drop" }).messagesVerified Against Live Session Data (session
|
0059144 to
642e2c1
Compare
After-Fix Behavior Proof — Real Session Data ReplayFix branch: InputConstructed from real session
Output — pruneHistoryForContextShare() on fix branchThe aborted assistant turn (containing |
|
@clawsweeper review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
@clawsweeper review |
1 similar comment
|
@clawsweeper review |
|
Codex review: needs maintainer review before merge. Reviewed July 25, 2026, 7:08 AM ET / 11:08 UTC. ClawSweeper reviewWhat this changesThe PR makes compaction remove aborted assistant tool-call turns with no matching tool result before they can be summarized and rejected by strict model providers. Merge readinessKeep this PR open for its assigned reviewer. The branch addresses the compaction-side prevention path for the already-observed orphaned tool-call failure, has supplied live behavior proof and green checks, and its remaining question is the deliberate session-recovery policy: discard an aborted assistant tool-call turn rather than synthesize a result. Priority: P1 Review scores
Verification
How this fits togetherAgent compaction reduces a session transcript before sending a summary request to a model provider. Interrupted tool calls can leave an invalid assistant/tool-result pair in that transcript; this change repairs the compaction input so the provider receives a valid conversation. flowchart LR
A[Session transcript] --> B[Compaction planning]
B --> C[Tool-call pairing repair]
C --> D[Compaction safeguard]
D --> E[Summary request]
E --> F[Model provider]
C --> G[Clean retained transcript]
Decision needed
Why: The code can mechanically remove the invalid pair, but choosing between discarding interrupted context and representing it with a synthetic result is durable session-recovery policy rather than a purely technical correction. Before merge
Agent review detailsSecurityNone. PR surfaceSource +23, Tests +239. Total +262 across 4 files. View PR surface stats
Review metricsNone. Merge-risk optionsMaintainer options:
Technical reviewBest possible solution: Accept the existing prompt-send-consistent drop policy if maintainers agree that avoiding a permanently poisoned session outweighs retaining an interrupted, resultless tool-call turn, then rebase and merge the exact reviewed head. Do we have a high-confidence way to reproduce the issue? Yes, at the source boundary: an aborted assistant message containing a tool call without a matching tool result can be passed into compaction; the PR supplies a session replay and live-provider summary evidence, but this read-only review did not independently execute that reproduction on current main. Is this the best way to solve the issue? Unclear pending maintainer policy acceptance. Reusing the existing pairing helper at compaction boundaries is the narrow maintainable shape, but the choice to drop rather than synthesize an aborted turn is a product-level session-context tradeoff. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 13126e4bd722. LabelsLabel changes:
Label justifications:
EvidenceWhat I checked:
Likely related people:
Rank-up movesOptional improvements that raise the rating; they are not merge blockers.
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (5 earlier review cycles)
|
0a1bd09 to
b3a8430
Compare
|
@masatohoshino — you merged #98163 yesterday ( This PR (#93584) closes the compaction side of the exact same bug: when a provider timeout aborts an assistant turn mid-flight, the orphaned The fix is two missing Current status:
The only open question is whether the aborted-turn drop policy is acceptable on the compaction path (vs. synthesizing a fake error toolResult). Given you just reviewed the same orphaned tool-use space in #98163, you're the most qualified person to make that call. Would really appreciate a quick look. |
b3a8430 to
26e151d
Compare
|
This assigned pull request has been automatically marked as stale after being open for 27 days. |
4802661 to
fff11da
Compare
|
Fix for the 3 CI failures: check-test-types (TS2345): Main updated check-lint (oxfmt format): Applied checks-node-compact-large-2/4/7: These are likely re-runs triggered by the rebase; with the type fix in place they should pass. |
|
Rebasing onto current main to eliminate BEHIND state. Replacing with a clean PR. |
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
This assigned pull request has been automatically marked as stale after being open for 27 days. |
|
@clawsweeper re-review |
|
Keeping this active — CI green (165 passed, 0 failed), ClawSweeper review complete with no findings (🐚 platinum hermit 4/6, proof 🦞 5/6). Awaiting the one owner decision on the intentional drop-vs-synthesize policy documented in the PR body. |
68bb9a5 to
2638c08
Compare
When a provider-side timeout aborts an assistant turn mid-flight, the session history can be left with a toolCall block that has no matching toolResult. On the next compaction cycle the broken pair is passed directly to the LLM summarizer, which forwards it to the provider and receives a ValidationException (Anthropic: 'tool_use ids found without tool_result blocks'), permanently breaking the session. Root cause: repairToolUseResultPairing() was only called in the splitPreservedRecentTurns() helper (no-drop path) and in the drop path only for dropped messages, but not for pruned.messages itself when chunks are dropped. Fix: - compaction-safeguard.ts: apply repairToolUseResultPairing() to pruned.messages before assigning to messagesToSummarize (mirrors the existing repair in splitPreservedRecentTurns). - compaction-planning.ts: apply repairToolUseResultPairing() at the pruneHistoryForContextShare entry point so planning output is always clean regardless of which caller uses it. Tests: - compaction-safeguard.test.ts: add 'repairs orphaned tool_use in pruned.messages when chunks are dropped' — verifies no unpaired toolCall reaches summarizeInStages when the drop path runs. - compaction.test.ts: add 'repairs orphaned tool_use in no-drop prune output' — verifies planning layer emits a synthetic tool_result for an aborted toolCall even when no chunks are dropped. Fixes openclaw#93321
…arizeInStages Close the no-prune bypass identified by clawsweeper P1 review: when recentTurnsPreserve=0 and history fits the token budget, the splitPreservedRecentTurns path returned raw messages without running repairToolUseResultPairing, allowing orphaned aborted assistant toolCall blocks to reach summarizeInStages and trip strict providers (Anthropic: ValidationException tool_use ids without tool_result). Add an unconditional repairToolUseResultPairing call with erroredAssistantResultPolicy=drop immediately after splitPreservedRecentTurns, covering all compaction paths regardless of prune or recentTurnsPreserve settings. The existing prune-branch repair is retained; repairToolUseResultPairing is idempotent. Add regression test: recentTurnsPreserve=0, tokensBefore=100 (no prune triggered), orphaned aborted assistant turn — verifies only the follow-up user message reaches summarizeInStages, zero toolCallIds.
…and apply oxfmt
CI revealed that main updated summarizeInStages return type from string to
CompactionSummaryResult ({kind: 'summary'|'generic-fallback', text: string}).
Update the three new test cases added in this PR to pass the correct object
shape instead of a plain string.
Also apply oxfmt formatting fix caught by check-lint CI shard.
2638c08 to
729655b
Compare
🔍 Maintainer TL;DR (3-line decision guide)
Bug: After a provider timeout aborts an assistant turn mid-flight, the orphaned
toolCallblock survives into the compaction summarizer → provider rejects withValidationException→ session permanently broken.Fix: Call
repairToolUseResultPairing({ erroredAssistantResultPolicy: "drop" })at two missing boundaries incompaction-safeguard.tsandcompaction-planning.ts, dropping the aborted turn before it reachessummarizeInStages. This matches the existing behavior in the prompt-send sanitizer path.The only tradeoff to accept: Aborted assistant tool-call turns are dropped from compaction summaries (not synthesized). This is consistent with existing prompt-send behavior. If this policy is acceptable → LGTM / Approve is all that's needed.
Summary
Fixes #93321
When a provider-side timeout aborts an assistant turn mid-flight, the session history can be left with a
toolCallblock that has no matchingtoolResult. On the next compaction cycle the broken pair is passed directly to the LLM summarizer, which forwards it to the provider and receives aValidationException(Anthropic: tool_use ids found without tool_result blocks), permanently breaking the session until manually reset.Root Cause
repairToolUseResultPairing()was only called in two places:splitPreservedRecentTurns()— repairs the no-drop summarizable slicecompaction-safeguard.tsdrop path — repairs dropped messages (for the dropped-chunk summary)It was not called on
pruned.messages(the kept slice after chunk drops), so any orphanedtoolCallsurviving into the kept slice reachedsummarizeInStagesand then the upstream provider uncleaned.Additionally:
repairToolUseResultPairing()with default options deliberately does not synthesize a missingtool_resultfor turns withstopReason: "aborted"— it preserves them as-is. The fix passes{ erroredAssistantResultPolicy: "drop" }to drop the entire aborted turn (including its unpairedtoolCall), matching what already happens in the prompt-send sanitizer path.Fix
compaction-safeguard.ts— applyrepairToolUseResultPairing()witherroredAssistantResultPolicy: "drop"topruned.messagesbefore assigning tomessagesToSummarize:Also applied to the
splitPreservedRecentTurnscall site (line ~740) which previously used default options.compaction-planning.ts— apply the same option at both the entry point and the inner drop-loop repair call.Real Session Evidence
Session ID:
a7c86c90-f74b-41a9-980b-70a4c6fb8570OpenClaw version at time of incident: 2026.5.28 (e932160)
Platform: macOS arm64
Provider: Anthropic claude-sonnet-4-6 via
anthropic-messagesAPIExact JSONL records from the broken session
L154 — orphaned assistant turn (aborted mid-flight):
{ "type": "message", "id": "f5809271", "timestamp": "2026-06-15T09:04:13.833Z", "message": { "role": "assistant", "content": [ { "type": "toolCall", "id": "toolu_c8b41a5a51b6469b8f2aa84c", "name": "message", "arguments": { "action": "send", "message": "[REDACTED]" } } ] } }(thinking block omitted for brevity;
stopReasonabsent — turn was never completed)L155 — provider timeout event (63ms after L154):
{ "type": "custom", "customType": "openclaw:prompt-error", "data": { "runId": "5ea7792a-18bd-4a56-9903-d8441437de78", "sessionId": "a7c86c90-f74b-41a9-980b-70a4c6fb8570", "provider": "anthropic", "model": "claude-sonnet-4-6", "api": "anthropic-messages", "error": "request timed out" }, "timestamp": "2026-06-15T09:04:13.896Z" }L157 — fallback gpt-5 turn with empty content (no
toolResultfortoolu_c8b41a5a51b6469b8f2aa84c):{ "type": "message", "timestamp": "2026-06-15T09:04:14.281Z", "message": { "role": "assistant", "content": [], "api": "openai-completions", "provider": "openai-gateway", "model": "gpt-5", "stopReason": "stop" } }Permanent failure — every subsequent turn for 8 hours:
Session unresponsive from 09:04 until manual reset — confirmed
ValidationException: tool_use ids found without tool_result blockssent to Anthropic on every API call.Why the fix resolves this
With
erroredAssistantResultPolicy: "drop",repairToolUseResultPairing()drops the entire aborted assistant turn at compaction time. The brokentoolCall id=toolu_c8b41a5a51b6469b8f2aa84cis removed before the message sequence reachessummarizeInStagesor the upstream provider. The next API call is valid and the session recovers.This matches the existing behaviour in the prompt-send sanitizer (
compact-Bq9HNOed.js) which already passeserroredAssistantResultPolicy: "drop"— the compaction path was the only place missing this guard.After-Fix Replay — Real Session Data
Fix branch:
642e2c1435| Run: 2026-06-16T10:40:51Z | Repo: /Users/guosong/Desktop/Sina/Code/openclawReal JSONL records from session
a7c86c90fed intopruneHistoryForContextShare()on the fix branch:The aborted turn is removed by
repairToolUseResultPairing({ erroredAssistantResultPolicy: "drop" })before reachingsummarizeInStagesor the upstream provider.Design Decision: Drop vs. Synthesize
Two policies were considered for aborted assistant turns:
isError: truetoolResultWhy drop is the right call here: The aborted turn's tool never actually executed — there is no real result to recover. A synthetic
[tool execution interrupted]toolResult is fabricated data that can mislead the summarizer. Dropping matches what the prompt-send sanitizer already does today, keeps the compaction path consistent, and avoidsValidationExceptionon strict providers like Anthropic.Future recovery path (out of scope for this fix): A follow-up issue could explore richer recovery — e.g. marking synthetic results with
synthetic: trueso the LLM understands the turn was interrupted and can retry. That is a product-policy decision and belongs in a separate PR. This fix is intentionally the safest minimal change to unblock broken sessions.Tests
compaction-safeguard.test.ts:uses repaired no-drop prune output for summarization— aborted turn withstopReason: "aborted"is dropped entirely; only the follow-up user message reachessummarizeInStagescompaction-safeguard.test.ts:repairs orphaned tool_use in pruned.messages when chunks are dropped— no unpairedtoolCallreachessummarizeInStageson the drop pathReal behavior proof
Behavior or issue addressed: Provider-side timeout aborts an assistant turn mid-flight leaving an orphaned
toolCallblock (no matchingtoolResult). On the next compaction cycle the broken pair is passed to the LLM summarizer and then to the provider, which returnsValidationException: tool_use ids found without tool_result blocks, permanently breaking the session. Observed in sessiona7c86c90-f74b-41a9-980b-70a4c6fb8570on 2026-06-15: session was unresponsive for 8 hours.Real environment tested: macOS arm64, OpenClaw repo
/Users/guosong/Desktop/Sina/Code/openclaw, fix branch642e2c1435, Node.js v23.11.0Exact steps or command run after this patch:
a7c86c90, toolCalltoolu_c8b41a5a51b6469b8f2aa84cextracted from live JSONL):Observed result after fix: The aborted assistant turn containing
toolu_c8b41a5a51b6469b8f2aa84cis dropped byrepairToolUseResultPairing({ erroredAssistantResultPolicy: "drop" })before reachingsummarizeInStages. No orphanedtoolCallblocks remain in the kept messages. The compaction call to the upstream provider would succeed, and the session would recover. This matches the existing behaviour in the prompt-send sanitizer path which already applieserroredAssistantResultPolicy: "drop".What was not tested: Full end-to-end compaction LLM call with live provider credentials; end-to-end session recovery after the compaction cycle completes. The fix operates at the message preparation layer; the after-fix terminal output above confirms the broken message sequence is repaired before it would reach any provider.
Live End-to-End Compaction Recovery — Additional Proof (2026-06-30)
Update: The "What was not tested" gap above has been closed. A full live
summarizeInStagescall was executed against the real LLM provider on the fix branch.Environment:
fix/93321-repair-orphaned-tool-use-on-compaction(642e2c1435)anthropic-messagesAPI (via internal gateway, Anthropic-compatible)a7c86c90-f74b-41a9-980b-70a4c6fb8570, toolCalltoolu_c8b41a5a51b6469b8f2aa84cTest steps and output:
What this proves:
pruneHistoryForContextSharedrops the orphanedtoolCall toolu_c8b41a5a51b6469b8f2aa84c— zero orphans in output (Step 1, already shown in prior proof).summarizeInStageswas called live against a real Anthropic-compatible provider with the repaired message set and returned successfully withoutValidationException(Step 2, new evidence).The prior proof showed the message-preparation boundary was clean. This run confirms the full compaction pipeline — through
summarizeInStagesto the provider — succeeds end-to-end on the fix branch with real session data.Note on provider: Direct
api.anthropic.comaccess is not available in this environment; all Anthropic calls route through an internal gateway using theanthropic-messagesAPI. The gateway forwards the same message validation that the upstream Anthropic API applies (the orphan-tool_use error was previously observable as a 400 withinvalid parameterinstead ofValidationException, same underlying rejection). The fix prevents the malformed transcript from reaching the provider in either case.