Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ Thumbs.db

# stray compiled binary (v1 Go artifact name)
/cli

# local changelog notes
/CHANGELOG.md
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ SEARXNG_URL self-hosted SearXNG instance
```
CODEBASE_FRESH=1 skip auto-resume of prior session (same as --new)
CODEBASE_REACT_TUI=1 legacy ink renderer (pi-tui is the default)
CODEBASE_NO_FULLSCREEN=1 inline scrollback layout instead of the full-screen
view (pinned input, wheel/PgUp/PgDn scrolls the chat,
drag selects, Ctrl+C copies the selection)
CODEBASE_NO_SUGGESTIONS=1 disable ghost-text prompt suggestions
CODEBASE_NO_NOTIFY=1 silence the turn-completion bell + OS notification
CODEBASE_NO_AUTO_MEMORY=1 disable background memory extraction
Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"@types/proper-lockfile": "^4.1.4",
"diff": "^9.0.0",
"glob": "^13.0.1",
"highlight.js": "^11.12.0",
"ignore": "^7.0.0",
"ink": "^5.2.1",
"openai": "6.26.0",
Expand Down
153 changes: 153 additions & 0 deletions src/agent/__test__/agent-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,159 @@ describe("agent bundle end-to-end", () => {
expect(stored.messages[1].role).toBe("assistant");
});

it("session keeps every turn, not just the latest run", async () => {
faux.setResponses([fauxAssistantMessage("first reply"), fauxAssistantMessage("second reply")]);
const opts = {
cwd,
configOverride: { model: faux.getModel(), apiKey: "test-key", source: "explicit" as const },
autoApprove: true,
};
const bundle = createAgent(opts);
await bundle.agent.prompt("one");
await bundle.agent.prompt("two");
expect(bundle.sessions.load(faux.getModel().id)?.messages).toHaveLength(4);

// A resumed session keeps its history when the next turn saves.
faux.setResponses([fauxAssistantMessage("third reply")]);
const resumed = createAgent({ ...opts, resume: true });
expect(resumed.resumedMessages).toHaveLength(4);
await resumed.agent.prompt("three");
expect(resumed.sessions.load(faux.getModel().id)?.messages).toHaveLength(6);
});

it("compacts once per threshold crossing, not on every later request", async () => {
faux.unregister();
faux = registerFauxProvider({
provider: "faux",
models: [{ id: "faux-small", name: "Faux Small", contextWindow: 20_270 }],
});
const firstMessages: string[] = [];
faux.setResponses(
Array.from({ length: 60 }, () => (context: Context) => {
const first = context.messages[0];
firstMessages.push(typeof first?.content === "string" ? first.content : JSON.stringify(first?.content));
return fauxAssistantMessage("ok");
}),
);
const opts = {
cwd,
configOverride: { model: faux.getModel(), apiKey: "test-key", source: "explicit" as const },
autoApprove: true,
};
const bundle = createAgent(opts);
let compactions = 0;
bundle.compactionMonitor.subscribe((s) => {
if (s.active) compactions++;
});

for (let i = 0; i < 10; i++) await bundle.agent.prompt(`big ${i} ${"x ".repeat(300)}`);
const afterCrossing = compactions;
for (let i = 0; i < 4; i++) await bundle.agent.prompt(`small ${i}`);

expect(afterCrossing).toBe(1);
expect(compactions).toBe(afterCrossing);

// After a restart the saved summary is reused: no new compaction, and
// the first request already starts from the summary, not the full history.
const resumed = createAgent({ ...opts, resume: true });
let resumedCompactions = 0;
resumed.compactionMonitor.subscribe((s) => {
if (s.active) resumedCompactions++;
});
firstMessages.length = 0;
await resumed.agent.prompt("after restart");
expect(resumedCompactions).toBe(0);
expect(firstMessages[0]).toContain("[Conversation compacted");
});

it("never caches a failed summary: keeps full context, retries, and resumes clean", async () => {
faux.unregister();
faux = registerFauxProvider({
provider: "faux",
models: [{ id: "faux-small", name: "Faux Small", contextWindow: 20_270 }],
});
const firstMessages: string[] = [];
let summaryCalls = 0;
faux.setResponses(
Array.from({ length: 80 }, () => (context: Context) => {
if (context.systemPrompt?.startsWith("Summarize the prior conversation")) {
summaryCalls++;
throw new Error("summarizer down");
}
const first = context.messages[0];
firstMessages.push(typeof first?.content === "string" ? first.content : JSON.stringify(first?.content));
return fauxAssistantMessage("ok");
}),
);
const opts = {
cwd,
configOverride: { model: faux.getModel(), apiKey: "test-key", source: "explicit" as const },
autoApprove: true,
};
const bundle = createAgent(opts);
for (let i = 0; i < 10; i++) await bundle.agent.prompt(`big ${i} ${"x ".repeat(300)}`);
const failedAttempts = summaryCalls;
await bundle.agent.prompt("small after failure");

expect(failedAttempts).toBeGreaterThan(0);
// Not memoized, so the next request tries again instead of trusting the failure.
expect(summaryCalls).toBeGreaterThan(failedAttempts);
// The full context (earliest user constraint included) was still sent.
expect(firstMessages.at(-1)).toContain("big 0");
expect(firstMessages.some((m) => m.includes("summarization failed"))).toBe(false);

const saved = bundle.sessions.load(faux.getModel().id);
expect(saved?.compaction).toBeUndefined();

firstMessages.length = 0;
const resumed = createAgent({ ...opts, resume: true });
await resumed.agent.prompt("after restart");
expect(firstMessages[0]).toContain("big 0");
});

it("never caches a summary from an aborted run, and resumes with the full history", async () => {
faux.unregister();
faux = registerFauxProvider({
provider: "faux",
models: [{ id: "faux-small", name: "Faux Small", contextWindow: 20_270 }],
});
const firstMessages: string[] = [];
let abortDuringSummary = false;
let summaries = 0;
const opts = {
cwd,
configOverride: { model: faux.getModel(), apiKey: "test-key", source: "explicit" as const },
autoApprove: true,
};
const bundle = createAgent(opts);
faux.setResponses(
Array.from({ length: 80 }, () => (context: Context) => {
if (context.systemPrompt?.startsWith("Summarize the prior conversation")) {
summaries++;
// The user hits Ctrl+C while the summary is being written.
if (abortDuringSummary) bundle.agent.abort();
return fauxAssistantMessage(`summary #${summaries}`);
}
const first = context.messages[0];
firstMessages.push(typeof first?.content === "string" ? first.content : JSON.stringify(first?.content));
return fauxAssistantMessage("ok");
}),
);
for (let i = 0; i < 9; i++) await bundle.agent.prompt(`big ${i} ${"x ".repeat(300)}`);
abortDuringSummary = true;
await bundle.agent.prompt(`big 9 ${"x ".repeat(300)}`).catch(() => undefined);

