fix(guard): pair-atomic tool_use/tool_result commit — prevent orphaned tool_use in JSONL#20980
fix(guard): pair-atomic tool_use/tool_result commit — prevent orphaned tool_use in JSONL#20980amabito wants to merge 5 commits into
Conversation
c34ea89 to
c9b044b
Compare
c9b044b to
96d8cb8
Compare
nikolasdehor
left a comment
There was a problem hiding this comment.
Thorough review of the full diff. This is a well-designed fix for a real problem — orphaned tool_use blocks in session JSONL causing hard 400 errors on Anthropic/Cloud Code Assist that survive across sessions.
The pair-atomic approach is the right call: buffer the assistant tool_use message, accumulate matching tool_result IDs via the pending map, commit the whole pair only when pending.size === 0. The validatePairIntegrity() guard at commit time is a good safety net against edge cases where IDs somehow mismatch.
Key things I verified:
-
All exit paths flush correctly. The
finallyblock inattempt.tsis the single flush point for abort/rate-limit/failover.run.tscallsadvanceAuthProfile()only afterrunEmbeddedAttempt()returns, so no orphaned state leaks into the next provider attempt. -
Hook suppression is handled. When
applyBeforeWriteHooksuppresses atool_result, the buffer is discarded (viahook_suppressed_tool_result). One subtlety: thepending.size === 0check means this only triggers when the suppressed result was the last pending one. For multi-tool-call assistants where a hook suppresses one result but not others, the incomplete pair persists until the next turn boundary hitsnew_turn_before_pair_completed. This is fine — the orphan never reaches JSONL either way — but worth a brief inline comment if you want to make the intent explicit. -
Legacy compat. The
OPENCLAW_TOOL_GUARD_ABORT_MODE=syntheticpath correctly pushes synthesized results into the buffer, clearspending, then commits. Clean fallback for environments relying on the old behavior. -
dropOrphanedToolPairs()for session repair. Good complement to the guard — handles already-corrupted JSONL on load. Correctly skipserror/abortedstop reasons and preserves non-tool-use content blocks in mixed assistant messages. -
Test coverage. 5 atomic-commit cases (abort, failover, rate-limit, normal, sequential) plus the updated wait-for-idle tests covering both discard and synthetic modes. The
hasOrphanToolUsehelper in the test is a nice reusable invariant check.
LGTM. Clean implementation, proper edge case handling, and good test coverage.
3d5bdda to
05f4691
Compare
05f4691 to
da36c45
Compare
1c20c8a to
80ac4ed
Compare
0e78c2e to
2315445
Compare
|
This pull request has been automatically marked as stale due to inactivity. |
…ault Prevent orphaned tool_use blocks from being written to JSONL when a run is interrupted (abort, failover, rate-limit). The pair buffer is held in memory and committed atomically only when all tool_results for the turn have been received. Key changes: - session-tool-result-guard.ts: introduce toolPairBuffer; assistant messages with tool_use are now buffered (not written) until the matching tool_result(s) arrive. On any interruption, the buffer is discarded by default (OPENCLAW_TOOL_GUARD_ABORT_MODE=synthetic preserves legacy behaviour). discardToolPairBuffer() logs structured fields: discard_reason, discarded_tool_use_count, discarded_tool_result_count. - session-transcript-repair.ts: add dropOrphanedToolPairs() — strict removal of unmatched tool_use/tool_result pairs without synthesising replacements. - session-tool-result-guard.atomic-commit.test.ts: 5 new cases covering abort, failover, rate-limit, normal pair, and partial (first pair committed / second pair aborted) scenarios. - pi-embedded-runner.guard.waitforidle-before-flush.test.ts: update existing tests to reflect pair-atomic semantics (nothing written until pair completes) and add explicit coverage for synthetic mode.
…over coverage
Two minimal follow-up fixes after the pair-atomic commit:
1. session-tool-result-guard.ts: the hook-suppressed-result path previously
called commitToolPairBuffer() and relied on validatePairIntegrity() to
detect the incomplete pair and discard. This obscured the intent. Change
to call discardToolPairBuffer("hook_suppressed_tool_result") directly,
making it explicit that a suppressed result cannot satisfy its tool_use
counterpart and the entire pair must be dropped.
2. attempt.ts: add a FAILOVER COVERAGE comment to the finally block that
calls flushPendingToolResultsAfterIdle(). Documents that this is the
single flush point for all abort/rate-limit/provider-failover exit paths:
run.ts advances to the next auth profile only after runEmbeddedAttempt()
returns, so incomplete pairs are always discarded before the next attempt.
…inference and missing schedule field)
cf56045 to
9ba3651
Compare
Existing tests expected assistant messages with tool_use to be immediately persisted to JSONL. With pair-atomic buffering, these messages are held until matching tool_results arrive. Updated tests to either: set OPENCLAW_TOOL_GUARD_ABORT_MODE=synthetic with explicit flush, or adjust expectations for discard-default semantics.
9ba3651 to
7585244
Compare
|
The remaining CI failure (
Could a maintainer re-run the failed job? I don't have permission to trigger re-runs on this repo. |
|
Any update on this? Happy to rebase if needed. |
|
This pull request has been automatically marked as stale due to inactivity. |
|
Closing due to inactivity. |
Summary
installSessionToolResultGuardnow holds assistant messages that containtool_useblocks in a local pair buffer, committing the pair to JSONL only after all matchingtool_resultIDs are received.flushPendingToolResults()defaults to discard mode: on any interruption the incomplete pair is dropped without being written, so no orphanedtool_useblock ever reaches JSONL.OPENCLAW_TOOL_GUARD_ABORT_MODE=syntheticpreserves 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_useblock without a correspondingtool_result. Strict providers (Anthropic, Cloud Code Assist, MiniMax) reject any subsequent API call that includes an orphanedtool_usein its history, causing a hard 400 error that survives across sessions until the JSON file is manually repaired.Changes
session-tool-result-guard.tstoolPairBuffer: AgentMessage[]alongside the existingpendingmap.guardedAppend: assistant messages withtool_useare buffered rather than written to JSONL; each incomingtool_resultadvances the buffer; whenpending.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_resultwould otherwise allow a lonetool_useto persist and corrupt the transcript. This keeps the pair-atomic contract strict. For environments that relied on legacy behavior,OPENCLAW_TOOL_GUARD_ABORT_MODE=syntheticpreserves the prior "synthesize-and-commit" semantics (only whenallowSyntheticToolResultsis enabled).attempt.tsFAILOVER COVERAGEcomment to thefinallyblock documenting this as the single flush point for all exit paths (abort, rate-limit, provider failover).Abort mode
OPENCLAW_TOOL_GUARD_ABORT_MODEdiscard(new default)synthetictool_resultsynthesized and committed (legacy)Tests
New —
session-tool-result-guard.atomic-commit.test.ts5 cases:
[assistant, toolResult]committed in orderUpdated —
pi-embedded-runner.guard.waitforidle-before-flush.test.tsassistantprematurely).[assistant, toolResult]with synthetic error).beforeEach/afterEach.Greptile Summary
Implements pair-atomic
tool_use/tool_resultcommit to prevent orphanedtool_useblocks in session JSONL when runs are interrupted (abort, rate-limit, provider failover). The guard now buffers assistant messages containingtool_useblocks and only commits the complete pair to JSONL after all matchingtool_resultIDs arrive. On interruption, the incomplete pair is discarded by default (newdiscardmode), ensuring no corrupt state reaches JSONL. Legacysyntheticmode preserves the previous behavior viaOPENCLAW_TOOL_GUARD_ABORT_MODE=synthetic.Key improvements:
toolPairBufferto hold assistant messages withtool_useuntil their results completevalidatePairIntegrity()to ensure alltool_useIDs have matchingtool_resultIDs before commitdiscardmode drops incomplete pairs on any interruption (abort, failover, rate-limit, hook suppression)dropOrphanedToolPairs()utility for session repair when loading corrupted transcriptsConfidence Score: 5/5
Last reviewed commit: c34ea89