← Back to PRs

#20980: fix(guard): pair-atomic tool_use/tool_result commit — prevent orphaned tool_use in JSONL

by amabito open 2026-02-19 14:21 View on GitHub →
cli agents size: L
## Summary - `installSessionToolResultGuard` now holds assistant messages that contain `tool_use` blocks in a local pair buffer, committing the pair to JSONL only after all matching `tool_result` IDs are received. - `flushPendingToolResults()` defaults to **discard** mode: on any interruption the incomplete pair is dropped without being written, so no orphaned `tool_use` block ever reaches JSONL. - Legacy `OPENCLAW_TOOL_GUARD_ABORT_MODE=synthetic` preserves the previous synthesize-and-commit behavior. ## Why Tool-use aborts (rate-limit, provider failover, user cancel) previously left the session JSONL in an inconsistent state: an assistant `tool_use` block without a corresponding `tool_result`. Strict providers (Anthropic, Cloud Code Assist, MiniMax) reject any subsequent API call that includes an orphaned `tool_use` in its history, causing a hard 400 error that survives across sessions until the JSON file is manually repaired. ## Changes ### `session-tool-result-guard.ts` - Added `toolPairBuffer: AgentMessage[]` alongside the existing `pending` map. - `guardedAppend`: assistant messages with `tool_use` are buffered rather than written to JSONL; each incoming `tool_result` advances the buffer; when `pending.size === 0`, `commitToolPairBuffer()` writes the whole pair atomically. - `validatePairIntegrity()` guards the commit: if a buffer somehow arrives with mismatched IDs it is discarded instead of committed. - `flushPendingToolResults`: new env-var-driven abort mode (see table below). **Why discard on hook suppression?** In the hook-suppressed path we intentionally discard the in-flight buffer instead of attempting a commit, because a suppressed `tool_result` would otherwise allow a lone `tool_use` to persist and corrupt the transcript. This keeps the pair-atomic contract strict. For environments that relied on legacy behavior, `OPENCLAW_TOOL_GUARD_ABORT_MODE=synthetic` preserves the prior "synthesize-and-commit" semantics (only when `allowSyntheticToolResults` is enabled). ### `attempt.ts` - Added `FAILOVER COVERAGE` comment to the `finally` block documenting this as the single flush point for all exit paths (abort, rate-limit, provider failover). ## Abort mode | `OPENCLAW_TOOL_GUARD_ABORT_MODE` | Behavior | |----------------------------------|----------| | unset / `discard` (new default) | buffer discarded; structured log emitted | | `synthetic` | synthetic error `tool_result` synthesized and committed (legacy) | ## Tests ### New — `session-tool-result-guard.atomic-commit.test.ts` 5 cases: - Case 1: abort during tool execution → 0 messages, no orphan - Case 2: failover during tool execution → 0 messages, no orphan - Case 3: rate-limit interruption → user message preserved, no orphan - Case 4: normal pair → `[assistant, toolResult]` committed in order - Case 5: first pair committed, second aborted → only first pair in JSONL ### Updated — `pi-embedded-runner.guard.waitforidle-before-flush.test.ts` - Intermediate assertion: nothing in JSONL while pair is in flight (was incorrect: showed `assistant` prematurely). - Added timeout / discard-mode test (expects 0 entries). - Added timeout / synthetic-mode test (expects `[assistant, toolResult]` with synthetic error). - Env var management via `beforeEach`/`afterEach`. <!-- greptile_comment --> <h3>Greptile Summary</h3> Implements pair-atomic `tool_use`/`tool_result` commit to prevent orphaned `tool_use` blocks in session JSONL when runs are interrupted (abort, rate-limit, provider failover). The guard now buffers assistant messages containing `tool_use` blocks and only commits the complete pair to JSONL after all matching `tool_result` IDs arrive. On interruption, the incomplete pair is discarded by default (new `discard` mode), ensuring no corrupt state reaches JSONL. Legacy `synthetic` mode preserves the previous behavior via `OPENCLAW_TOOL_GUARD_ABORT_MODE=synthetic`. Key improvements: - Introduced `toolPairBuffer` to hold assistant messages with `tool_use` until their results complete - Added `validatePairIntegrity()` to ensure all `tool_use` IDs have matching `tool_result` IDs before commit - New default `discard` mode drops incomplete pairs on any interruption (abort, failover, rate-limit, hook suppression) - Comprehensive test coverage for all abort scenarios and pair commit edge cases - Added `dropOrphanedToolPairs()` utility for session repair when loading corrupted transcripts <h3>Confidence Score: 5/5</h3> - This PR is safe to merge with minimal risk - The implementation is well-designed with strong safeguards: pair-atomic commit ensures JSONL consistency, comprehensive test coverage validates all abort scenarios (5 new tests covering abort/failover/rate-limit/normal/sequential cases), backward compatibility is maintained via environment variable, and the discard-by-default behavior is conservative and safe. The logic is clean, well-commented, and follows the repository's coding standards. - No files require special attention <sub>Last reviewed commit: c34ea89</sub> <!-- greptile_other_comments_section --> <!-- /greptile_comment -->

Most Similar PRs