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`
|
`agents.defaults.compaction.reserveTokensFloor` enforces a minimum `reserveTokens`
|
||||||
value for Pi compaction (default: `20000`). Set it to `0` to disable the floor.
|
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
|
`agents.defaults.compaction.memoryFlush` runs a **silent** agentic turn before
|
||||||
auto-compaction, instructing the model to store durable memories on disk (e.g.
|
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
|
`memory/YYYY-MM-DD.md`). It triggers when the session token estimate crosses a
|
||||||
@ -1853,6 +1858,7 @@ Example (tuned):
|
|||||||
compaction: {
|
compaction: {
|
||||||
mode: "safeguard",
|
mode: "safeguard",
|
||||||
reserveTokensFloor: 24000,
|
reserveTokensFloor: 24000,
|
||||||
|
keepLastMessages: 3, // Preserve last 3 user/assistant pairs
|
||||||
memoryFlush: {
|
memoryFlush: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
softThresholdTokens: 6000,
|
softThresholdTokens: 6000,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
estimateMessagesTokens,
|
estimateMessagesTokens,
|
||||||
pruneHistoryForContextShare,
|
pruneHistoryForContextShare,
|
||||||
|
splitMessagesForPreservation,
|
||||||
splitMessagesByTokenShare,
|
splitMessagesByTokenShare,
|
||||||
} from "./compaction.js";
|
} from "./compaction.js";
|
||||||
|
|
||||||
@ -147,3 +148,121 @@ describe("pruneHistoryForContextShare", () => {
|
|||||||
expect(pruned.messages.length).toBe(1);
|
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 {
|
export function resolveContextWindowTokens(model?: ExtensionContext["model"]): number {
|
||||||
return Math.max(1, Math.floor(model?.contextWindow ?? DEFAULT_CONTEXT_TOKENS));
|
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;
|
const compactionCfg = params.cfg?.agents?.defaults?.compaction;
|
||||||
setCompactionSafeguardRuntime(params.sessionManager, {
|
setCompactionSafeguardRuntime(params.sessionManager, {
|
||||||
maxHistoryShare: compactionCfg?.maxHistoryShare,
|
maxHistoryShare: compactionCfg?.maxHistoryShare,
|
||||||
|
keepLastMessages: compactionCfg?.keepLastMessages,
|
||||||
});
|
});
|
||||||
paths.push(resolvePiExtensionPath("compaction-safeguard"));
|
paths.push(resolvePiExtensionPath("compaction-safeguard"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export type CompactionSafeguardRuntimeValue = {
|
export type CompactionSafeguardRuntimeValue = {
|
||||||
maxHistoryShare?: number;
|
maxHistoryShare?: number;
|
||||||
|
keepLastMessages?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Session-scoped runtime registry keyed by object identity.
|
// Session-scoped runtime registry keyed by object identity.
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
isOversizedForSummary,
|
isOversizedForSummary,
|
||||||
pruneHistoryForContextShare,
|
pruneHistoryForContextShare,
|
||||||
resolveContextWindowTokens,
|
resolveContextWindowTokens,
|
||||||
|
splitMessagesForPreservation,
|
||||||
summarizeInStages,
|
summarizeInStages,
|
||||||
} from "../compaction.js";
|
} from "../compaction.js";
|
||||||
import { getCompactionSafeguardRuntime } from "./compaction-safeguard-runtime.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 runtime = getCompactionSafeguardRuntime(ctx.sessionManager);
|
||||||
const maxHistoryShare = runtime?.maxHistoryShare ?? 0.5;
|
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 =
|
const tokensBefore =
|
||||||
typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.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 () => {
|
it("defaults compaction mode to safeguard", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const configDir = path.join(home, ".clawdbot");
|
const configDir = path.join(home, ".clawdbot");
|
||||||
|
|||||||
@ -246,6 +246,8 @@ export type AgentCompactionConfig = {
|
|||||||
reserveTokensFloor?: number;
|
reserveTokensFloor?: number;
|
||||||
/** Max share of context window for history during safeguard pruning (0.1–0.9, default 0.5). */
|
/** Max share of context window for history during safeguard pruning (0.1–0.9, default 0.5). */
|
||||||
maxHistoryShare?: number;
|
maxHistoryShare?: number;
|
||||||
|
/** Preserve the last N user/assistant message pairs before compaction (default 0). */
|
||||||
|
keepLastMessages?: number;
|
||||||
/** Pre-compaction memory flush (agentic turn). Default: enabled. */
|
/** Pre-compaction memory flush (agentic turn). Default: enabled. */
|
||||||
memoryFlush?: AgentCompactionMemoryFlushConfig;
|
memoryFlush?: AgentCompactionMemoryFlushConfig;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -91,6 +91,7 @@ export const AgentDefaultsSchema = z
|
|||||||
mode: z.union([z.literal("default"), z.literal("safeguard")]).optional(),
|
mode: z.union([z.literal("default"), z.literal("safeguard")]).optional(),
|
||||||
reserveTokensFloor: z.number().int().nonnegative().optional(),
|
reserveTokensFloor: z.number().int().nonnegative().optional(),
|
||||||
maxHistoryShare: z.number().min(0.1).max(0.9).optional(),
|
maxHistoryShare: z.number().min(0.1).max(0.9).optional(),
|
||||||
|
keepLastMessages: z.number().int().nonnegative().optional(),
|
||||||
memoryFlush: z
|
memoryFlush: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user