Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 10 additions & 76 deletions src/session/compactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,17 +204,12 @@ export type CompactorConfig = {
// errors) before the summary stub. Pulled from the end of the older set
// so the most-recent anchors survive.
maxAnchorTurns: number;
// When true, replace tool_result content in every kept turn with a
// one-line stub. Safe at compaction time because the cache is already
// cold from the compaction event itself.
stripResultContent: boolean;
};

const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = {
keepRecentTurns: 5,
summaryMaxChars: 2000,
maxAnchorTurns: 8,
stripResultContent: false,
};

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

// Tool name → path argument, used to build readable stubs.
type ToolCallInfo = {
name: string;
pathArg?: string;
commandArg?: string;
};

// Build a callId → tool info index from the full turn list so the strip
// function can produce named stubs without searching across turns.
function buildCallIndex(turns: ConversationTurn[]): Map<string, ToolCallInfo> {
const index = new Map<string, ToolCallInfo>();
for (const turn of turns) {
for (const block of turn.content) {
if (block.type !== "tool_call") continue;
const info: ToolCallInfo = { name: block.name };
const args = block.arguments;
if (typeof args === "object" && args !== null) {
const a = args as Record<string, unknown>;
if (typeof a["path"] === "string") info.pathArg = a["path"];
if (typeof a["command"] === "string") info.commandArg = a["command"];
}
index.set(block.id, info);
}
}
return index;
}

