Skip to content

Commit e92a55e

Browse files
Merge pull request #544 from corbitsdev/cl-6906-compaction-preserves-the-loop-and-drops-the-substance
Compaction preserves the loop and drops the substance
2 parents bf01b2a + b9b65ee commit e92a55e

9 files changed

Lines changed: 724 additions & 68 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

16+
### Agent
17+
18+
- **Compaction keeps scored work, not retry loops.** Errored tool results are no
19+
longer auto-pinned; identical errors collapse to one representative. Anchors
20+
are scored (writes, successful task completions, plan updates) and pair
21+
closures count against `maxAnchorTurns`. The LLM summary is workflow-aware
22+
and skips degenerate assistant text.
23+
1624
### Plugins
1725

1826
- **`run_shell` no longer defaults to a 15s timeout.** Omitted timeout arms no

src/context-compactor.test.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,248 @@ describe("createPruningCompactor — image aging", () => {
335335
});
336336
});
337337

338+
describe("createPruningCompactor — error anchoring (CL-6906)", () => {
339+
function assistantErrorCall(id: string, name: string): ConversationTurn {
340+
return makeTurn({
341+
role: "assistant",
342+
content: [{ type: "tool_call", id, name, arguments: {} }],
343+
});
344+
}
345+
function errorResult(callId: string, text: string): ConversationTurn {
346+
return makeTurn({
347+
role: "user",
348+
content: [{ type: "tool_result", callId, content: [{ type: "text", text }], isError: true }],
349+
});
350+
}
351+
function padding(n: number, prefix: string): ConversationTurn[] {
352+
return Array.from({ length: n }, (_, i) =>
353+
makeTurn({
354+
role: i % 2 === 0 ? "assistant" : "user",
355+
content: [{ type: "text", text: `${prefix}${i}` }],
356+
}),
357+
);
358+
}
359+
360+
test("a lone errored tool_result no longer anchors on its own", async () => {
361+
const turns: ConversationTurn[] = [
362+
makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }),
363+
...padding(3, "before"),
364+
assistantErrorCall("e1", "run_shell"),
365+
errorResult("e1", "Error: exit code 1 " + "x".repeat(100)),
366+
...padding(8, "after"),
367+
];
368+
const compactor = createPruningCompactor({
369+
keepRecentTurns: 6,
370+
maxAnchorTurns: 8,
371+
summaryMaxChars: 2000,
372+
});
373+
const { output } = await compactor.apply(turns, mockStrategyCtx);
374+
// The lone error's own turn score (3) sits below the anchor threshold (5),
375+
// so its body must not survive verbatim outside the recent window.
376+
const survivedVerbatim = output.some((t) =>
377+
t.content.some((b) => b.type === "tool_result" && b.callId === "e1"),
378+
);
379+
expect(survivedVerbatim).toBe(false);
380+
});
381+
382+
test("two distinct errors on one turn still clear the anchor threshold", async () => {
383+
const turns: ConversationTurn[] = [
384+
makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }),
385+
...padding(3, "before"),
386+
makeTurn({
387+
role: "assistant",
388+
content: [
389+
{ type: "tool_call", id: "d1", name: "run_shell", arguments: {} },
390+
{ type: "tool_call", id: "d2", name: "grep", arguments: {} },
391+
],
392+
}),
393+
makeTurn({
394+
role: "user",
395+
content: [
396+
{
397+
type: "tool_result",
398+
callId: "d1",
399+
content: [{ type: "text", text: "Error: build failed" }],
400+
isError: true,
401+
},
402+
{
403+
type: "tool_result",
404+
callId: "d2",
405+
content: [{ type: "text", text: "Error: no matches found" }],
406+
isError: true,
407+
},
408+
],
409+
}),
410+
...padding(8, "after"),
411+
];
412+
const compactor = createPruningCompactor({
413+
keepRecentTurns: 6,
414+
maxAnchorTurns: 8,
415+
summaryMaxChars: 2000,
416+
});
417+
const { output } = await compactor.apply(turns, mockStrategyCtx);
418+
const kept = output.find((t) =>
419+
t.content.some((b) => b.type === "tool_result" && b.callId === "d1"),
420+
);
421+
expect(kept).toBeDefined();
422+
expect(kept?.content.some((b) => b.type === "tool_result" && b.callId === "d2")).toBe(true);
423+
});
424+
425+
test("repeated identical errors collapse to one representative before anchor selection", async () => {
426+
// "old" repeats the same (tool, error-text) signature that recurs again
427+
// later ("recur"); combined with a distinct error on the same turn, the
428+
// uncollapsed score (3 + 3 = 6) would clear the threshold, but the
429+
// collapsed score (0 + 3 = 3) must not.
430+
const sharedErrorText = "Error: type mismatch on line 12, expected string";
431+
const turns: ConversationTurn[] = [
432+
makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }),
433+
...padding(3, "before"),
434+
makeTurn({
435+
role: "assistant",
436+
content: [
437+
{ type: "tool_call", id: "old", name: "edit_file_check", arguments: {} },
438+
{ type: "tool_call", id: "uniq", name: "grep", arguments: {} },
439+
],
440+
}),
441+
makeTurn({
442+
role: "user",
443+
content: [
444+
{
445+
type: "tool_result",
446+
callId: "old",
447+
content: [{ type: "text", text: sharedErrorText }],
448+
isError: true,
449+
},
450+
{
451+
type: "tool_result",
452+
callId: "uniq",
453+
content: [{ type: "text", text: "Error: distinct failure here" }],
454+
isError: true,
455+
},
456+
],
457+
}),
458+
...padding(4, "mid"),
459+
assistantErrorCall("recur", "edit_file_check"),
460+
errorResult("recur", sharedErrorText),
461+
...padding(8, "after"),
462+
];
463+
const compactor = createPruningCompactor({
464+
keepRecentTurns: 6,
465+
maxAnchorTurns: 8,
466+
summaryMaxChars: 2000,
467+
});
468+
const { output, record } = await compactor.apply(turns, mockStrategyCtx);
469+
expect(record.decisions["repeatedErrorCount"]).toBe(1);
470+
// The combined turn's score drops below threshold once "old" is
471+
// collapsed, so neither of its results survives verbatim.
472+
const oldSurvived = output.some((t) =>
473+
t.content.some((b) => b.type === "tool_result" && b.callId === "old"),
474+
);
475+
const uniqSurvived = output.some((t) =>
476+
t.content.some((b) => b.type === "tool_result" && b.callId === "uniq"),
477+
);
478+
expect(oldSurvived).toBe(false);
479+
expect(uniqSurvived).toBe(false);
480+
});
481+
});
482+
483+
describe("createPruningCompactor — maxAnchorTurns caps pairing pulls (CL-6906)", () => {
484+
test("bounds the total scored-anchor pull even when many high-score pairs are scattered through history", async () => {
485+
const turns: ConversationTurn[] = [
486+
makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }),
487+
];
488+
// 10 write-pair call/result turns, well separated from each other and from
489+
// the recent window. A single edit_file scores 3 (below the threshold of
490+
// 5); two writes on the same assistant turn score 6, so each pair
491+
// independently clears the scored-anchor bar.
492+
for (let i = 0; i < 10; i++) {
493+
turns.push(
494+
makeTurn({
495+
role: "assistant",
496+
content: [
497+
{
498+
type: "tool_call",
499+
id: `edit${i}a`,
500+
name: "edit_file",
501+
arguments: { path: `f${i}a.ts` },
502+
},
503+
{
504+
type: "tool_call",
505+
id: `edit${i}b`,
506+
name: "edit_file",
507+
arguments: { path: `f${i}b.ts` },
508+
},
509+
],
510+
}),
511+
makeTurn({
512+
role: "user",
513+
content: [
514+
{
515+
type: "tool_result",
516+
callId: `edit${i}a`,
517+
content: [{ type: "text", text: `edited f${i}a.ts` }],
518+
},
519+
{
520+
type: "tool_result",
521+
callId: `edit${i}b`,
522+
content: [{ type: "text", text: `edited f${i}b.ts` }],
523+
},
524+
],
525+
}),
526+
makeTurn({ role: "assistant", content: [{ type: "text", text: `note ${i}` }] }),
527+
makeTurn({ role: "user", content: [{ type: "text", text: `ask ${i}` }] }),
528+
);
529+
}
530+
for (let i = 0; i < 6; i++) {
531+
turns.push(
532+
makeTurn({
533+
role: i % 2 === 0 ? "assistant" : "user",
534+
content: [{ type: "text", text: `recent${i}` }],
535+
}),
536+
);
537+
}
538+
539+
const maxAnchorTurns = 4;
540+
const compactor = createPruningCompactor({
541+
keepRecentTurns: 6,
542+
maxAnchorTurns,
543+
summaryMaxChars: 2000,
544+
});
545+
const { record } = await compactor.apply(turns, mockStrategyCtx);
546+
// The initiating task (1 turn, no partners) is kept outside the cap; the
547+
// scored/pair-partner pull must stay within maxAnchorTurns.
548+
const anchorTurnCount = record.decisions["anchorTurnCount"] as number;
549+
expect(anchorTurnCount - 1).toBeLessThanOrEqual(maxAnchorTurns);
550+
// With a budget of 4 and each edit pair costing 2 (call + result), exactly
551+
// two pairs (the most recent two) fit; a third would overshoot and must
552+
// be rejected as a whole, not split.
553+
expect(anchorTurnCount).toBe(1 + 4);
554+
});
555+
});
556+
557+
describe("createPruningCompactor — summarize receives the workflow context (CL-6906)", () => {
558+
test("passes cfg.summaryContext() through to summarize as the second argument", async () => {
559+
let capturedCtx: unknown = "not called";
560+
const workflowCtx = { workflow: { name: "build", stepIndex: 2, total: 7 } };
561+
const compactor = createPruningCompactor({
562+
keepRecentTurns: 1,
563+
summaryMaxChars: 500,
564+
summaryContext: () => workflowCtx,
565+
summarize: async (_turns, ctx) => {
566+
capturedCtx = ctx;
567+
return "summary text";
568+
},
569+
});
570+
const turns: ConversationTurn[] = [
571+
makeTurn({ role: "assistant", content: [{ type: "text", text: "a" }] }),
572+
makeTurn({ role: "assistant", content: [{ type: "text", text: "b" }] }),
573+
makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }),
574+
];
575+
await compactor.apply(turns, mockStrategyCtx);
576+
expect(capturedCtx).toBe(workflowCtx);
577+
});
578+
});
579+
338580
describe("buildContextEnvelope", () => {
339581
test("includes active task label", () => {
340582
const result = buildContextEnvelope({

0 commit comments

Comments
 (0)