Repository navigation
fix(compress): keep reasoning paired with its assistant message across compression - #68
Merged
Merged
Conversation
…s compression
Mirrors the tool-pair mechanism to guarantee reasoning_content and its
companion assistant text/tool-call always compress together. Prevents
DeepSeek-thinking 400 ('reasoning_content must be passed back') and the
same latent class on Anthropic/Responses paths.
- adjustBoundariesForReasoningPairs: expands a compress range so a
reasoning msg pulls in its companion assistant text/tool-call (forward)
and vice versa (backward). Handles multi-msg reasoning runs.
- applyPairBoundaryAdjustments: composes reasoning-pair + tool-pair
boundary expansion to a fixpoint (<=2 passes, monotonic widening).
- stripOrphanedReasoning: rebuild safety net (outermost in prune) drops
a reasoning whose companion was removed.
Fixes ranxianglei/billion-context#133
…ns a pair Two end-to-end regression tests reproducing the exact failure mode of ranxianglei/billion-context#133: a compress range that covers only the reasoning (or only the companion text) of an assistant turn. Verified as true breaking tests: with adjustBoundariesForReasoningPairs disabled, the forward case fails (orphaned assistant text with no reasoning_content -> DeepSeek 400). The backward case is also caught by the stripOrphanedReasoning safety net, demonstrating defense-in-depth.
This was referenced Aug 13, 2026
Merged
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Fixes the root cause of ranxianglei/billion-context#133 (DeepSeek thinking mode 400
"reasoning_content in the thinking mode must be passed back to the API"after compression), and the same latent class on the Anthropic and Responses paths.Root cause
All three protocol adapters (
openai.ts,responses.ts,anthropic.ts) emit each assistant turn'sreasoning_contentas a separatecontentType: "reasoning"core message, followed by separatetext/tool-callmessages of the same turn. Pairing between reasoning and its companion text is purely positional (thecoreToOpenaipendingaccumulator assumes a reasoning msg immediately precedes its assistant text).acp-kernel had a robust tool-pair mechanism (
adjustBoundariesForToolPairs+stripOrphanedToolCalls/stripOrphanedToolResults) that guarantees a tool-call and its tool-result always compress together — but no equivalent for reasoning↔text. So a compress range could cover a reasoning message but not its companion assistant text (or vice versa), leaving an orphaned half that, on rebuild, produces an assistant message missing/extrareasoning_content. DeepSeek-thinking strictly requires a byte-consistent historicalreasoning_contentand returns HTTP 400 on mismatch; gpt-5-codex (Responses) and Anthropic are lenient, so the bug is latent there.Fix — mirror the tool-pair mechanism
adjustBoundariesForToolPairsadjustBoundariesForReasoningPairsapplyPairBoundaryAdjustments(fixpoint ≤2 passes)stripOrphanedToolCalls/stripOrphanedToolResultsstripOrphanedReasoning1.
src/reasoning-pairs.ts(new)adjustBoundariesForReasoningPairs(startIndex, endIndex, messages)— scans the in-range messages:reasoningmsg → pulls its companion assistant text/tool-call (the run of assistant messages following the reasoning run, up to a non-assistant boundary) forward into the range;Handles multi-message reasoning runs. No-op when
startIndex > endIndex.2.
src/compress.tsRenamed
applyToolPairAdjustment→applyPairBoundaryAdjustments, which composes reasoning-pair and tool-pair adjustment to a fixpoint (≤2 passes). Both adjusters only ever widen the range, so a second pass reaches fixpoint: reasoning may pull in a tool-call whose result tool-pairs then extends for, and vice versa.3.
src/prune.tsAdded
stripOrphanedReasoning(messages), wired as the outermost step inprune()— runs afterstripOrphanedToolCalls/stripOrphanedToolResults, because removing an orphaned tool-call can leave a reasoning message dangling. Drops a reasoning whose companion assistant text/tool-call is absent (degenerate straddle / malformed input).Why in the kernel (not the adapter)
The pairing invariant belongs at the compression boundary, not per-adapter. Doing it here means all three protocol paths (OpenAI chat/completions, Responses/codex, Anthropic) are fixed with one change, and the
ACP_REASONING_KEEP=noneopt-in workaround inbillion-contextPR #139 remains useful only as a token-saving escape hatch — no longer needed for correctness.Tests
tests/reasoning-pairs.test.ts— 13 new tests, mirroringtool-pairs.test.ts:adjustBoundariesForReasoningPairs(no-op, forward pull, backward pull, both-direction straddle, tool-call companion, no-companion-no-extend, multi-reasoning-run)stripOrphanedReasoningtests (dangling reasoning stripped, intact pair kept)Note
src/compress.tscarries some pre-existing prettier drift onmaster(prettier 3.9.6 wants to reformat unrelated lines). This PR does not reformat those — it keeps the diff to the logical change only. (format:checkis not a CI gate; CI runs typecheck/test/build/branch-name.)Follow-up (separate)
After this merges and publishes: bump the
acp-kernelpin inbillion-context(PR #139'sACP_REASONING_KEEP=nonestays as an opt-in token-saving escape hatch).