Skip to content

Commit 5e71be1

Browse files
Merge pull request #1407 from corbitsdev/cl-10535-excerpt-once
refactor(session): stop excerpting the same tail turn twice in turnCost
2 parents dadc8be + 65b9ffc commit 5e71be1

2 files changed

Lines changed: 118 additions & 14 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
createPruningCompactor,
4+
excerptTailTurnCallCount,
5+
} from "./compactor.js";
6+
import type {
7+
ConversationTurn,
8+
ReactorState,
9+
StrategyContext,
10+
} from "@intx/types/runtime";
11+
12+
const mockStrategyCtx: StrategyContext = {
13+
state: {} as ReactorState,
14+
trigger: "test",
15+
};
16+
17+
function textTurn(
18+
role: ConversationTurn["role"],
19+
text: string,
20+
): ConversationTurn {
21+
return {
22+
role,
23+
content: [{ type: "text", text }],
24+
timestamp: Date.now(),
25+
};
26+
}
27+
28+
function pairTurns(id: string, resultText: string): ConversationTurn[] {
29+
return [
30+
{
31+
role: "assistant",
32+
content: [
33+
{
34+
type: "tool_call",
35+
id,
36+
name: "read_file",
37+
arguments: { path: `src/${id}.ts` },
38+
},
39+
],
40+
timestamp: Date.now(),
41+
},
42+
{
43+
role: "user",
44+
content: [
45+
{
46+
type: "tool_result",
47+
callId: id,
48+
content: [{ type: "text", text: resultText }],
49+
},
50+
],
51+
timestamp: Date.now(),
52+
},
53+
];
54+
}
55+
56+
function liveResultText(turns: ConversationTurn[]): string {
57+
return turns
58+
.flatMap((t) =>
59+
t.content.flatMap((b) => {
60+
if (b.type === "text") return [b.text];
61+
if (b.type === "tool_result")
62+
return b.content.map((c) => (c.type === "text" ? c.text : ""));
63+
return [];
64+
}),
65+
)
66+
.join("\n");
67+
}
68+
69+
describe("excerpt once per tail turn", () => {
70+
test("compactor excerpts each tail turn exactly once with identical values", async () => {
71+
const dump = "z".repeat(12_000);
72+
const turns: ConversationTurn[] = [
73+
textTurn("user", "do the work"),
74+
...pairTurns("a", `a-result:${dump}`),
75+
...pairTurns("b", `b-result:${dump}`),
76+
...pairTurns("c", `c-result:${dump}`),
77+
textTurn("user", "newest ask"),
78+
textTurn("assistant", "newest reply"),
79+
];
80+
81+
const before = excerptTailTurnCallCount();
82+
const result = await createPruningCompactor({
83+
compactionShape: { tailBudgetTokens: 1_000_000 },
84+
summarize: async () => {
85+
throw new Error("must not invent a summary: nothing excluded");
86+
},
87+
}).apply(turns, mockStrategyCtx);
88+
89+
const calls = excerptTailTurnCallCount() - before;
90+
91+
expect(calls).toBe(turns.length);
92+
93+
const live = liveResultText(result.output);
94+
expect(live.match(/\[tail-shortened \d+→/g)?.length ?? 0).toBe(3);
95+
expect(live).not.toContain(dump);
96+
});
97+
});

‎src/session/compactor.ts‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,11 @@ function excerptTailText(
647647
};
648648
}
649649

650+
let excerptTailTurnCallCountValue = 0;
651+
export function excerptTailTurnCallCount(): number {
652+
return excerptTailTurnCallCountValue;
653+
}
654+
650655
// Excerpted live copy of a tail turn: thinking/redacted_thinking drop except
651656
// on the last assistant with a tool_call (Anthropic continuation needs the
652657
// unmodified thinking+signature on that tool_use). Whole blocks drop so
@@ -658,6 +663,7 @@ function excerptTailTurn(
658663
shape: CompactionShape,
659664
keepThinking = false,
660665
): { turn: ConversationTurn; shortenedOutputs: number; changed: boolean } {
666+
excerptTailTurnCallCountValue += 1;
661667
let shortenedOutputs = 0;
662668
let changed = false;
663669
const content = turn.content.flatMap((block): ConversationTurn["content"] => {
@@ -716,12 +722,7 @@ function lastAssistantToolCallIndex(
716722
return undefined;
717723
}
718724

719-
function excerptedTurnTokens(
720-
turn: ConversationTurn,
721-
shape: CompactionShape,
722-
keepThinking = false,
723-
): number {
724-
const { turn: live } = excerptTailTurn(turn, shape, keepThinking);
725+
function liveTurnTokens(live: ConversationTurn): number {
725726
let tokens = 0;
726727
for (const block of live.content) {
727728
tokens += estimateContentBlockTokens(block);
@@ -769,18 +770,24 @@ function selectTail(
769770
const budgetTokens = shape.tailBudgetTokens;
770771
const keepThinkingAt = lastAssistantToolCallIndex(turns);
771772

773+
type ExcerptResult = ReturnType<typeof excerptTailTurn>;
774+
const excerptCache = new Map<number, ExcerptResult>();
775+
const excerptFor = (idx: number, turn: ConversationTurn): ExcerptResult => {
776+
let result = excerptCache.get(idx);
777+
if (result === undefined) {
778+
result = excerptTailTurn(turn, shape, idx === keepThinkingAt);
779+
excerptCache.set(idx, result);
780+
}
781+
return result;
782+
};
783+
772784
const turnCost = (idx: number): { tokens: number; shortened: number } => {
773785
const turn = turns[idx];
774786
if (turn === undefined) return { tokens: 0, shortened: 0 };
775-
const keepThinking = idx === keepThinkingAt;
776-
const {
777-
turn: live,
778-
shortenedOutputs,
779-
changed,
780-
} = excerptTailTurn(turn, shape, keepThinking);
787+
const { turn: live, shortenedOutputs, changed } = excerptFor(idx, turn);
781788
if (changed) excerpted.set(idx, live);
782789
return {
783-
tokens: excerptedTurnTokens(turn, shape, keepThinking),
790+
tokens: liveTurnTokens(live),
784791
shortened: shortenedOutputs,
785792
};
786793
};
@@ -836,7 +843,7 @@ function selectTail(
836843
for (const idx of closure) {
837844
const t = turns[idx];
838845
if (t === undefined || isFoldableHandoffTurn(t)) continue;
839-
closureTokens += excerptedTurnTokens(t, shape, idx === keepThinkingAt);
846+
closureTokens += liveTurnTokens(excerptFor(idx, t).turn);
840847
}
841848
if (picked.size > 0 && usedTokens + closureTokens > budgetTokens) break;
842849
for (const idx of closure) pick(idx);

0 commit comments

Comments
 (0)