Skip to content

Commit 6ab0446

Browse files
committed
fix(compaction): keep thinking on the last live tool-use assistant
1 parent 081c161 commit 6ab0446

3 files changed

Lines changed: 223 additions & 19 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ 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 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:
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 except on the last live assistant that still has a `tool_call`; full thinking stays in the archived transcript. The governor covers five cases:
205205

206206
- **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.

‎src/context-compactor.test.ts‎

Lines changed: 193 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,7 +1187,7 @@ describe("CL-9489 zero-verbatim fold", () => {
11871187
);
11881188
});
11891189

1190-
test("a newest-pair thinking block is dropped from the live tail", async () => {
1190+
test("the last live tool-use assistant keeps thinking unmodified", async () => {
11911191
const thinking = "t".repeat(160_000);
11921192
const filler = "x".repeat(1_500);
11931193
const turns: ConversationTurn[] = [];
@@ -1241,17 +1241,25 @@ describe("CL-9489 zero-verbatim fold", () => {
12411241
"Folded dummy pairs. Next: resume the newest tool.",
12421242
}).apply(turns, mockStrategyCtx);
12431243

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) =>
1244+
const lastToolUse = result.output.find(
1245+
(t) =>
1246+
t.role === "assistant" &&
12521247
t.content.some((b) => b.type === "tool_call" && b.id === "c-newest"),
1253-
),
1254-
).toBe(true);
1248+
);
1249+
expect(lastToolUse).toBeDefined();
1250+
expect(lastToolUse?.content).toEqual([
1251+
{
1252+
type: "thinking",
1253+
thinking,
1254+
signature: "sig-thinking-1",
1255+
},
1256+
{
1257+
type: "tool_call",
1258+
id: "c-newest",
1259+
name: "read_file",
1260+
arguments: { path: "src/app.ts" },
1261+
},
1262+
]);
12551263
expect(
12561264
result.output.some((t) =>
12571265
t.content.some(
@@ -1264,6 +1272,180 @@ describe("CL-9489 zero-verbatim fold", () => {
12641272
);
12651273
});
12661274

1275+
test("older live-tail tool-use thinking is dropped when a newer last assistant has a tool call", async () => {
1276+
const olderThinking = "older-thinking-block";
1277+
const newerThinking = "newer-thinking-block";
1278+
const filler = "x".repeat(1_500);
1279+
const turns: ConversationTurn[] = [];
1280+
for (let i = 0; i < 6; i++) {
1281+
turns.push(
1282+
makeTurn({
1283+
role: "user",
1284+
content: [{ type: "text", text: `dummy ask ${i} ${filler}` }],
1285+
}),
1286+
);
1287+
turns.push(
1288+
makeTurn({
1289+
role: "assistant",
1290+
content: [{ type: "text", text: `dummy reply ${i} ${filler}` }],
1291+
}),
1292+
);
1293+
}
1294+
turns.push(
1295+
makeTurn({
1296+
role: "assistant",
1297+
content: [
1298+
{
1299+
type: "thinking",
1300+
thinking: olderThinking,
1301+
signature: "sig-older",
1302+
},
1303+
{
1304+
type: "tool_call",
1305+
id: "c-older",
1306+
name: "read_file",
1307+
arguments: { path: "src/old.ts" },
1308+
},
1309+
],
1310+
}),
1311+
);
1312+
turns.push(
1313+
makeTurn({
1314+
role: "user",
1315+
content: [
1316+
{
1317+
type: "tool_result",
1318+
callId: "c-older",
1319+
content: [{ type: "text", text: "export const old = 1;\n" }],
1320+
},
1321+
],
1322+
}),
1323+
);
1324+
turns.push(
1325+
makeTurn({
1326+
role: "assistant",
1327+
content: [
1328+
{
1329+
type: "thinking",
1330+
thinking: newerThinking,
1331+
signature: "sig-newer",
1332+
},
1333+
{
1334+
type: "tool_call",
1335+
id: "c-newer",
1336+
name: "read_file",
1337+
arguments: { path: "src/new.ts" },
1338+
},
1339+
],
1340+
}),
1341+
);
1342+
turns.push(
1343+
makeTurn({
1344+
role: "user",
1345+
content: [
1346+
{
1347+
type: "tool_result",
1348+
callId: "c-newer",
1349+
content: [{ type: "text", text: "export const neu = 1;\n" }],
1350+
},
1351+
],
1352+
}),
1353+
);
1354+
1355+
const result = await createPruningCompactor({
1356+
summarize: async () =>
1357+
"Folded dummy pairs. Next: resume the newest tool.",
1358+
}).apply(turns, mockStrategyCtx);
1359+
1360+
const older = result.output.find(
1361+
(t) =>
1362+
t.role === "assistant" &&
1363+
t.content.some((b) => b.type === "tool_call" && b.id === "c-older"),
1364+
);
1365+
const newer = result.output.find(
1366+
(t) =>
1367+
t.role === "assistant" &&
1368+
t.content.some((b) => b.type === "tool_call" && b.id === "c-newer"),
1369+
);
1370+
expect(older).toBeDefined();
1371+
expect(newer).toBeDefined();
1372+
expect(older?.content.some((b) => b.type === "thinking")).toBe(false);
1373+
expect(older?.content).toEqual([
1374+
{
1375+
type: "tool_call",
1376+
id: "c-older",
1377+
name: "read_file",
1378+
arguments: { path: "src/old.ts" },
1379+
},
1380+
]);
1381+
expect(newer?.content).toEqual([
1382+
{
1383+
type: "thinking",
1384+
thinking: newerThinking,
1385+
signature: "sig-newer",
1386+
},
1387+
{
1388+
type: "tool_call",
1389+
id: "c-newer",
1390+
name: "read_file",
1391+
arguments: { path: "src/new.ts" },
1392+
},
1393+
]);
1394+
});
1395+
1396+
test("a text-only last assistant drops all live-tail thinking", async () => {
1397+
const thinking = "t".repeat(20_000);
1398+
const filler = "x".repeat(1_500);
1399+
const turns: ConversationTurn[] = [];
1400+
for (let i = 0; i < 6; i++) {
1401+
turns.push(
1402+
makeTurn({
1403+
role: "user",
1404+
content: [{ type: "text", text: `dummy ask ${i} ${filler}` }],
1405+
}),
1406+
);
1407+
turns.push(
1408+
makeTurn({
1409+
role: "assistant",
1410+
content: [{ type: "text", text: `dummy reply ${i} ${filler}` }],
1411+
}),
1412+
);
1413+
}
1414+
turns.push(
1415+
makeTurn({
1416+
role: "assistant",
1417+
content: [
1418+
{
1419+
type: "thinking",
1420+
thinking,
1421+
signature: "sig-text-only",
1422+
},
1423+
{ type: "text", text: "here is the answer" },
1424+
],
1425+
}),
1426+
);
1427+
1428+
const result = await createPruningCompactor({
1429+
summarize: async () =>
1430+
"Folded dummy pairs. Next: resume after a text reply.",
1431+
}).apply(turns, mockStrategyCtx);
1432+
1433+
const liveTypes = result.output.flatMap((t) =>
1434+
t.content.map((b) => b.type),
1435+
);
1436+
expect(liveTypes).not.toContain("thinking");
1437+
expect(liveTypes).not.toContain("redacted_thinking");
1438+
expect(
1439+
result.output.some(
1440+
(t) =>
1441+
t.role === "assistant" &&
1442+
t.content.some(
1443+
(b) => b.type === "text" && b.text === "here is the answer",
1444+
),
1445+
),
1446+
).toBe(true);
1447+
});
1448+
12671449
test("a thinking-only live turn is replaced with THINKING_ONLY_OMITTED", async () => {
12681450
const thinking = "t".repeat(20_000);
12691451
const filler = "x".repeat(1_500);

‎src/session/compactor.ts‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -715,20 +715,23 @@ function excerptTailText(
715715
}
716716

717717
// 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
718+
// from the live copy except on the last assistant that still has a tool_call
719+
// (Anthropic continuation requires unmodified thinking+signature on that
720+
// tool_use). Full bodies stay in the archive. Drop whole blocks so
721+
// signatures are not sent with truncated text. Large tool_result
721722
// text parts shrink to head+tail excerpts; everything else (user text,
722723
// attachments, tool calls, error results stay whole — errors are resume
723724
// state, not bulk) passes through untouched.
724725
function excerptTailTurn(
725726
turn: ConversationTurn,
726727
shape: CompactionShape,
728+
keepThinking = false,
727729
): { turn: ConversationTurn; shortenedOutputs: number; changed: boolean } {
728730
let shortenedOutputs = 0;
729731
let changed = false;
730732
const content = turn.content.flatMap((block): ConversationTurn["content"] => {
731733
if (block.type === "thinking" || block.type === "redacted_thinking") {
734+
if (keepThinking) return [block];
732735
changed = true;
733736
return [];
734737
}
@@ -769,11 +772,28 @@ interface TailSelection {
769772
tailTokenEstimate: number;
770773
}
771774

775+
function lastAssistantToolCallIndex(
776+
turns: readonly ConversationTurn[],
777+
): number | undefined {
778+
for (let i = turns.length - 1; i >= 0; i--) {
779+
const turn = turns[i];
780+
if (
781+
turn !== undefined &&
782+
turn.role === "assistant" &&
783+
turn.content.some((block) => block.type === "tool_call")
784+
) {
785+
return i;
786+
}
787+
}
788+
return undefined;
789+
}
790+
772791
function excerptedTurnTokens(
773792
turn: ConversationTurn,
774793
shape: CompactionShape,
794+
keepThinking = false,
775795
): number {
776-
const { turn: live } = excerptTailTurn(turn, shape);
796+
const { turn: live } = excerptTailTurn(turn, shape, keepThinking);
777797
let tokens = 0;
778798
for (const block of live.content) {
779799
tokens += estimateContentBlockTokens(block);
@@ -822,18 +842,20 @@ function selectTail(
822842
let shortenedToolOutputs = 0;
823843
let usedTokens = 0;
824844
const budgetTokens = shape.tailBudgetTokens;
845+
const keepThinkingAt = lastAssistantToolCallIndex(turns);
825846

826847
const turnCost = (idx: number): { tokens: number; shortened: number } => {
827848
const turn = turns[idx];
828849
if (turn === undefined) return { tokens: 0, shortened: 0 };
850+
const keepThinking = idx === keepThinkingAt;
829851
const {
830852
turn: live,
831853
shortenedOutputs,
832854
changed,
833-
} = excerptTailTurn(turn, shape);
855+
} = excerptTailTurn(turn, shape, keepThinking);
834856
if (changed) excerpted.set(idx, live);
835857
return {
836-
tokens: excerptedTurnTokens(turn, shape),
858+
tokens: excerptedTurnTokens(turn, shape, keepThinking),
837859
shortened: shortenedOutputs,
838860
};
839861
};
@@ -889,7 +911,7 @@ function selectTail(
889911
for (const idx of closure) {
890912
const t = turns[idx];
891913
if (t === undefined || isFoldableHandoffTurn(t)) continue;
892-
closureTokens += excerptedTurnTokens(t, shape);
914+
closureTokens += excerptedTurnTokens(t, shape, idx === keepThinkingAt);
893915
}
894916
if (picked.size > 0 && usedTokens + closureTokens > budgetTokens) break;
895917
for (const idx of closure) pick(idx);

0 commit comments

Comments
 (0)