Skip to content

Commit 967ba4e

Browse files
Merge kept-turn tool result retention
2 parents bfd1595 + 1c7370f commit 967ba4e

5 files changed

Lines changed: 57 additions & 83 deletions

File tree

src/session/compactor.ts

Lines changed: 10 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -204,17 +204,12 @@ export type CompactorConfig = {
204204
// errors) before the summary stub. Pulled from the end of the older set
205205
// so the most-recent anchors survive.
206206
maxAnchorTurns: number;
207-
// When true, replace tool_result content in every kept turn with a
208-
// one-line stub. Safe at compaction time because the cache is already
209-
// cold from the compaction event itself.
210-
stripResultContent: boolean;
211207
};
212208

213209
const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = {
214210
keepRecentTurns: 5,
215211
summaryMaxChars: 2000,
216212
maxAnchorTurns: 8,
217-
stripResultContent: false,
218213
};
219214

220215
// Recent turns kept verbatim by both real pruning-compactor registrations
@@ -233,33 +228,6 @@ export function compactorNoOpFloor(keepRecentTurns: number): number {
233228
// Minimum anchor score for a turn to be pulled forward past the summary boundary.
234229
const ANCHOR_SCORE_THRESHOLD = 5;
235230

236-
// Tool name → path argument, used to build readable stubs.
237-
type ToolCallInfo = {
238-
name: string;
239-
pathArg?: string;
240-
commandArg?: string;
241-
};
242-
243-
// Build a callId → tool info index from the full turn list so the strip
244-
// function can produce named stubs without searching across turns.
245-
function buildCallIndex(turns: ConversationTurn[]): Map<string, ToolCallInfo> {
246-
const index = new Map<string, ToolCallInfo>();
247-
for (const turn of turns) {
248-
for (const block of turn.content) {
249-
if (block.type !== "tool_call") continue;
250-
const info: ToolCallInfo = { name: block.name };
251-
const args = block.arguments;
252-
if (typeof args === "object" && args !== null) {
253-
const a = args as Record<string, unknown>;
254-
if (typeof a["path"] === "string") info.pathArg = a["path"];
255-
if (typeof a["command"] === "string") info.commandArg = a["command"];
256-
}
257-
index.set(block.id, info);
258-
}
259-
}
260-
return index;
261-
}
262-
263231
// Locate the turn index of each tool_call and its matching tool_result. In this
264232
// runtime a call lives on one turn and its result on the following turn, so the
265233
// two halves of a pair can straddle a keep/summarize boundary.
@@ -309,39 +277,6 @@ function resultContentSize(block: Extract<ConversationTurn["content"][number], {
309277
return block.content.reduce((sum, c) => sum + (c.type === "text" ? c.text.length : 0), 0);
310278
}
311279

312-
function buildResultStub(
313-
block: Extract<ConversationTurn["content"][number], { type: "tool_result" }>,
314-
callIndex: Map<string, ToolCallInfo>,
315-
): string {
316-
const info = callIndex.get(block.callId);
317-
const name = info?.name ?? "tool_result";
318-
const size = resultContentSize(block);
319-
if (info?.pathArg !== undefined) {
320-
const path = info.pathArg;
321-
const spillHint =
322-
path.startsWith("tool-output://") ? " Re-read with read_file offset/limit or grep on that URI." : "";
323-
return `[${name} ${path}${size} chars omitted from context; source unchanged.${spillHint}]`;
324-
}
325-
if (info?.commandArg !== undefined) {
326-
const cmd = info.commandArg.slice(0, 40);
327-
return `[${name} "${cmd}" — ${size} chars, omitted]`;
328-
}
329-
return `[${name}${size} chars, omitted]`;
330-
}
331-
332-
// Replace tool_result content with a one-line stub. Errors are kept in full
333-
// because they may describe constraints the model still needs to respect.
334-
function stripTurnResults(
335-
turn: ConversationTurn,
336-
callIndex: Map<string, ToolCallInfo>,
337-
): ConversationTurn {
338-
const content = turn.content.map((block): ConversationTurn["content"][number] => {
339-
if (block.type !== "tool_result" || block.isError === true) return block;
340-
return { ...block, content: [{ type: "text", text: buildResultStub(block, callIndex) }] };
341-
});
342-
return { ...turn, content };
343-
}
344-
345280
// True when a turn carries no tool_call/tool_result blocks.
346281
function isPlainTextTurn(turn: ConversationTurn): boolean {
347282
return !turn.content.some((b) => b.type === "tool_call" || b.type === "tool_result");
@@ -429,7 +364,7 @@ export function createPruningCompactor(
429364

430365
return {
431366
name: "pruning-compactor",
432-
version: "1.1.0",
367+
version: "1.2.0",
433368
async apply(
434369
turns: ConversationTurn[],
435370
_ctx: StrategyContext,
@@ -455,8 +390,6 @@ export function createPruningCompactor(
455390
};
456391
}
457392

458-
const callIndex = buildCallIndex(aged.turns);
459-
460393
const keepCount = Math.min(cfg.keepRecentTurns, aged.turns.length - 1);
461394
const keepFrom = aged.turns.length - keepCount;
462395
const recentTurns = aged.turns.slice(keepFrom);
@@ -513,15 +446,17 @@ export function createPruningCompactor(
513446
timestamp: olderTurns[olderTurns.length - 1]?.timestamp ?? Date.now(),
514447
};
515448

516-
const process = (t: ConversationTurn): ConversationTurn =>
517-
cfg.stripResultContent ? stripTurnResults(t, callIndex) : t;
518-
519-
// Anchors are already image-aged (outside the recent window). Recent
520-
// turns keep live base64 so a just-pasted screenshot still reaches the model.
449+
// Anchors and recent turns are exactly what compaction chose to keep —
450+
// pulling a turn forward and then hollowing out its tool_result defeats
451+
// the reason it was kept. Only summarizedTurns lose their content, and
452+
// they lose it wholesale (folded into `summary` above), not stubbed
453+
// in place. Anchors are already image-aged (outside the recent window).
454+
// Recent turns keep live base64 so a just-pasted screenshot still
455+
// reaches the model.
521456
const output = coalesceAdjacentTextTurns([
522457
summaryTurn,
523-
...anchorTurns.map(process),
524-
...recentTurns.map(process),
458+
...anchorTurns,
459+
...recentTurns,
525460
]);
526461

527462
return {
@@ -533,7 +468,6 @@ export function createPruningCompactor(
533468
keepRecentTurns: cfg.keepRecentTurns,
534469
summaryMaxChars: cfg.summaryMaxChars,
535470
maxAnchorTurns: cfg.maxAnchorTurns,
536-
stripResultContent: cfg.stripResultContent,
537471
},
538472
reason: `compacted ${summarizedTurns.length} turns, anchored ${anchorTurns.length}, keeping ${keepCount} recent`,
539473
decisions: {

src/session/runtime-assembly.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ describe("skillDirsFromEnabledPlugins", () => {
142142
});
143143

144144
describe("createSessionPruningCompactor", () => {
145-
test("uses stripResultContent in pruning mode and summarize otherwise", async () => {
145+
test("only wires a summarize function in llm mode", async () => {
146146
const summarize = async () => "summary";
147147
const pruning = createSessionPruningCompactor({
148148
compactionMode: "pruning",

src/session/runtime-assembly.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,6 @@ export function createSessionPruningCompactor(
255255
return createPruningCompactor({
256256
keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS,
257257
summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS,
258-
...(args.compactionMode !== "pruning"
259-
? { summarize: args.summarize }
260-
: { stripResultContent: true }),
258+
...(args.compactionMode !== "pruning" ? { summarize: args.summarize } : {}),
261259
});
262260
}

src/subagent/run.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,6 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
491491
"pruning-compactor": createPruningCompactor({
492492
keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS,
493493
summaryMaxChars: 2500,
494-
stripResultContent: true,
495494
// A structured model summary keeps sub-agent context useful across a
496495
// compaction; the deterministic stub remains the fallback on failure.
497496
...(subagentSource !== undefined

tests/unit/compactor-pairing.test.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => {
2525
userResult("c1"), // index 4 -> recent window head
2626
userText("c"), userText("d"), userText("e"), userText("f"), userText("g"),
2727
];
28-
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2, stripResultContent: true });
28+
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 });
2929
const { output } = await compactor.apply(turns, {} as never);
3030
expect(() => assertWellFormedToolSequence(output)).not.toThrow();
3131
});
@@ -38,11 +38,54 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => {
3838
userText("a"), userText("b"), userText("c"), userText("d"),
3939
userText("e"), userText("f"), userText("g"),
4040
];
41-
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2, stripResultContent: true });
41+
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 });
4242
const { output } = await compactor.apply(turns, {} as never);
4343
expect(() => assertWellFormedToolSequence(output)).not.toThrow();
4444
});
4545

46+
test("keeps tool_result content in a recent-window turn across a pruning pass", async () => {
47+
const editResult: ConversationTurn = {
48+
role: "user",
49+
content: [{ type: "tool_result", callId: "edit1", content: [{ type: "text", text: "diff applied to file.ts" }] }],
50+
timestamp: 1,
51+
};
52+
const turns: ConversationTurn[] = [
53+
userText("start"), userText("a"), userText("b"), userText("c"), userText("d"),
54+
{ role: "assistant", content: [{ type: "tool_call", id: "edit1", name: "edit_file", arguments: { path: "file.ts" } }], timestamp: 1 },
55+
editResult,
56+
userText("e"), userText("f"), userText("g"),
57+
];
58+
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 });
59+
const { output } = await compactor.apply(turns, {} as never);
60+
const kept = output.find((t) =>
61+
t.content.some((b) => b.type === "tool_result" && b.callId === "edit1"),
62+
);
63+
const resultBlock = kept?.content.find((b) => b.type === "tool_result" && b.callId === "edit1");
64+
expect(resultBlock).toMatchObject({ content: [{ type: "text", text: "diff applied to file.ts" }] });
65+
});
66+
67+
test("keeps tool_result content in an anchored file-edit turn pulled forward from the discarded middle", async () => {
68+
const editResult: ConversationTurn = {
69+
role: "user",
70+
content: [{ type: "tool_result", callId: "edit1", content: [{ type: "text", text: "diff applied to file.ts" }] }],
71+
timestamp: 1,
72+
};
73+
const turns: ConversationTurn[] = [
74+
userText("start"),
75+
{ role: "assistant", content: [{ type: "tool_call", id: "edit1", name: "edit_file", arguments: { path: "file.ts" } }], timestamp: 1 },
76+
editResult,
77+
userText("a"), userText("b"), userText("c"), userText("d"),
78+
userText("e"), userText("f"), userText("g"),
79+
];
80+
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 });
81+
const { output } = await compactor.apply(turns, {} as never);
82+
const kept = output.find((t) =>
83+
t.content.some((b) => b.type === "tool_result" && b.callId === "edit1"),
84+
);
85+
const resultBlock = kept?.content.find((b) => b.type === "tool_result" && b.callId === "edit1");
86+
expect(resultBlock).toMatchObject({ content: [{ type: "text", text: "diff applied to file.ts" }] });
87+
});
88+
4689
test("buildTurnSummary counts large tool_result payloads", () => {
4790
const big: ConversationTurn = {
4891
role: "user",

0 commit comments

Comments
 (0)