Skip to content

Commit 87fb412

Browse files
committed
fix(compaction): drop thinking blocks from the live tail
Uncapped thinking on the newest pair was pinning post-fold occupancy near 45%. The live copy drops thinking and redacted_thinking; the archive keeps the full bodies. Thinking-only turns become the existing omitted marker so adapters do not 400 on empty content.
1 parent 28131a3 commit 87fb412

6 files changed

Lines changed: 196 additions & 28 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,9 @@ The agent maintains an optional **`manage_tasks`** list (create/update via the h
201201

202202
#### Context compaction (the compaction governor)
203203

204-
When a cycle's input tokens cross a threshold, the director compacts the inference-facing history (the full run is always retained in the context store). The threshold is **model-aware** — roughly 60% of the active model's real context window — so small-window models compact early enough to avoid provider context-overflow while large-window models do not compact prematurely. The compacted prefix is **append-only across passes**: the existing compacted user turn stays byte-identical; new folds become later summary turns with a harness-inserted assistant spacer (identified by reserved `model: "harness"`, plus a visible sentinel; persisted `[compaction]` tokens without a producer still freeze) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. The governor covers five cases:
204+
When a cycle's input tokens cross a threshold, the director compacts the inference-facing history (the full run is always retained in the context store). The threshold is **model-aware** — roughly 60% of the active model's real context window — so small-window models compact early enough to avoid provider context-overflow while large-window models do not compact prematurely. The compacted prefix is **append-only across passes**: the existing compacted user turn stays byte-identical; new folds become later summary turns with a harness-inserted assistant spacer (identified by reserved `model: "harness"`, plus a visible sentinel; persisted `[compaction]` tokens without a producer still freeze) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. The live tail drops `thinking` and `redacted_thinking` blocks so they are not billed against occupancy; full thinking stays in the archived transcript. The governor covers five cases:
205205

206-
- **Threshold at a tool pause** — Once over threshold, the follow-up `infer` after a tool batch is swapped for a `compact` cycle, and inference resumes via a host continuation message. After every compact (threshold, operator, overflow), the governor snapshots post-compact usage (`tokensAtLastCompact`) and keeps that growth latch. It does not re-arm the proactive path on growth alone; re-arm requires a wide resume gap (~20% of the window, a full warning band) past that snapshot. Usage back at or under the 60% threshold restores the consecutive-compact cap and the overflow-recovery budget; it does not drop the growth latch. A fold that landed well under (snapshot + 20% already past the 60% trigger) still re-arms at the next 60% crossing because the gap predicate is already satisfied. A fold that only just got under (typical live ~45% after a thin-tail fold) does not sawtooth on the next 60% crossing; it waits until post-compact + 20%. A consecutive-compact cap holds across tool-call occupancy as a second rail.
206+
- **Threshold at a tool pause** — Once over threshold, the follow-up `infer` after a tool batch is swapped for a `compact` cycle, and inference resumes via a host continuation message. After every compact (threshold, operator, overflow), the governor snapshots post-compact usage (`tokensAtLastCompact`) and keeps that growth latch. It does not re-arm the proactive path on growth alone; re-arm requires a wide resume gap (~20% of the window, a full warning band) past that snapshot. Usage back at or under the 60% threshold restores the consecutive-compact cap and the overflow-recovery budget; it does not drop the growth latch. A fold that landed well under (snapshot + 20% already past the 60% trigger) still re-arms at the next 60% crossing because the gap predicate is already satisfied. A fold that only just got under (a marginal fold, not a deep one) does not sawtooth on the next 60% crossing; it waits until post-compact + 20%. A consecutive-compact cap holds across tool-call occupancy as a second rail.
207207
- **Idle (end-of-turn)** — An interactive turn can end with a reply and then sit idle with no tool batch to intercept; the governor requests a continuation at that pause and compacts when it arrives. An operator message that races the continuation still compacts first, then re-enters inference to answer it.
208208
- **Idle recompress past the provider TTL** — Cache expiry does not compact. Stored turns stay. When settings set `anthropicCachePrompt`, and the saved Anthropic-protocol cache write is at least 5 minutes old, the pre-inference context transform stubs old tool-result bodies on the outgoing prompt only. `turns.jsonl` is not rewritten. The setting defaults off.
209209

‎docs/TELEMETRY.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Each event carries a small set of properties:
2323
| `subagent_start` | A `spawn_agent` dispatch begins | `agent_name` |
2424
| `subagent_end` | A `spawn_agent` dispatch finishes | `agent_name`, `status`, `duration_ms`, `model`, `turn_count`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `tool_call_count`, `tool_error_count`, `stop_reason`, `parent_trace_id` |
2525
| `permission_prompt` | An approval prompt is answered (or abandoned) | `decision`, `permission_kind` |
26-
| `compaction` | The compactor actually folds turns away | `mode`, `duration_ms`, `turns_before`, `turns_after` |
26+
| `compaction` | The compactor actually folds turns away | `mode`, `duration_ms`, `turns_before`, `turns_after`, `live_tokens` |
2727
| `summarizer_failure` | The compaction summary call fails after its retry budget is spent | `provider`, `model`, `error_kind`, `duration_ms` |
2828
| `crash` | A fatal error reaches the process-level handler | `kind`, `error_class` |
2929
| `auth_failure` | A provider rejects the stored credentials | `auth_provider` |
@@ -34,7 +34,8 @@ Each event carries a small set of properties:
3434

3535
`compaction` is deliberately silent on the runs where the compactor decides
3636
there is nothing to compact — an event that also fires on no-ops makes its own
37-
duration and turn-count averages meaningless.
37+
duration and turn-count averages meaningless. `live_tokens` is the post-fold
38+
live-turn token estimate.
3839

3940
`summarizer_failure` fires once per failed summary call, not per attempt.
4041
`error_kind` is a first-party enum (`auth`, `provider`, `timeout`, `aborted`,

‎src/context-compactor.test.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { HANDOFF_LATEST_KEY } from "./session/compaction-handoff.js";
1515
import { compactionThresholdFor } from "./provider/context-window.js";
1616
import { estimateContextTokens } from "./agent/context-estimate.js";
17+
import { THINKING_ONLY_OMITTED } from "./provider/replay-sanitizer.js";
1718
import { createModelSummarizer } from "./session/summarizer.js";
1819
import {
1920
createCompactionGovernor,
@@ -1185,4 +1186,135 @@ describe("CL-9489 zero-verbatim fold", () => {
11851186
compactionThresholdFor(undefined),
11861187
);
11871188
});
1189+
1190+
test("a newest-pair thinking block is dropped from the live tail", async () => {
1191+
const thinking = "t".repeat(160_000);
1192+
const filler = "x".repeat(1_500);
1193+
const turns: ConversationTurn[] = [];
1194+
for (let i = 0; i < 6; i++) {
1195+
turns.push(
1196+
makeTurn({
1197+
role: "user",
1198+
content: [{ type: "text", text: `dummy ask ${i} ${filler}` }],
1199+
}),
1200+
);
1201+
turns.push(
1202+
makeTurn({
1203+
role: "assistant",
1204+
content: [{ type: "text", text: `dummy reply ${i} ${filler}` }],
1205+
}),
1206+
);
1207+
}
1208+
turns.push(
1209+
makeTurn({
1210+
role: "assistant",
1211+
content: [
1212+
{
1213+
type: "thinking",
1214+
thinking,
1215+
signature: "sig-thinking-1",
1216+
},
1217+
{
1218+
type: "tool_call",
1219+
id: "c-newest",
1220+
name: "read_file",
1221+
arguments: { path: "src/app.ts" },
1222+
},
1223+
],
1224+
}),
1225+
);
1226+
turns.push(
1227+
makeTurn({
1228+
role: "user",
1229+
content: [
1230+
{
1231+
type: "tool_result",
1232+
callId: "c-newest",
1233+
content: [{ type: "text", text: "export const app = 1;\n" }],
1234+
},
1235+
],
1236+
}),
1237+
);
1238+
1239+
const result = await createPruningCompactor({
1240+
summarize: async () =>
1241+
"Folded dummy pairs. Next: resume the newest tool.",
1242+
}).apply(turns, mockStrategyCtx);
1243+
1244+
const liveTypes = result.output.flatMap((t) =>
1245+
t.content.map((b) => b.type),
1246+
);
1247+
expect(liveTypes).not.toContain("thinking");
1248+
expect(liveTypes).not.toContain("redacted_thinking");
1249+
expect(estimateContextTokens(result.output)).toBeLessThan(12_800);
1250+
expect(
1251+
result.output.some((t) =>
1252+
t.content.some((b) => b.type === "tool_call" && b.id === "c-newest"),
1253+
),
1254+
).toBe(true);
1255+
expect(
1256+
result.output.some((t) =>
1257+
t.content.some(
1258+
(b) => b.type === "tool_result" && b.callId === "c-newest",
1259+
),
1260+
),
1261+
).toBe(true);
1262+
expect(result.record.decisions.liveTokenEstimate).toBe(
1263+
estimateContextTokens(result.output),
1264+
);
1265+
});
1266+
1267+
test("a thinking-only live turn is replaced with THINKING_ONLY_OMITTED", async () => {
1268+
const thinking = "t".repeat(20_000);
1269+
const filler = "x".repeat(1_500);
1270+
const turns: ConversationTurn[] = [];
1271+
for (let i = 0; i < 6; i++) {
1272+
turns.push(
1273+
makeTurn({
1274+
role: "user",
1275+
content: [{ type: "text", text: `dummy ask ${i} ${filler}` }],
1276+
}),
1277+
);
1278+
turns.push(
1279+
makeTurn({
1280+
role: "assistant",
1281+
content: [{ type: "text", text: `dummy reply ${i} ${filler}` }],
1282+
}),
1283+
);
1284+
}
1285+
turns.push(
1286+
makeTurn({
1287+
role: "assistant",
1288+
content: [
1289+
{
1290+
type: "thinking",
1291+
thinking,
1292+
signature: "sig-thinking-only",
1293+
},
1294+
],
1295+
}),
1296+
);
1297+
1298+
const result = await createPruningCompactor({
1299+
summarize: async () =>
1300+
"Folded dummy pairs. Next: resume after thinking-only.",
1301+
}).apply(turns, mockStrategyCtx);
1302+
1303+
const liveTypes = result.output.flatMap((t) =>
1304+
t.content.map((b) => b.type),
1305+
);
1306+
expect(liveTypes).not.toContain("thinking");
1307+
expect(liveTypes).not.toContain("redacted_thinking");
1308+
expect(result.output.every((t) => t.content.length > 0)).toBe(true);
1309+
expect(
1310+
result.output.some(
1311+
(t) =>
1312+
t.role === "assistant" &&
1313+
t.content.some(
1314+
(b) => b.type === "text" && b.text === THINKING_ONLY_OMITTED,
1315+
),
1316+
),
1317+
).toBe(true);
1318+
expect(estimateContextTokens(result.output)).toBeLessThan(12_800);
1319+
});
11881320
});