// Locate the turn index of each tool_call and its matching tool_result. In this
// runtime a call lives on one turn and its result on the following turn, so the
// two halves of a pair can straddle a keep/summarize boundary.
Expand Down Expand Up @@ -309,39 +277,6 @@ function resultContentSize(block: Extract<ConversationTurn["content"][number], {
return block.content.reduce((sum, c) => sum + (c.type === "text" ? c.text.length : 0), 0);
}

function buildResultStub(
block: Extract<ConversationTurn["content"][number], { type: "tool_result" }>,
callIndex: Map<string, ToolCallInfo>,
): string {
const info = callIndex.get(block.callId);
const name = info?.name ?? "tool_result";
const size = resultContentSize(block);
if (info?.pathArg !== undefined) {
const path = info.pathArg;
const spillHint =
path.startsWith("tool-output://") ? " Re-read with read_file offset/limit or grep on that URI." : "";
return `[${name} ${path} — ${size} chars omitted from context; source unchanged.${spillHint}]`;
}
if (info?.commandArg !== undefined) {
const cmd = info.commandArg.slice(0, 40);
return `[${name} "${cmd}" — ${size} chars, omitted]`;
}
return `[${name} — ${size} chars, omitted]`;
}

// Replace tool_result content with a one-line stub. Errors are kept in full
// because they may describe constraints the model still needs to respect.
function stripTurnResults(
turn: ConversationTurn,
callIndex: Map<string, ToolCallInfo>,
): ConversationTurn {
const content = turn.content.map((block): ConversationTurn["content"][number] => {
if (block.type !== "tool_result" || block.isError === true) return block;
return { ...block, content: [{ type: "text", text: buildResultStub(block, callIndex) }] };
});
return { ...turn, content };
}

// True when a turn carries no tool_call/tool_result blocks.
function isPlainTextTurn(turn: ConversationTurn): boolean {
return !turn.content.some((b) => b.type === "tool_call" || b.type === "tool_result");
Expand Down Expand Up @@ -429,7 +364,7 @@ export function createPruningCompactor(

return {
name: "pruning-compactor",
version: "1.1.0",
version: "1.2.0",
async apply(
turns: ConversationTurn[],
_ctx: StrategyContext,
Expand All @@ -455,8 +390,6 @@ export function createPruningCompactor(
};
}

const callIndex = buildCallIndex(aged.turns);

const keepCount = Math.min(cfg.keepRecentTurns, aged.turns.length - 1);
const keepFrom = aged.turns.length - keepCount;
const recentTurns = aged.turns.slice(keepFrom);
Expand Down Expand Up @@ -513,15 +446,17 @@ export function createPruningCompactor(
timestamp: olderTurns[olderTurns.length - 1]?.timestamp ?? Date.now(),
};

const process = (t: ConversationTurn): ConversationTurn =>
cfg.stripResultContent ? stripTurnResults(t, callIndex) : t;

// Anchors are already image-aged (outside the recent window). Recent
// turns keep live base64 so a just-pasted screenshot still reaches the model.
// Anchors and recent turns are exactly what compaction chose to keep —
// pulling a turn forward and then hollowing out its tool_result defeats
// the reason it was kept. Only summarizedTurns lose their content, and
// they lose it wholesale (folded into `summary` above), not stubbed
// in place. Anchors are already image-aged (outside the recent window).
// Recent turns keep live base64 so a just-pasted screenshot still
// reaches the model.
const output = coalesceAdjacentTextTurns([
summaryTurn,
...anchorTurns.map(process),
...recentTurns.map(process),
...anchorTurns,
...recentTurns,
]);

return {
Expand All @@ -533,7 +468,6 @@ export function createPruningCompactor(
keepRecentTurns: cfg.keepRecentTurns,
summaryMaxChars: cfg.summaryMaxChars,
maxAnchorTurns: cfg.maxAnchorTurns,
stripResultContent: cfg.stripResultContent,
},
reason: `compacted ${summarizedTurns.length} turns, anchored ${anchorTurns.length}, keeping ${keepCount} recent`,
decisions: {
Expand Down
2 changes: 1 addition & 1 deletion src/session/runtime-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ describe("skillDirsFromEnabledPlugins", () => {
});

describe("createSessionPruningCompactor", () => {
test("uses stripResultContent in pruning mode and summarize otherwise", async () => {
test("only wires a summarize function in llm mode", async () => {
const summarize = async () => "summary";
const pruning = createSessionPruningCompactor({
compactionMode: "pruning",
Expand Down
4 changes: 1 addition & 3 deletions src/session/runtime-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,6 @@ export function createSessionPruningCompactor(
return createPruningCompactor({
keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS,
summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS,
...(args.compactionMode !== "pruning"
? { summarize: args.summarize }
: { stripResultContent: true }),
...(args.compactionMode !== "pruning" ? { summarize: args.summarize } : {}),
});
}
1 change: 0 additions & 1 deletion src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,6 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
"pruning-compactor": createPruningCompactor({
keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS,
summaryMaxChars: 2500,
stripResultContent: true,
// A structured model summary keeps sub-agent context useful across a
// compaction; the deterministic stub remains the fallback on failure.
...(subagentSource !== undefined
Expand Down
47 changes: 45 additions & 2 deletions tests/unit/compactor-pairing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => {
userResult("c1"), // index 4 -> recent window head
userText("c"), userText("d"), userText("e"), userText("f"), userText("g"),
];
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2, stripResultContent: true });
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 });
const { output } = await compactor.apply(turns, {} as never);
expect(() => assertWellFormedToolSequence(output)).not.toThrow();
});
Expand All @@ -38,11 +38,54 @@ describe("pruning compactor preserves tool_call/tool_result pairing", () => {
userText("a"), userText("b"), userText("c"), userText("d"),
userText("e"), userText("f"), userText("g"),
];
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2, stripResultContent: true });
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 });
const { output } = await compactor.apply(turns, {} as never);
expect(() => assertWellFormedToolSequence(output)).not.toThrow();
});

test("keeps tool_result content in a recent-window turn across a pruning pass", async () => {
const editResult: ConversationTurn = {
role: "user",
content: [{ type: "tool_result", callId: "edit1", content: [{ type: "text", text: "diff applied to file.ts" }] }],
timestamp: 1,
};
const turns: ConversationTurn[] = [
userText("start"), userText("a"), userText("b"), userText("c"), userText("d"),
{ role: "assistant", content: [{ type: "tool_call", id: "edit1", name: "edit_file", arguments: { path: "file.ts" } }], timestamp: 1 },
editResult,
userText("e"), userText("f"), userText("g"),
];
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 });
const { output } = await compactor.apply(turns, {} as never);
const kept = output.find((t) =>
t.content.some((b) => b.type === "tool_result" && b.callId === "edit1"),
);
const resultBlock = kept?.content.find((b) => b.type === "tool_result" && b.callId === "edit1");
expect(resultBlock).toMatchObject({ content: [{ type: "text", text: "diff applied to file.ts" }] });
});

test("keeps tool_result content in an anchored file-edit turn pulled forward from the discarded middle", async () => {
const editResult: ConversationTurn = {
role: "user",
content: [{ type: "tool_result", callId: "edit1", content: [{ type: "text", text: "diff applied to file.ts" }] }],
timestamp: 1,
};
const turns: ConversationTurn[] = [
userText("start"),
{ role: "assistant", content: [{ type: "tool_call", id: "edit1", name: "edit_file", arguments: { path: "file.ts" } }], timestamp: 1 },
editResult,
userText("a"), userText("b"), userText("c"), userText("d"),
userText("e"), userText("f"), userText("g"),
];
const compactor = createPruningCompactor({ keepRecentTurns: 6, maxAnchorTurns: 2 });
const { output } = await compactor.apply(turns, {} as never);
const kept = output.find((t) =>
t.content.some((b) => b.type === "tool_result" && b.callId === "edit1"),
);
const resultBlock = kept?.content.find((b) => b.type === "tool_result" && b.callId === "edit1");
expect(resultBlock).toMatchObject({ content: [{ type: "text", text: "diff applied to file.ts" }] });
});

test("buildTurnSummary counts large tool_result payloads", () => {
const big: ConversationTurn = {
role: "user",
Expand Down
Loading