diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 9c850e070..332a8581d 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1833,6 +1833,11 @@ See [/concepts/session-pruning](/concepts/session-pruning) for behavior details. `agents.defaults.compaction.reserveTokensFloor` enforces a minimum `reserveTokens` value for Pi compaction (default: `20000`). Set it to `0` to disable the floor. +`agents.defaults.compaction.keepLastMessages` preserves the last N user/assistant +message pairs from being summarized during compaction (default: `0`). This ensures +recent conversational context remains intact even if summarization fails or is incomplete. +Only applies when `mode: "safeguard"`. + `agents.defaults.compaction.memoryFlush` runs a **silent** agentic turn before auto-compaction, instructing the model to store durable memories on disk (e.g. `memory/YYYY-MM-DD.md`). It triggers when the session token estimate crosses a @@ -1853,6 +1858,7 @@ Example (tuned): compaction: { mode: "safeguard", reserveTokensFloor: 24000, + keepLastMessages: 3, // Preserve last 3 user/assistant pairs memoryFlush: { enabled: true, softThresholdTokens: 6000, diff --git a/src/agents/compaction.test.ts b/src/agents/compaction.test.ts index 32511a586..e04ebcb52 100644 --- a/src/agents/compaction.test.ts +++ b/src/agents/compaction.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { estimateMessagesTokens, pruneHistoryForContextShare, + splitMessagesForPreservation, splitMessagesByTokenShare, } from "./compaction.js"; @@ -147,3 +148,121 @@ describe("pruneHistoryForContextShare", () => { expect(pruned.messages.length).toBe(1); }); }); + +describe("splitMessagesForPreservation", () => { + function makeUser(text: string): AgentMessage { + return { role: "user", content: text, timestamp: Date.now() }; + } + + function makeAssistant(text: string): AgentMessage { + return { role: "assistant", content: [{ type: "text", text }], timestamp: Date.now() }; + } + + it("preserves last N user/assistant pairs", () => { + const messages: AgentMessage[] = [ + makeUser("u1"), + makeAssistant("a1"), + makeUser("u2"), + makeAssistant("a2"), + makeUser("u3"), + makeAssistant("a3"), + ]; + + const result = splitMessagesForPreservation({ messages, keepLastMessages: 2 }); + + expect(result.preservedPairs).toBe(2); + expect(result.toPreserve.length).toBe(4); // 2 pairs = 4 messages + expect(result.toSummarize.length).toBe(2); // First pair + expect(result.toSummarize[0]).toBe(messages[0]); // u1 + expect(result.toSummarize[1]).toBe(messages[1]); // a1 + expect(result.toPreserve[0]).toBe(messages[2]); // u2 + expect(result.toPreserve[3]).toBe(messages[5]); // a3 + }); + + it("returns all messages to summarize when keepLastMessages is 0", () => { + const messages: AgentMessage[] = [ + makeUser("u1"), + makeAssistant("a1"), + makeUser("u2"), + makeAssistant("a2"), + ]; + + const result = splitMessagesForPreservation({ messages, keepLastMessages: 0 }); + + expect(result.preservedPairs).toBe(0); + expect(result.toPreserve.length).toBe(0); + expect(result.toSummarize).toBe(messages); + }); + + it("preserves all messages when keepLastMessages exceeds available pairs", () => { + const messages: AgentMessage[] = [ + makeUser("u1"), + makeAssistant("a1"), + makeUser("u2"), + makeAssistant("a2"), + ]; + + const result = splitMessagesForPreservation({ messages, keepLastMessages: 10 }); + + expect(result.preservedPairs).toBe(2); + expect(result.toPreserve.length).toBe(4); + expect(result.toSummarize.length).toBe(0); + }); + + it("handles incomplete pairs by excluding trailing incomplete messages", () => { + const messages: AgentMessage[] = [ + makeUser("u1"), + makeAssistant("a1"), + makeUser("u2"), + makeAssistant("a2"), + makeUser("u3"), // Incomplete pair - no assistant response yet + ]; + + const result = splitMessagesForPreservation({ messages, keepLastMessages: 2 }); + + // Should preserve both complete pairs (u1+a1, u2+a2), and include the trailing u3 + // because it comes after the preserved pairs + expect(result.preservedPairs).toBe(2); + expect(result.toPreserve.length).toBe(5); // u1, a1, u2, a2, u3 + expect(result.toSummarize.length).toBe(0); + }); + + it("handles messages with tool results between user and assistant", () => { + const messages: AgentMessage[] = [ + makeUser("u1"), + makeAssistant("a1"), + { role: "toolResult", toolCallId: "t1", toolName: "test", timestamp: Date.now() } as AgentMessage, + makeUser("u2"), + makeAssistant("a2"), + ]; + + const result = splitMessagesForPreservation({ messages, keepLastMessages: 1 }); + + // Should preserve last complete user/assistant pair (u2, a2) + expect(result.preservedPairs).toBe(1); + expect(result.toPreserve.length).toBe(2); + expect(result.toPreserve[0]).toBe(messages[3]); // u2 + expect(result.toPreserve[1]).toBe(messages[4]); // a2 + }); + + it("returns empty arrays for empty messages", () => { + const result = splitMessagesForPreservation({ messages: [], keepLastMessages: 3 }); + + expect(result.preservedPairs).toBe(0); + expect(result.toPreserve.length).toBe(0); + expect(result.toSummarize.length).toBe(0); + }); + + it("handles negative keepLastMessages", () => { + const messages: AgentMessage[] = [ + makeUser("u1"), + makeAssistant("a1"), + ]; + + const result = splitMessagesForPreservation({ messages, keepLastMessages: -1 }); + + expect(result.preservedPairs).toBe(0); + expect(result.toPreserve.length).toBe(0); + expect(result.toSummarize).toBe(messages); + }); +}); diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index a88447307..7b047569b 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -340,6 +340,86 @@ export function pruneHistoryForContextShare(params: { }; } +/** + * Split messages into two groups: messages to summarize and recent pairs to preserve. + * Preserves the last N user/assistant message pairs in their entirety. + */ +export function splitMessagesForPreservation(params: { + messages: AgentMessage[]; + keepLastMessages: number; +}): { + toSummarize: AgentMessage[]; + toPreserve: AgentMessage[]; + preservedPairs: number; +} { + const { messages, keepLastMessages } = params; + + if (keepLastMessages <= 0 || messages.length === 0) { + return { + toSummarize: messages, + toPreserve: [], + preservedPairs: 0, + }; + } + + // Walk backwards to find complete user/assistant pairs + let pairsFound = 0; + let splitIndex = messages.length; + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (!msg || typeof msg !== "object") continue; + + const role = (msg as { role?: unknown }).role; + + // When we find an assistant message, look for its preceding user message + if (role === "assistant") { + // Search backwards from current position for the matching user message + let userIndex = -1; + for (let j = i - 1; j >= 0; j--) { + const prevMsg = messages[j]; + if (prevMsg && typeof prevMsg === "object") { + const prevRole = (prevMsg as { role?: unknown }).role; + if (prevRole === "user") { + userIndex = j; + break; + } + } + } + + // If we found a user message, we have a complete pair + if (userIndex !== -1) { + pairsFound++; + splitIndex = userIndex; // Update split index to start of this pair + + if (pairsFound >= keepLastMessages) { + // We've found enough pairs + break; + } + + // Continue from before the user message + i = userIndex; + } + } + } + + // If we found pairs, split at the boundary + if (pairsFound > 0) { + return { + toSummarize: messages.slice(0, splitIndex), + toPreserve: messages.slice(splitIndex), + preservedPairs: pairsFound, + }; + } + + // No complete pairs found, summarize everything + return { + toSummarize: messages, + toPreserve: [], + preservedPairs: 0, + }; +} + export function resolveContextWindowTokens(model?: ExtensionContext["model"]): number { return Math.max(1, Math.floor(model?.contextWindow ?? DEFAULT_CONTEXT_TOKENS)); } diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts index bb592e930..7e2be54c1 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/pi-embedded-runner/extensions.ts @@ -79,6 +79,7 @@ export function buildEmbeddedExtensionPaths(params: { const compactionCfg = params.cfg?.agents?.defaults?.compaction; setCompactionSafeguardRuntime(params.sessionManager, { maxHistoryShare: compactionCfg?.maxHistoryShare, + keepLastMessages: compactionCfg?.keepLastMessages, }); paths.push(resolvePiExtensionPath("compaction-safeguard")); } diff --git a/src/agents/pi-extensions/compaction-safeguard-runtime.ts b/src/agents/pi-extensions/compaction-safeguard-runtime.ts index f42cf7abe..aa9b6ae68 100644 --- a/src/agents/pi-extensions/compaction-safeguard-runtime.ts +++ b/src/agents/pi-extensions/compaction-safeguard-runtime.ts @@ -1,5 +1,6 @@ export type CompactionSafeguardRuntimeValue = { maxHistoryShare?: number; + keepLastMessages?: number; }; // Session-scoped runtime registry keyed by object identity. diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index b2fe39884..758f1aa57 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -9,6 +9,7 @@ import { isOversizedForSummary, pruneHistoryForContextShare, resolveContextWindowTokens, + splitMessagesForPreservation, summarizeInStages, } from "../compaction.js"; import { getCompactionSafeguardRuntime } from "./compaction-safeguard-runtime.js"; @@ -177,6 +178,26 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { const runtime = getCompactionSafeguardRuntime(ctx.sessionManager); const maxHistoryShare = runtime?.maxHistoryShare ?? 0.5; + const keepLastMessages = + typeof runtime?.keepLastMessages === "number" && runtime.keepLastMessages >= 0 + ? Math.floor(runtime.keepLastMessages) + : 0; + + // Split messages: preserve last N pairs, summarize the rest + const preservation = splitMessagesForPreservation({ + messages: messagesToSummarize, + keepLastMessages, + }); + + if (preservation.preservedPairs > 0) { + console.log( + `Compaction: preserving last ${preservation.preservedPairs} user/assistant pair(s) ` + + `(${preservation.toPreserve.length} messages) from summarization.`, + ); + } + + // Only summarize messages that aren't preserved + messagesToSummarize = preservation.toSummarize; const tokensBefore = typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore) diff --git a/src/config/config.compaction-settings.test.ts b/src/config/config.compaction-settings.test.ts index e85ea3aeb..769a22dcf 100644 --- a/src/config/config.compaction-settings.test.ts +++ b/src/config/config.compaction-settings.test.ts @@ -46,6 +46,38 @@ describe("config compaction settings", () => { }); }); + it("preserves keepLastMessages config value", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + agents: { + defaults: { + compaction: { + mode: "safeguard", + keepLastMessages: 3, + }, + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + + expect(cfg.agents?.defaults?.compaction?.mode).toBe("safeguard"); + expect(cfg.agents?.defaults?.compaction?.keepLastMessages).toBe(3); + }); + }); + it("defaults compaction mode to safeguard", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdbot"); diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 9c6ce0211..a85912387 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -246,6 +246,8 @@ export type AgentCompactionConfig = { reserveTokensFloor?: number; /** Max share of context window for history during safeguard pruning (0.1–0.9, default 0.5). */ maxHistoryShare?: number; + /** Preserve the last N user/assistant message pairs before compaction (default 0). */ + keepLastMessages?: number; /** Pre-compaction memory flush (agentic turn). Default: enabled. */ memoryFlush?: AgentCompactionMemoryFlushConfig; }; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index a849078ed..0648596fc 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -91,6 +91,7 @@ export const AgentDefaultsSchema = z mode: z.union([z.literal("default"), z.literal("safeguard")]).optional(), reserveTokensFloor: z.number().int().nonnegative().optional(), maxHistoryShare: z.number().min(0.1).max(0.9).optional(), + keepLastMessages: z.number().int().nonnegative().optional(), memoryFlush: z .object({ enabled: z.boolean().optional(),