‎src/session/compactor.ts‎

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ import {
3030
PATH_KEYED_READ_TOOLS,
3131
SEARCH_QUERY_TOOLS,
3232
} from "../agent/tool-classification.js";
33-
import { estimateContentBlockTokens } from "../agent/context-estimate.js";
33+
import {
34+
estimateContentBlockTokens,
35+
estimateContextTokens,
36+
} from "../agent/context-estimate.js";
37+
import { THINKING_ONLY_OMITTED } from "../provider/replay-sanitizer.js";
3438

3539
// ---------------------------------------------------------------------------
3640
// Compactor
@@ -710,31 +714,49 @@ function excerptTailText(
710714
};
711715
}
712716

713-
// Excerpted live copy of a tail turn: large tool_result text parts shrink to
714-
// head+tail excerpts, everything else (user text, attachments, tool calls,
715-
// error results stay whole — errors are resume state, not bulk) passes
716-
// through untouched.
717+
// Excerpted live copy of a tail turn: thinking/redacted_thinking are dropped
718+
// from the live copy (full bodies stay in the archive) because uncapped
719+
// thinking on the newest pair was pinning occupancy near 45%. Drop whole
720+
// blocks so signatures are not sent with truncated text. Large tool_result
721+
// text parts shrink to head+tail excerpts; everything else (user text,
722+
// attachments, tool calls, error results stay whole — errors are resume
723+
// state, not bulk) passes through untouched.
717724
function excerptTailTurn(
718725
turn: ConversationTurn,
719726
shape: CompactionShape,
720-
): { turn: ConversationTurn; shortenedOutputs: number } {
727+
): { turn: ConversationTurn; shortenedOutputs: number; changed: boolean } {
721728
let shortenedOutputs = 0;
722729
let changed = false;
723-
const content = turn.content.map(
724-
(block): ConversationTurn["content"][number] => {
725-
if (block.type !== "tool_result" || block.isError === true) return block;
726-
const parts = block.content.map((c) => {
727-
if (c.type !== "text") return c;
728-
const excerpted = excerptTailText(c.text, shape);
729-
if (!excerpted.shortened) return c;
730-
shortenedOutputs += 1;
731-
changed = true;
732-
return { ...c, text: excerpted.text };
733-
});
734-
return changed ? { ...block, content: parts } : block;
735-
},
736-
);
737-
return { turn: changed ? { ...turn, content } : turn, shortenedOutputs };
730+
const content = turn.content.flatMap((block): ConversationTurn["content"] => {
731+
if (block.type === "thinking" || block.type === "redacted_thinking") {
732+
changed = true;
733+
return [];
734+
}
735+
if (block.type !== "tool_result" || block.isError === true) return [block];
736+
let blockChanged = false;
737+
const parts = block.content.map((c) => {
738+
if (c.type !== "text") return c;
739+
const excerpted = excerptTailText(c.text, shape);
740+
if (!excerpted.shortened) return c;
741+
shortenedOutputs += 1;
742+
blockChanged = true;
743+
changed = true;
744+
return { ...c, text: excerpted.text };
745+
});
746+
return [blockChanged ? { ...block, content: parts } : block];
747+
});
748+
// Dropping thinking/redacted_thinking can leave a thinking-only assistant
749+
// with empty content. Adapters 400 on that; keep the turn (role
750+
// alternation) and reuse the replay-sanitizer marker.
751+
const liveContent =
752+
content.length === 0
753+
? [{ type: "text" as const, text: THINKING_ONLY_OMITTED }]
754+
: content;
755+
return {
756+
turn: changed ? { ...turn, content: liveContent } : turn,
757+
shortenedOutputs,
758+
changed,
759+
};
738760
}
739761

