Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .ework-daemon/issue-body.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
**Setup:** billion-context 0.1.212 (`daa082ead`), Claude Code 2.1.311, `claude-haiku-5-5` with adaptive thinking (signature-only thinking blocks), Anthropic wire through LiteLLM to AWS Bedrock. Config: `{"compress":{"modelContextLimit":55573,"nudgeGrowthTokens":2000}}`. Related: #4885, #4886, PR #4898, #5180.

**Steps:**
1. Ask the agent to read a large file with `Read` (1,500 lines, about 24K tokens), then answer a question.
2. The nudge makes the model call `compress` on the Read exchange in the same tool loop.
3. bili logs `Compressed m00004–m00005 ... Excluded 1 protected message(s) m00003 from compression range (recent/last-user zone)`. `m00003` is the signed thinking block that comes before the Read `tool_use`.
4. The next outbound assistant message is `[thinking A (orphan), thinking B, text...]`. Upstream returns `400 messages.N.content.1: thinking or redacted_thinking blocks in the latest assistant message cannot be modified`.

**Rate:** 11 of 123 upstream requests (8.9%) over 11 three-turn sessions.

**Cause:** `computeProtectedRefs` (`kernel/src/recommend.ts`) skips `neverPreserveRecentTools` matches (`read` by default), so the Read call and result are not protected. Rules 1 and 2 still protect the reasoning core before the call. The #2886 closure covers tool pairs only. Then the advisory carve (`kernel/src/unit-projection.ts:320-323`) removes the protected reasoning from the fold that `adjustBoundariesForReasoningPairs` had widened, and `coreToAnthropic` merges the orphan into the next assistant message.

**Minimal kernel test** (fails on 0.1.212): messages `[user, reasoning(signed) th1, tool-call Read c1, tool-result Read r1 (40K chars), reasoning(signed) th2, tool-call Grep c2, tool-result r2]` with `defaultConfig(100000)`. Expected: `computeProtectedRefs` does not protect `th1`, because its companion `c1` is not protected. Actual: `th1` is protected and `c1` is not.

**Proposed fix:** after the #2886 tool-pair closure, remove a reasoning run from the protected set when its assistant companion is not protected (15 lines, patch attached). With it: 0 of 62 upstream 400s, the same fold savings, and all 1,563 kernel tests pass. PR #4898 also fixes this case (0/35 in our back-port).

A patch for 0.1.212 (about 15 lines in the kernel, plus a regression test) is attached below.

