fix: add keepLastMessages option to preserve recent user/assistant pairs during compaction
This commit is contained in:
parent
959ddae612
commit
89080816b2
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export type CompactionSafeguardRuntimeValue = {
|
||||
maxHistoryShare?: number;
|
||||
keepLastMessages?: number;
|
||||
};
|
||||
|
||||
// Session-scoped runtime registry keyed by object identity.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user