Skip to content

Commit 43650a0

Browse files
committed
feat(compaction): microcompaction — clear stale tool results before summarizing
Our compaction was single-tier: at 75% of the window it ran the glue-model summarize-everything sledgehammer. Now a cheap first pass runs first — microcompaction clears the CONTENT of stale tool-result messages (old read_file/grep/glob/shell/web/edit output the model already consumed) while keeping the newest 6 intact and preserving message structure, so tool_use/tool_result pairing isn't broken and no glue call happens. transformContext now: needsCompaction → microcompact → if that alone drops us back under threshold, done (no summary); else fall through to the full summarize path operating on the already-cleared messages. Adapts Claude Code's COMPACTABLE_TOOLS + content-clear mechanic to our proxy/any-model setup — CC's two triggers (cache_edits API, cold-cache time-based) are Anthropic-cache-specific, so ours is token-pressure triggered instead. Errored results are kept (short, valuable as debugging signal); the pass is idempotent. 7 microcompact tests + an engine test proving microcompact frees tokens with zero glue calls.
1 parent 11ad4b4 commit 43650a0

6 files changed

Lines changed: 268 additions & 11 deletions

File tree

src/agent/agent.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,7 @@ export interface AgentBundle {
114114
* veto. Callers that originate prompts from real user input should use
115115
* this instead of `agent.prompt()` directly.
116116
*/
117-
submitUserPrompt: (
118-
text: string,
119-
) => Promise<{ submitted: boolean; reason?: string; error?: string }>;
117+
submitUserPrompt: (text: string) => Promise<{ submitted: boolean; reason?: string; error?: string }>;
120118
/**
121119
* Set when `--resume` actually loaded a prior session, with its
122120
* timestamp + message count so the welcome banner can say
@@ -228,7 +226,10 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
228226
// project's conventions on every turn. Memory addendum is appended
229227
// after — it's the user's accumulated long-term notes.
230228
const fullSystemPrompt =
231-
systemPrompt + buildProjectFilesAddendum(cwd) + buildMemoryAddendum(memory) + buildOutputStyleAddendum(persistedConfig, cwd);
229+
systemPrompt +
230+
buildProjectFilesAddendum(cwd) +
231+
buildMemoryAddendum(memory) +
232+
buildOutputStyleAddendum(persistedConfig, cwd);
232233

233234
const agent = new Agent({
234235
initialState: {
@@ -240,14 +241,28 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
240241
getApiKey: () => apiKey,
241242
transformContext: async (messages, signal) => {
242243
if (!compaction.needsCompaction(messages)) return messages;
243-
compactionMonitor.start(messages.length);
244+
245+
// Stage 1 — microcompaction: clear stale tool-result content
246+
// (old reads, grep dumps, command output) without a summary
247+
// round-trip. Cheap. If that alone drops us back under the
248+
// threshold, we're done and skip the expensive summarize path.
249+
const micro = compaction.microcompact(messages);
250+
if (micro.clearedCount > 0 && !compaction.needsCompaction(micro.messages)) {
251+
return micro.messages;
252+
}
253+
// Microcompaction wasn't enough (or freed nothing) — fall through
254+
// to the full summarize-everything compaction, operating on the
255+
// already-cleared messages so the summary input is smaller too.
256+
const working = micro.messages;
257+
258+
compactionMonitor.start(working.length);
244259
try {
245260
await hooks.dispatch(
246261
"PreCompact",
247-
{ event: "PreCompact", workingDir: cwd, messageCount: messages.length },
262+
{ event: "PreCompact", workingDir: cwd, messageCount: working.length },
248263
signal,
249264
);
250-
const result = await compaction.compact(messages, signal);
265+
const result = await compaction.compact(working, signal);
251266
await hooks.dispatch(
252267
"PostCompact",
253268
{
@@ -419,9 +434,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
419434
* accepted the prompt. The agent's own turn lifecycle still emits
420435
* events on bundle.subscribe.
421436
*/
422-
const submitUserPrompt = async (
423-
text: string,
424-
): Promise<{ submitted: boolean; reason?: string; error?: string }> => {
437+
const submitUserPrompt = async (text: string): Promise<{ submitted: boolean; reason?: string; error?: string }> => {
425438
const outcome = await hooks.dispatch("UserPromptSubmit", {
426439
event: "UserPromptSubmit",
427440
workingDir: cwd,

src/compaction/engine.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,22 @@ describe("CompactionEngine", () => {
222222
const result = await engine.compact(messages);
223223
expect(result.details.summary).toMatch(/summarization failed/);
224224
});
225+
226+
it("microcompact clears stale tool-result content without a glue call", () => {
227+
const glue = fakeGlue("should not be called");
228+
const engine = new CompactionEngine({ glue, modelId: "claude-sonnet-4-6" });
229+
const big = "x".repeat(4000);
230+
const messages: AgentMessage[] = [];
231+
for (let i = 0; i < 10; i++) {
232+
messages.push(toolCallMessage("read_file", `tc${i}`, { path: `f${i}.ts` }));
233+
messages.push(toolResultMessage(`tc${i}`, "read_file", `${big} #${i}`));
234+
}
235+
const before = estimateTotalTokens(messages);
236+
const out = engine.microcompact(messages);
237+
expect(out.clearedCount).toBeGreaterThan(0);
238+
expect(out.tokensSaved).toBeGreaterThan(0);
239+
expect(estimateTotalTokens(out.messages)).toBeLessThan(before);
240+
// Microcompaction never summarizes — glue must not be touched.
241+
expect(glue.smart).not.toHaveBeenCalled();
242+
});
225243
});

src/compaction/engine.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import type { AgentMessage } from "@earendil-works/pi-agent-core";
22
import type { GlueClient } from "../glue/client.js";
3+
import { microcompact } from "./microcompact.js";
34
import { contextWindow, estimateMessageTokens, estimateTotalTokens } from "./tokens.js";
4-
import type { CompactionDetails, CompactionResult } from "./types.js";
5+
import type { CompactionDetails, CompactionResult, MicrocompactResult } from "./types.js";
56

67
const DEFAULT_THRESHOLD = 0.75;
78
const DEFAULT_KEEP_RECENT = 8;
9+
/** Newest compactable tool results microcompaction always keeps intact. */
10+
const MICRO_KEEP_RECENT = 6;
811

912
const SUMMARY_SYSTEM_PROMPT = `Summarize the prior conversation between a user and a coding agent in tight markdown. Output structure:
1013
@@ -62,6 +65,21 @@ export class CompactionEngine {
6265
return estimateTotalTokens(messages) >= this.threshold();
6366
}
6467

68+
/**
69+
* Cheap first-pass compaction: clear the content of stale tool
70+
* results (old file reads, grep dumps, command output the model
71+
* already consumed) while keeping the newest few and preserving
72+
* message structure. No glue-model round-trip. Returns the rewritten
73+
* messages plus how much was freed.
74+
*
75+
* Exposed so the agent's transformContext can try this before the
76+
* expensive summarize-everything path — clearing tool results often
77+
* relieves the pressure on its own.
78+
*/
79+
microcompact(messages: AgentMessage[]): MicrocompactResult {
80+
return microcompact(messages, MICRO_KEEP_RECENT);
81+
}
82+
6583
async compact(messages: AgentMessage[], signal?: AbortSignal): Promise<CompactionResult> {
6684
if (messages.length <= this.keepRecent) {
6785
return {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
2+
import { describe, expect, it } from "vitest";
3+
import { CLEARED_TOOL_RESULT, microcompact } from "./microcompact.js";
4+
5+
function toolResult(toolName: string, text: string, opts: { isError?: boolean } = {}): AgentMessage {
6+
return {
7+
role: "toolResult",
8+
toolCallId: `tc-${Math.random().toString(36).slice(2)}`,
9+
toolName,
10+
content: [{ type: "text", text }],
11+
isError: opts.isError ?? false,
12+
timestamp: 0,
13+
} as AgentMessage;
14+
}
15+
16+
function user(text: string): AgentMessage {
17+
return { role: "user", content: text, timestamp: 0 } as AgentMessage;
18+
}
19+
20+
function clearedText(m: AgentMessage): string | undefined {
21+
const content = (m as { content: Array<{ type: string; text?: string }> }).content;
22+
return content?.[0]?.text;
23+
}
24+
25+
describe("microcompact", () => {
26+
it("clears stale compactable tool results, keeping the newest N", () => {
27+
const big = "x".repeat(5000);
28+
const messages: AgentMessage[] = [];
29+
for (let i = 0; i < 10; i++) {
30+
messages.push(user(`turn ${i}`));
31+
messages.push(toolResult("read_file", `${big} #${i}`));
32+
}
33+
const out = microcompact(messages, 3);
34+
// 10 read_file results, keep 3 → clear 7.
35+
expect(out.clearedCount).toBe(7);
36+
expect(out.tokensSaved).toBeGreaterThan(0);
37+
38+
// The last 3 tool results retain their content; earlier ones are cleared.
39+
const toolResults = out.messages.filter((m) => m.role === "toolResult");
40+
const clearedFlags = toolResults.map((m) => clearedText(m) === CLEARED_TOOL_RESULT);
41+
expect(clearedFlags.slice(0, 7)).toEqual([true, true, true, true, true, true, true]);
42+
expect(clearedFlags.slice(7)).toEqual([false, false, false]);
43+
});
44+
45+
it("preserves message structure (same count, same order, tool results stay tool results)", () => {
46+
const messages: AgentMessage[] = [
47+
user("a"),
48+
toolResult("read_file", "x".repeat(2000)),
49+
toolResult("grep", "y".repeat(2000)),
50+
toolResult("read_file", "z".repeat(2000)),
51+
];
52+
const out = microcompact(messages, 1);
53+
expect(out.messages).toHaveLength(messages.length);
54+
expect(out.messages.map((m) => m.role)).toEqual(["user", "toolResult", "toolResult", "toolResult"]);
55+
});
56+
57+
it("never clears errored tool results", () => {
58+
const messages: AgentMessage[] = [
59+
toolResult("shell", "boom", { isError: true }),
60+
toolResult("read_file", "x".repeat(2000)),
61+
toolResult("read_file", "y".repeat(2000)),
62+
];
63+
const out = microcompact(messages, 0);
64+
// keepRecent 0, but the error is exempt → only the two reads clear.
65+
expect(out.clearedCount).toBe(2);
66+
const err = out.messages[0];
67+
expect(clearedText(err)).toBe("boom");
68+
});
69+
70+
it("ignores non-compactable tools (ask_user, tasks, config, git)", () => {
71+
const messages: AgentMessage[] = [
72+
toolResult("git_status", "branch info"),
73+
toolResult("create_task", "task body"),
74+
toolResult("config", "{}"),
75+
];
76+
const out = microcompact(messages, 0);
77+
expect(out.clearedCount).toBe(0);
78+
expect(out.tokensSaved).toBe(0);
79+
});
80+
81+
it("is idempotent — a second pass clears nothing new", () => {
82+
const messages: AgentMessage[] = [
83+
toolResult("read_file", "x".repeat(3000)),
84+
toolResult("read_file", "y".repeat(3000)),
85+
toolResult("read_file", "z".repeat(3000)),
86+
];
87+
const first = microcompact(messages, 1);
88+
expect(first.clearedCount).toBe(2);
89+
const second = microcompact(first.messages, 1);
90+
expect(second.clearedCount).toBe(0);
91+
});
92+
93+
it("returns the same array unchanged when nothing to clear", () => {
94+
const messages: AgentMessage[] = [user("hi"), toolResult("read_file", "small")];
95+
const out = microcompact(messages, 6);
96+
expect(out.clearedCount).toBe(0);
97+
expect(out.messages).toBe(messages);
98+
});
99+
});

src/compaction/microcompact.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
2+
import { estimateMessageTokens } from "./tokens.js";
3+
import type { MicrocompactResult } from "./types.js";
4+
5+
/**
6+
* Placeholder swapped in for a cleared tool result. Kept terse and
7+
* self-explanatory so the model understands the content is gone but
8+
* re-obtainable, without spending tokens describing it.
9+
*/
10+
export const CLEARED_TOOL_RESULT =
11+
"[Old tool result content cleared — re-read the file or re-run the command if you need it again]";
12+
13+
/**
14+
* Tool results worth clearing once they're stale: read-only lookups and
15+
* file/command output that the model has already acted on. Deliberately
16+
* EXCLUDES tools whose result is small or load-bearing past its turn
17+
* (ask_user, tasks, memory, config, git status/diff — those are short
18+
* and often re-referenced). Mirrors Claude Code's COMPACTABLE_TOOLS.
19+
*/
20+
const COMPACTABLE_TOOLS: ReadonlySet<string> = new Set([
21+
"read_file",
22+
"shell",
23+
"ssh_exec",
24+
"grep",
25+
"glob",
26+
"list_files",
27+
"web_fetch",
28+
"web_search",
29+
"edit_file",
30+
"multi_edit",
31+
"write_file",
32+
]);
33+
34+
const DEFAULT_KEEP_RECENT = 6;
35+
36+
/**
37+
* Microcompaction: clear the CONTENT of stale tool-result messages while
38+
* keeping the message itself (so the tool_use/tool_result pairing stays
39+
* intact and the provider doesn't reject the transcript). The newest
40+
* `keepRecent` compactable results are left untouched — they're the
41+
* model's live working set.
42+
*
43+
* This is the cheap first line of defense against context pressure: a
44+
* repo-wide grep dump or a big file read the model already consumed gets
45+
* its bytes reclaimed WITHOUT a glue-model summary round-trip. The
46+
* expensive summarize-everything compaction stays as the fallback for
47+
* when clearing tool results alone isn't enough.
48+
*
49+
* Errored results are preserved — they're short and usually still
50+
* relevant as debugging signal. Already-cleared results are skipped so
51+
* the pass is idempotent.
52+
*/
53+
export function microcompact(messages: AgentMessage[], keepRecent = DEFAULT_KEEP_RECENT): MicrocompactResult {
54+
// First pass: index every compactable, not-yet-cleared tool-result
55+
// message in order.
56+
const indices: number[] = [];
57+
for (let i = 0; i < messages.length; i++) {
58+
const m = messages[i];
59+
if (m.role !== "toolResult") continue;
60+
const tr = m as ToolResultMessage;
61+
if (tr.isError) continue;
62+
if (!COMPACTABLE_TOOLS.has(tr.toolName)) continue;
63+
if (isAlreadyCleared(tr)) continue;
64+
indices.push(i);
65+
}
66+
67+
// Keep the most-recent `keepRecent`; clear the rest.
68+
const keep = Math.max(0, keepRecent);
69+
const clearIdx = new Set(indices.slice(0, Math.max(0, indices.length - keep)));
70+
if (clearIdx.size === 0) {
71+
return { messages, tokensSaved: 0, clearedCount: 0 };
72+
}
73+
74+
let tokensSaved = 0;
75+
const next = messages.map((m, i) => {
76+
if (!clearIdx.has(i)) return m;
77+
const tr = m as ToolResultMessage;
78+
const before = estimateMessageTokens(m);
79+
const cleared: ToolResultMessage = {
80+
...tr,
81+
content: [{ type: "text", text: CLEARED_TOOL_RESULT }],
82+
};
83+
tokensSaved += before - estimateMessageTokens(cleared as AgentMessage);
84+
return cleared as AgentMessage;
85+
});
86+
87+
return { messages: next, tokensSaved: Math.max(0, tokensSaved), clearedCount: clearIdx.size };
88+
}
89+
90+
interface ToolResultMessage {
91+
role: "toolResult";
92+
toolCallId: string;
93+
toolName: string;
94+
content: Array<{ type: string; text?: string }>;
95+
isError: boolean;
96+
timestamp: number;
97+
}
98+
99+
function isAlreadyCleared(tr: ToolResultMessage): boolean {
100+
return tr.content.length === 1 && tr.content[0].type === "text" && tr.content[0].text === CLEARED_TOOL_RESULT;
101+
}

src/compaction/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,11 @@ export interface CompactionResult {
1717
messages: AgentMessage[];
1818
details: CompactionDetails;
1919
}
20+
21+
export interface MicrocompactResult {
22+
messages: AgentMessage[];
23+
/** Approximate tokens freed by clearing stale tool-result content. */
24+
tokensSaved: number;
25+
/** How many tool-result messages had their content cleared. */
26+
clearedCount: number;
27+
}

0 commit comments

Comments
 (0)