Skip to content

Commit 450f19d

Browse files
Merge pull request #1354 from corbitsdev/cl-9904-restore-anthropic-thinking-on-the-live-tool-use-assistant
fix(compaction): keep thinking on the last live tool-use assistant
2 parents 081c161 + 3f81e4f commit 450f19d

3 files changed

Lines changed: 311 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: 284 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,271 @@ 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+
1449+
test("earlier live tool-use thinking is dropped when the last assistant is text-only", async () => {
1450+
const toolThinking = "tool-use-thinking-block";
1451+
const lastThinking = "text-only-thinking-block";
1452+
const filler = "x".repeat(1_500);
1453+
const turns: ConversationTurn[] = [];
1454+
for (let i = 0; i < 6; i++) {
1455+
turns.push(
1456+
makeTurn({
1457+
role: "user",
1458+
content: [{ type: "text", text: `dummy ask ${i} ${filler}` }],
1459+
}),
1460+
);
1461+
turns.push(
1462+
makeTurn({
1463+
role: "assistant",
1464+
content: [{ type: "text", text: `dummy reply ${i} ${filler}` }],
1465+
}),
1466+
);
1467+
}
1468+
turns.push(
1469+
makeTurn({
1470+
role: "assistant",
1471+
content: [
1472+
{
1473+
type: "thinking",
1474+
thinking: toolThinking,
1475+
signature: "sig-tool-use",
1476+
},
1477+
{
1478+
type: "tool_call",
1479+
id: "c-earlier",
1480+
name: "read_file",
1481+
arguments: { path: "src/app.ts" },
1482+
},
1483+
],
1484+
}),
1485+
);
1486+
turns.push(
1487+
makeTurn({
1488+
role: "user",
1489+
content: [
1490+
{
1491+
type: "tool_result",
1492+
callId: "c-earlier",
1493+
content: [{ type: "text", text: "export const app = 1;\n" }],
1494+
},
1495+
],
1496+
}),
1497+
);
1498+
turns.push(
1499+
makeTurn({
1500+
role: "assistant",
1501+
content: [
1502+
{
1503+
type: "thinking",
1504+
thinking: lastThinking,
1505+
signature: "sig-text-only",
1506+
},
1507+
{ type: "text", text: "here is the answer" },
1508+
],
1509+
}),
1510+
);
1511+
1512+
const result = await createPruningCompactor({
1513+
summarize: async () =>
1514+
"Folded dummy pairs. Next: resume after a text reply.",
1515+
}).apply(turns, mockStrategyCtx);
1516+
1517+
const liveTypes = result.output.flatMap((t) =>
1518+
t.content.map((b) => b.type),
1519+
);
1520+
expect(liveTypes).not.toContain("thinking");
1521+
expect(liveTypes).not.toContain("redacted_thinking");
1522+
expect(
1523+
result.output.some(
1524+
(t) =>
1525+
t.role === "assistant" &&
1526+
t.content.some((b) => b.type === "tool_call" && b.id === "c-earlier"),
1527+
),
1528+
).toBe(true);
1529+
expect(
1530+
result.output.some(
1531+
(t) =>
1532+
t.role === "assistant" &&
1533+
t.content.some(
1534+
(b) => b.type === "text" && b.text === "here is the answer",
1535+
),
1536+
),
1537+
).toBe(true);
1538+
});
1539+
12671540
test("a thinking-only live turn is replaced with THINKING_ONLY_OMITTED", async () => {
12681541
const thinking = "t".repeat(20_000);
12691542
const filler = "x".repeat(1_500);

0 commit comments

Comments
 (0)