fix: add keepLastMessages option to preserve recent user/assistant pairs during compaction

This commit is contained in:
Charlie 2026-01-27 03:54:27 +00:00
parent 959ddae612
commit 89080816b2
9 changed files with 263 additions and 0 deletions

View File

@ -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,

View File

@ -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);
});
});

View File

@ -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));
}

View File

@ -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"));
}

View File

@ -1,5 +1,6 @@
export type CompactionSafeguardRuntimeValue = {
maxHistoryShare?: number;
keepLastMessages?: number;
};
// Session-scoped runtime registry keyed by object identity.

View File

@ -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)

View File

@ -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");

View File

@ -246,6 +246,8 @@ export type AgentCompactionConfig = {
reserveTokensFloor?: number;
/** Max share of context window for history during safeguard pruning (0.10.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;
};

View File

@ -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(),