```diff
diff --git a/kernel/src/recommend.ts b/kernel/src/recommend.ts
index d2cd66982..d2ffed920 100644
--- a/kernel/src/recommend.ts
+++ b/kernel/src/recommend.ts
@@ -192,6 +192,21 @@ export function computeProtectedRefs(
}
}

+ for (let i = 0; i < messages.length; i++) {
+ if (messages[i]!.contentType !== "reasoning") continue;
+ let j = i;
+ while (j + 1 < messages.length && messages[j + 1]!.contentType === "reasoning") j++;
+ const companion = messages[j + 1];
+ const companionRef = companion?.id ? state.messageRefs.byRaw[companion.id] : undefined;
+ if (companion?.role === "assistant" && companionRef && companionRef !== "BLOCKED" && !result.has(companionRef)) {
+ for (let k = i; k <= j; k++) {
+ const ref = state.messageRefs.byRaw[messages[k]!.id];
+ if (ref) result.delete(ref);
+ }
+ }
+ i = j;
+ }
+
return result;
}

diff --git a/kernel/tests/bili-fix-reasoning-protect.test.ts b/kernel/tests/bili-fix-reasoning-protect.test.ts
new file mode 100644
index 000000000..190bc2041
--- /dev/null
+++ b/kernel/tests/bili-fix-reasoning-protect.test.ts
@@ -0,0 +1,28 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { createInitialState } from "../src/state.js";
+import { assignRefs } from "../src/refs.js";
+import { computeProtectedRefs } from "../src/recommend.js";
+import { defaultConfig } from "../src/config.js";
+import type { CoreMessage } from "../src/types.js";
+
+const messages: CoreMessage[] = [
+ { id: "u", role: "user", contentType: "text", text: "Read data.log and report record 42" },
+ { id: "th1", role: "assistant", contentType: "reasoning", text: "", thinkingSignature: "sig-1" },
+ { id: "c1", role: "assistant", contentType: "tool-call", toolName: "Read", toolCallId: "t1", text: "{}" },
+ { id: "r1", role: "tool", contentType: "tool-result", toolName: "Read", toolCallId: "t1", text: "x".repeat(40000) },
+ { id: "th2", role: "assistant", contentType: "reasoning", text: "", thinkingSignature: "sig-2" },
+ { id: "c2", role: "assistant", contentType: "tool-call", toolName: "Grep", toolCallId: "t2", text: "{}" },
+ { id: "r2", role: "tool", contentType: "tool-result", toolName: "Grep", toolCallId: "t2", text: "42" },
+];
+
+test("signed reasoning is not protected when its tool-call companion is outside the recent zone", () => {
+ const state = createInitialState();
+ state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map;
+ const ref = (id: string) => state.messageRefs.byRaw[id]!;
+ const protectedRefs = computeProtectedRefs(messages, state, defaultConfig(100000));
+ assert.equal(protectedRefs.has(ref("c1")), false);
+ assert.equal(protectedRefs.has(ref("th1")), false);
+ assert.equal(protectedRefs.has(ref("th2")), true);
+ assert.equal(protectedRefs.has(ref("c2")), true);
+});

```
4 changes: 2 additions & 2 deletions kernel/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion kernel/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "acp-kernel",
"version": "0.0.195",
"version": "0.0.196",
"description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.",
"license": "MIT",
"author": "ranxianglei",
Expand Down
52 changes: 49 additions & 3 deletions kernel/src/reasoning-pairs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CoreMessage } from "./types.js";
import { isSignedThinkingFamily } from "./turn-integrity.js";