expect(summaries).toBe(1);
expect(bundle.sessions.load(faux.getModel().id)?.compaction).toBeUndefined();

abortDuringSummary = false;
firstMessages.length = 0;
const resumed = createAgent({ ...opts, resume: true });
await resumed.agent.prompt("after restart");
// A fresh compaction may run after resume, but the aborted summary (#1) is never reused.
expect(firstMessages[0]).not.toContain("summary #1");
});

it("compaction monitor stays inactive when transcript is short", async () => {
faux.setResponses([fauxAssistantMessage("short reply")]);
const bundle = createAgent({
Expand Down
25 changes: 22 additions & 3 deletions src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CredentialsStore } from "../auth/credentials.js";
import { TokenManager } from "../auth/token-manager.js";
import { CheckpointStore } from "../checkpoint/store.js";
import { CompactionEngine } from "../compaction/engine.js";
import { CompactionMemo } from "../compaction/memo.js";
import { CompactionMonitor } from "../compaction/monitor.js";
import { getOutputStyle } from "../config/output-styles.js";
import { ConfigStore } from "../config/store.js";
Expand Down Expand Up @@ -238,8 +239,10 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
// them at 128k and trigger compaction at ~96k on a 200k-context route.
const compaction = new CompactionEngine({ glue, modelId: model.id, contextWindow: model.contextWindow });
const compactionMonitor = new CompactionMonitor();
const compactionMemo = new CompactionMemo();
const sessions = new SessionStore({ cwd });
const resumed = opts.sessionId ? sessions.loadById(opts.sessionId) : opts.resume ? sessions.load(model.id) : null;
if (!opts.initialMessages) compactionMemo.restore(resumed?.compaction, resumed?.messages ?? []);
const memory = new MemoryStore({ cwd, sourceSessionId: sessions.id });

// Background memory extraction: a cheap-model pass mines settled turns
Expand Down Expand Up @@ -424,13 +427,16 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
getApiKey,
streamFn,
transformContext: async (messages, signal) => {
if (!compaction.needsCompaction(messages)) return withRelevantMemoryReminder(memory, messages);
// Reuse the last summary for history it already covers; only the
// newer messages count toward the threshold.
const view = compactionMemo.apply(messages);
if (!compaction.needsCompaction(view)) return withRelevantMemoryReminder(memory, view);

// Stage 1 — microcompaction: clear stale tool-result content
// (old reads, grep dumps, command output) without a summary
// round-trip. Cheap. If that alone drops us back under the
// threshold, we're done and skip the expensive summarize path.
const micro = compaction.microcompact(messages);
const micro = compaction.microcompact(view);
if (micro.clearedCount > 0 && !compaction.needsCompaction(micro.messages)) {
return withRelevantMemoryReminder(memory, micro.messages);
}
Expand All @@ -447,6 +453,16 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
signal,
);
const result = await compaction.compact(working, signal);
if (result.details.summaryFailed || signal?.aborted) {
// Never cache or persist a failed summary. Keep the full context
// while it still fits (the next request retries); truncate only
// when it can't be sent at all.
return withRelevantMemoryReminder(
memory,
compaction.fitsContextWindow(working) ? working : result.messages,
);
}
compactionMemo.record(messages, view, result.messages, result.details.collapsedMessageCount);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not memoize a failed compaction as a valid summary

CompactionEngine.summarize() catches upstream failures and returns the literal (summarization failed: ...; transcript truncated without a summary) message. This unconditional record() now caches and persists that fallback as though summarization succeeded. I reproduced this with 12 messages, a 2,048-token context, keepRecent=2, and glue.smart throwing: after save/load, the model view has only the failure message/ack and two recent messages, the earlier user constraint is absent, and needsCompaction(view) is false, so the next request does not retry. The raw transcript is still stored, but the model keeps receiving the failed summary even after restart. Only commit a memo after successful, non-aborted summarization; preserve/retry the original context on failure. Please cover the failure + resume path.

await hooks.dispatch(
"PostCompact",
{
Expand Down Expand Up @@ -522,12 +538,15 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
if (event.type !== "agent_end") return;
if (opts.persistSession === false) return;
try {
const messages = event.messages.length > 0 ? event.messages : (resumed?.messages ?? []);
// event.messages holds only this run's new messages; saving those
// overwrote the session with just the latest exchange.
const messages = agent.state.messages;
if (messages.length === 0) return;
sessions.save({
modelId: model.id,
title: resumed?.title ?? null,
messages,
compaction: compactionMemo.snapshot(),
usage: resumed?.usage ?? {
input: 0,
output: 0,
Expand Down
29 changes: 28 additions & 1 deletion src/agent/visible-messages.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import { describe, expect, it } from "vitest";
import { stripRuntimeMarkup, visibleMessages } from "./visible-messages.js";
import { recentMessages, stripRuntimeMarkup, visibleMessages } from "./visible-messages.js";

describe("stripRuntimeMarkup", () => {
it("removes system reminders and DSML framing", () => {
Expand Down Expand Up @@ -31,3 +31,30 @@ describe("visibleMessages", () => {
expect(visible[1]).toMatchObject({ role: "toolResult", toolCallId: "a" });
});
});

describe("recentMessages", () => {
const user = (n: number) => ({ role: "user", content: `u${n}`, timestamp: n }) as AgentMessage;
const tool = (n: number): AgentMessage => ({
role: "toolResult",
toolCallId: `t${n}`,
toolName: "read_file",
content: [],
isError: false,
timestamp: n,
});

it("returns everything under the limit", () => {
const msgs = [user(1), tool(2)];
expect(recentMessages(msgs, 5)).toBe(msgs);
});

it("starts the cut at a user turn so it never opens on a tool result", () => {
const msgs = [user(1), tool(2), tool(3), user(4), tool(5)];
expect(recentMessages(msgs, 3).map((m) => m.timestamp)).toEqual([4, 5]);
});

it("falls back to the plain cut when one turn exceeds the limit", () => {
const msgs = [user(1), tool(2), tool(3), tool(4)];
expect(recentMessages(msgs, 2).map((m) => m.timestamp)).toEqual([3, 4]);
});
});
14 changes: 14 additions & 0 deletions src/agent/visible-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ export function visibleMessages(messages: AgentMessage[]): AgentMessage[] {
return out;
}

/**
* The last `limit` messages for display, widened back to the start of
* that user turn so the transcript never opens on a stray tool result.
* Falls back to the plain cut when that turn alone exceeds the limit.
*/
export function recentMessages(messages: AgentMessage[], limit: number): AgentMessage[] {
if (messages.length <= limit) return messages;
const cut = messages.length - limit;
for (let i = cut; i < messages.length; i++) {
if (messages[i].role === "user") return messages.slice(i);
}
return messages.slice(cut);
}

function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
Expand Down
4 changes: 4 additions & 0 deletions src/commands/builtins/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export const compact: Command = {
ctx.emit("nothing to compact yet — transcript is short.");
return { handled: true };
}
if (result.details.summaryFailed) {
ctx.emit(`compaction failed, transcript left unchanged: ${result.details.summary}`);
return { handled: true };
}
ctx.bundle.agent.state.messages = result.messages;
ctx.emit(
`compacted ${result.details.collapsedMessageCount} messages (~${result.details.truncatedTokens} tokens). ` +
Expand Down
Loading
Loading