Skip to content

fix(compress): keep reasoning paired with its assistant message across compression - #68

Merged
ranxianglei merged 3 commits into
masterfrom
2026-08-13_fix-reasoning-pairing
Aug 13, 2026
Merged

ranxianglei merged 3 commits into
masterfrom
2026-08-13_fix-reasoning-pairing

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

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's reasoning_content as a separate contentType: "reasoning" core message, followed by separate text/tool-call messages of the same turn. Pairing between reasoning and its companion text is purely positional (the coreToOpenai pending accumulator 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/extra reasoning_content. DeepSeek-thinking strictly requires a byte-consistent historical reasoning_content and 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

Layer Tool-pair (existing) Reasoning-pair (new)
Compress boundary expansion adjustBoundariesForToolPairs adjustBoundariesForReasoningPairs
Compose both — applyPairBoundaryAdjustments (fixpoint ≤2 passes)
Rebuild safety net stripOrphanedToolCalls/stripOrphanedToolResults stripOrphanedReasoning

1. src/reasoning-pairs.ts (new)

adjustBoundariesForReasoningPairs(startIndex, endIndex, messages) — scans the in-range messages:

  • a reasoning msg → 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;
  • an assistant text/tool-call → pulls the preceding reasoning run backward into the range.

Handles multi-message reasoning runs. No-op when startIndex > endIndex.

2. src/compress.ts

Renamed 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.ts

Added stripOrphanedReasoning(messages), wired as the outermost step in prune() — runs after stripOrphanedToolCalls/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=none opt-in workaround in billion-context PR #139 remains useful only as a token-saving escape hatch — no longer needed for correctness.

Tests

tests/reasoning-pairs.test.ts — 13 new tests, mirroring tool-pairs.test.ts:

  • 7 unit tests for adjustBoundariesForReasoningPairs (no-op, forward pull, backward pull, both-direction straddle, tool-call companion, no-companion-no-extend, multi-reasoning-run)
  • 4 compression integration tests (auto-include companion text, auto-include reasoning backward, reasoning+toolcall pulls tool-result via the fixpoint, uncompressed pair survives)
  • 2 prune stripOrphanedReasoning tests (dangling reasoning stripped, intact pair kept)
typecheck: clean
test: 270/270 pass (was 257; +13 new)
build: success (109 KB)

Note

src/compress.ts carries some pre-existing prettier drift on master (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:check is not a CI gate; CI runs typecheck/test/build/branch-name.)

Follow-up (separate)

After this merges and publishes: bump the acp-kernel pin in billion-context (PR #139's ACP_REASONING_KEEP=none stays as an opt-in token-saving escape hatch).

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant