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
64 changes: 63 additions & 1 deletion src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ import type { SessionStore } from "./session";
import { log, formatError } from "./logger";
import type { ProgressUpdate } from "./progress";
import type { MemoryIndex } from "./memory/index";
import { shouldFlush, shouldCompact, buildFlushPrompt, compactSession } from "./memory/compaction";
import {
shouldFlush,
shouldCompact,
buildFlushPrompt,
compactSession,
estimateHistoryTokens,
CONTEXT_WINDOW,
} from "./memory/compaction";

export const MAX_ITERATIONS = 25;

Expand Down Expand Up @@ -89,6 +96,35 @@ export async function runAgent(
let totalTokens = 0;
let hasFlushed = false;

// Preemptive compaction: if history already exceeds context window, compact before calling LLM
const estimatedTokens = estimateHistoryTokens(history);
if (estimatedTokens >= CONTEXT_WINDOW) {
log.info("agent", "History exceeds context window, compacting before LLM call", {
estimatedTokens,
});

// Save new messages first (user message), then compact
ctx.sessionStore.append(ctx.sessionKey, sanitizeForPersist(newMessages));
newMessages.length = 0;

const result = await compactSession({
messages: history,
totalTokens: estimatedTokens,
callLLM: ctx.callLLM ?? callLLM,
authStorage: ctx.authStorage,
});

history.length = 0;
history.push(...result.messages);
ctx.sessionStore.compact(ctx.sessionKey, result.messages);

try {
await ctx.memoryIndex.sync();
} catch (err) {
log.warn("agent", "Memory index sync failed after preemptive compaction", formatError(err));
}
}

// Agent loop
for (let i = 0; i < MAX_ITERATIONS; i++) {
log.info("agent", "LLM iteration", { iteration: i + 1 });
Expand Down Expand Up @@ -153,6 +189,32 @@ export async function runAgent(
continue;
}

// After flush, compact immediately to avoid re-triggering flush on next run
if (hasFlushed) {
log.info("agent", "Compacting session after memory flush", { totalTokens });
ctx.sessionStore.append(ctx.sessionKey, sanitizeForPersist(newMessages));
newMessages.length = 0;

const result = await compactSession({
messages: history,
totalTokens,
callLLM: ctx.callLLM ?? callLLM,
authStorage: ctx.authStorage,
});

history.length = 0;
history.push(...result.messages);
ctx.sessionStore.compact(ctx.sessionKey, result.messages);

try {
await ctx.memoryIndex.sync();
} catch (err) {
log.warn("agent", "Memory index sync failed after compaction", formatError(err));
}

return response.text;
}

ctx.sessionStore.append(ctx.sessionKey, sanitizeForPersist(newMessages));
return response.text;
}
Expand Down
101 changes: 100 additions & 1 deletion tests/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { buildSystemPrompt } from "../src/workspace/prompt";
import { formatSkillsForPrompt } from "../src/skills/prompt";
import type { LLMResponse, LLMContentBlock, LLMMessage } from "../src/llm";
import type { ProgressUpdate } from "../src/progress";
import { CONTEXT_WINDOW, RESERVE_FLOOR } from "../src/memory/compaction";
import { CONTEXT_WINDOW, RESERVE_FLOOR, SOFT_THRESHOLD } from "../src/memory/compaction";
import { createTempDir, cleanupTempDir } from "./helpers/temp-dir";
import {
buildLLMResponse,
Expand Down Expand Up @@ -412,6 +412,105 @@ describe("agent loop", () => {
expect(saved[0].content).toBe("stay within budget");
});

test("preemptively compacts when loaded history already exceeds context window", async () => {
const sessionStore = new SessionStore(tmpDir);

// Seed session with a message large enough to exceed CONTEXT_WINDOW estimate
// estimateHistoryTokens: ceil((chars / 4) * 1.2), so we need chars ≈ CONTEXT_WINDOW * 4
const hugeText = "x".repeat(CONTEXT_WINDOW * 4);
sessionStore.append("preemptive-test", [
{ role: "user", content: "old question" },
{ role: "assistant", content: hugeText },
]);

let callCount = 0;
const ctx: AgentContext = {
authStorage: buildStubAuth(),
tools: [],
skills: [],
workspaceFiles: [],
sessionStore,
sessionKey: "preemptive-test",
memoryIndex,
callLLM: async () => {
callCount++;
if (callCount === 1) {
// Summarization call from preemptive compaction
return buildLLMResponse({ text: "Summary of old conversation" });
}
// Normal agent response after compaction
return buildLLMResponse({ text: "Hello after compaction" });
},
};

const result = await runAgent(ctx, "new message");

expect(result).toBe("Hello after compaction");
// Session should have been compacted (summary prepended)
const saved = sessionStore.get("preemptive-test");
expect(saved[0].content).toContain("[Previous conversation summary]");
});

test("compacts session after memory flush to prevent repeated flush on next run", async () => {
// Token count in the flush zone (above soft threshold, below hard threshold)
const flushZoneTokens = CONTEXT_WINDOW - RESERVE_FLOOR - SOFT_THRESHOLD + 100;
const tool = buildStubTool("write_file", "ok");
const sessionStore = new SessionStore(tmpDir);
let callCount = 0;
const flushUsage = {
inputTokens: flushZoneTokens,
outputTokens: 50,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
};

const ctx: AgentContext = {
authStorage: buildStubAuth(),
tools: [tool],
skills: [],
workspaceFiles: [],
sessionStore,
sessionKey: "flush-compact-test",
memoryIndex,
callLLM: async () => {
callCount++;
if (callCount === 1) {
// Initial response that puts us in the flush zone
return buildLLMResponse({
text: "Here's your answer",
stopReason: "end_turn",
usage: flushUsage,
});
}
if (callCount === 2) {
// Response to flush prompt — save memory via tool
return buildLLMResponse({
text: "",
toolCalls: [{ id: "tc1", name: "write_file", input: { path: "memory/2026-01-01.md" } }],
stopReason: "tool_use",
usage: flushUsage,
});
}
if (callCount === 3) {
// After tool execution, end turn
return buildLLMResponse({
text: "Memory saved",
stopReason: "end_turn",
usage: flushUsage,
});
}
// Summarization call during compaction
return buildLLMResponse({ text: "Summary of conversation" });
},
};

await runAgent(ctx, "trigger flush");

// Session should have been compacted (summary prepended)
const saved = sessionStore.get("flush-compact-test");
expect(saved[0].content).toContain("[Previous conversation summary]");
});

test("accepts LLMContentBlock[] as userMessage with image blocks", async () => {
let receivedMessageCount = 0;
let hadImageBlock = false;
Expand Down