/**
* Adjust compression range boundaries to keep a `reasoning` message together
Expand All @@ -25,6 +26,15 @@ import type { CoreMessage } from "./types.js";
* reasoning run immediately preceding it. This matches the round-trip contract
* every adapter relies on when reconstructing reasoning_content.
*
* Signed extension (#4885): for UNSIGNED reasoning the adjacency rule above
* holds exactly (the #564 directional allowance depends on it). When the
* stretch preceding a burst carries a SIGNED thinking-family block (Anthropic
* thinking signature / redacted_thinking sidecar), the backward pull widens to
* the whole consecutive reasoning+assistant-text stretch instead: rebuild
* merges that stretch into one provider message, so leaving any part of it
* behind orphans the signature (permanent 400). See
* isSignedThinkingFamily (turn-integrity.ts) for the predicate.
*
* @returns Adjusted { startIndex, endIndex } — may be wider than input.
*/
export function adjustBoundariesForReasoningPairs(
Expand Down Expand Up @@ -81,18 +91,54 @@ export function adjustBoundariesForReasoningPairs(
(msg.contentType === "text" || msg.contentType === "tool-call")
) {
// Backward: pull the reasoning run immediately preceding this assistant
// message into the range.
// message into the range (legacy adjacent-run behavior — unchanged for
// unsigned wires, where the #564 directional allowance depends on it).
let k = i - 1;
while (k >= 0 && messages[k]!.contentType === "reasoning") {
k--;
}
const runStart = k + 1;
if (
const legacyPull =
runStart < i &&
runStart >= 0 &&
messages[runStart]!.contentType === "reasoning" &&
runStart < newStartIndex
runStart < newStartIndex;
// #4885: rebuild merges the whole consecutive-assistant stretch into ONE
// provider message, so a signed thinking block must never be left behind
// by a fold. The adjacent scan above stops at the FIRST non-reasoning
// message, which orphans the signature when prose sits between the
// thinking and its tool_use ([thinking][text][tool_use], redacted_
// thinking separators, multiple thinking blocks) — permanent 400 on the
// Anthropic wire. When the stretch preceding this burst carries a signed
// thinking-family block, pull the ENTIRE stretch (contiguous reasoning +
// assistant-text, stopping at anything else — tool results and foreign
// tool-calls always break it) into the range instead.
let s = i;
while (
s > 0 &&
messages[s - 1]!.role === "assistant" &&
(messages[s - 1]!.contentType === "text" ||
messages[s - 1]!.contentType === "tool-call")
) {
s--;
}
let u = s - 1;
let stretchStart = s;
let signedInStretch = false;
while (
u >= 0 &&
(messages[u]!.contentType === "reasoning" ||
(messages[u]!.role === "assistant" &&
messages[u]!.contentType === "text"))
) {
if (!signedInStretch && isSignedThinkingFamily(messages[u]!))
signedInStretch = true;
u--;
stretchStart = u + 1;
}
if (signedInStretch && stretchStart < newStartIndex) {
newStartIndex = stretchStart;
} else if (legacyPull) {
newStartIndex = runStart;
}
}
Expand Down
99 changes: 95 additions & 4 deletions kernel/src/turn-integrity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ function isAssistantAct(msg: CoreMessage): boolean {
);
}

/** Wire-sidecar fields carrying signature-verified thinking-family payloads
* (Anthropic thinking.signature, redacted_thinking blocks). Typed
* structurally rather than as BiliMessage because core modules must not
* import from src/wire/. */
interface SignedThinkingSidecar {
thinkingSignature?: string;
rawAnthropicBlock?: unknown;
}

/** #4885: does this core carry a signature-verified thinking-family payload?
* Anthropic verifies thinking / redacted_thinking blocks byte-for-byte when
* their message is replayed; separating such a block from the siblings of
* its wire message desynchronizes the signature from its context and bricks
* the session (400 "thinking ... blocks cannot be modified"). OpenAI
* reasoning_content is deliberately NOT counted: strict-echo providers
* accept the #564 reverse split (reasoning kept, call folded). */
export function isSignedThinkingFamily(msg: CoreMessage): boolean {
const m = msg as CoreMessage & SignedThinkingSidecar;
if (typeof m.thinkingSignature === "string" && m.thinkingSignature.length > 0)
return true;
const ab = m.rawAnthropicBlock;
return (
!!ab &&
typeof ab === "object" &&
(ab as { type?: unknown }).type === "redacted_thinking"
);
}

/**
* Atomic turn groups for compression integrity (#684).
*
Expand Down Expand Up @@ -174,16 +202,23 @@ export function buildIntegrityPre(messages: CoreMessage[]): IntegrityPre {
* withdrawn wholesale (all members stay visible).
* - INV2 — a tool-call and its tool-result are one exchange: a pair the fold
* would split is withdrawn together.
* - INV3 (#4885) — a run of consecutive assistant messages carrying a signed
* thinking-family block rebuilds into ONE provider message; partially
* folding such a run orphans the signature (it merges into the next
* surviving message → 400 on every request). A partially folded signed run
* is withdrawn wholesale. Unsigned runs keep the #564 directional
* allowance; a signed run folding TOGETHER is ordinary compression.
*
* The two invariants feed each other. Withdrawing a call for INV2 leaves a turn
* The invariants feed each other. Withdrawing a call for INV2 leaves a turn
* whose reasoning still folds while its call now stays visible — exactly the
* INV1 split — and withdrawing a turn for INV1 can strand a result whose call
* survived outside the turn. One pass in a fixed order misses both, so the gate
* repeats until nothing changes; a turn or pair is acted on once and reported
* once.
*
* The reverse direction stays allowed (#564): reasoning and text may stay
* visible while the call and its result fold, since that leaves a valid stream.
* The reverse direction stays allowed (#564): unsigned reasoning and text may
* stay visible while the call and its result fold, since that leaves a valid
* stream. Signed thinking-family blocks are the exception (INV3).
*
* Coverage is not "kept". An id in `invisibleIds` is already hidden by an
* active block: it neither counts as folding nor as kept-visible. A covered
Expand Down Expand Up @@ -212,10 +247,64 @@ export function computeIntegrityWithdrawals(
const callIdByMessageId = built.callIdByMessageId;
const resultIdByCallId = built.resultIdByCallId;
const groups = built.groups;

// #4885: maximal runs of consecutive assistant-role messages that carry at
// least one signed thinking-family block. Rebuild merges each run into ONE
// provider message, so a partially folded run desynchronizes the signature
// from its context. Tool results (role "tool") break runs by construction.
const signedRuns: string[][] = [];
{
let i = 0;
while (i < messages.length) {
if (messages[i]!.role !== "assistant") {
i++;
continue;
}
let j = i;
while (j + 1 < messages.length && messages[j + 1]!.role === "assistant")
j++;
let signed = false;
for (let k = i; k <= j && !signed; k++)
signed = isSignedThinkingFamily(messages[k]!);
if (signed) {
const members: string[] = [];
for (let k = i; k <= j; k++) {
const id = messages[k]!.id;
if (id) members.push(id);
}
signedRuns.push(members);
}
i = j + 1;
}
}
const handledSignedRuns = new Set<number>();
let signedRunWithdrawals = 0;

let changed = true;
while (changed) {
changed = false;

for (let r = 0; r < signedRuns.length; r++) {
if (handledSignedRuns.has(r)) continue;
const members = signedRuns[r]!;
// Partial fold only: a run folding together (all members in `remaining`)
// or staying whole (none in it) is fine — ordinary compression / no-op.
let foldedAny = false;
let keptAny = false;
for (const id of members) {
if (remaining.has(id)) foldedAny = true;
else keptAny = true;
}
if (!foldedAny || !keptAny) continue;
handledSignedRuns.add(r);
for (const id of members) {
remaining.delete(id);
withdrawn.add(id);
}
signedRunWithdrawals++;
changed = true;
}

for (let g = 0; g < groups.length; g++) {
if (handledTurns.has(g)) continue;
const group = groups[g]!;
Expand Down Expand Up @@ -255,7 +344,9 @@ export function computeIntegrityWithdrawals(

return {
withdrawn,
splitTurnCount: handledTurns.size,
// Signed-run withdrawals count as turn splits: they keep whole assistant
// turns intact for the same reason INV1 does.
splitTurnCount: handledTurns.size + signedRunWithdrawals,
splitPairCount: handledPairs.size,
};
}
6 changes: 5 additions & 1 deletion kernel/src/unit-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,14 +404,18 @@ export function projectUnit(input: UnitProjectionInput): UnitProjection {
// leaves a valid message stream and stays allowed (#564 depends on it), so
// only turns whose KEPT side carries a tool-call while the FOLDED side
// carries the reasoning are withdrawn, entirely (all members stay visible).
// Signed thinking-family blocks are the exception (#4885 INV3): a run of
// consecutive assistant messages carrying one rebuilds into ONE provider
// message, so a partially folded signed run is withdrawn wholesale in
// either direction — leaving part of it behind orphans the signature.
// The same carve can also strip a call and its result onto opposite sides of
// the fold. Either half alone is invalid — a visible call whose result folded
// is an unanswered tool_call_id, a visible result whose call folded answers
// nothing — so those pairs are withdrawn together, both halves staying
// visible. Whole pairs may still fold while the turn's reasoning and text
// stay visible (#564).
//
// Both invariants are re-checked to a fixed point inside
// All three invariants are re-checked to a fixed point inside
// computeIntegrityWithdrawals: withdrawing a pair for the result half can
// strand a turn whose reasoning still folds and whose call now stays visible.
{
Expand Down
Loading
Loading