740762
interface TailSelection {
@@ -804,8 +826,12 @@ function selectTail(
804826
const turnCost = (idx: number): { tokens: number; shortened: number } => {
805827
const turn = turns[idx];
806828
if (turn === undefined) return { tokens: 0, shortened: 0 };
807-
const { turn: live, shortenedOutputs } = excerptTailTurn(turn, shape);
808-
if (shortenedOutputs > 0) excerpted.set(idx, live);
829+
const {
830+
turn: live,
831+
shortenedOutputs,
832+
changed,
833+
} = excerptTailTurn(turn, shape);
834+
if (changed) excerpted.set(idx, live);
809835
return {
810836
tokens: excerptedTurnTokens(turn, shape),
811837
shortened: shortenedOutputs,
@@ -1002,6 +1028,7 @@ export function createPruningCompactor(
10021028
shortenedToolOutputs: tail.shortenedToolOutputs,
10031029
agedImageCount: aged.agedImageCount,
10041030
supersededReadCount: supersededReads.size,
1031+
liveTokenEstimate: estimateContextTokens(output),
10051032
},
10061033
},
10071034
...(aged.blobs.length > 0 ? { blobs: aged.blobs } : {}),
@@ -1194,6 +1221,7 @@ export function createPruningCompactor(
11941221
agedImageCount: aged.agedImageCount,
11951222
supersededReadCount: supersededReads.size,
11961223
repeatedErrorCount: repeatedErrors.size,
1224+
liveTokenEstimate: estimateContextTokens(liveOutput),
11971225
...(summarizeFallback !== undefined
11981226
? {
11991227
summarizeFailed: 1,

‎src/session/runtime-assembly.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,7 @@ export function createSessionPruningCompactor(
579579
duration_ms: Date.now() - startedAt,
580580
turns_before: turnsBefore,
581581
turns_after: result.output.length,
582+
live_tokens: result.record.decisions.liveTokenEstimate,
582583
});
583584
return result;
584585
},

‎src/telemetry/index.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,13 @@ const EVENT_PROPERTY_ALLOWLIST: Record<TelemetryEvent, readonly string[]> = {
194194
],
195195

196196
permission_prompt: ["decision", "permission_kind"],
197-
compaction: ["mode", "duration_ms", "turns_before", "turns_after"],
197+
compaction: [
198+
"mode",
199+
"duration_ms",
200+
"turns_before",
201+
"turns_after",
202+
"live_tokens",
203+
],
198204
// provider/model are the canonical runtime ids (same trust class as
199205
// $ai_provider/$ai_model); error_kind is the summarizer's first-party
200206
// failure enum, never the provider's error text.

0 commit comments

Comments
 (0)