fix(compaction): honor manual keepRecentTokens

This commit is contained in:
Peter Steinberger
2026-04-25 04:02:57 +01:00
parent 92b17af817
commit 7920f8d4fd
15 changed files with 210 additions and 12 deletions

View File

@@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Compaction: honor explicit `agents.defaults.compaction.keepRecentTokens` for manual `/compact`, re-distill safeguard summaries instead of snowballing previous summaries, and enable safeguard summary quality checks by default. Fixes #71357. Thanks @WhiteGiverMa.
- Sessions: honor configured `session.maintenance` settings during load-time maintenance instead of falling back to default entry caps. Fixes #71356. Thanks @comolago.
- Browser/sandbox: pass the resolved `browser.ssrfPolicy` into sandbox browser bridges and refresh cached bridges when the effective policy changes, so sandboxed browser navigation honors private-network opt-ins. Fixes #45153 and #57055. Thanks @jzakirov, @zuoanCo, and @kybrcore.
- Browser/proxy: keep Gateway/provider proxy environment variables from proxying the OpenClaw-managed browser, so `HTTP_PROXY` and `HTTPS_PROXY` no longer block ordinary browser navigation. Fixes #71358. Thanks @Sanjays2402.

View File

@@ -1,4 +1,4 @@
8f23e853ccde6cd021b84b32fe205f456f8516667683d16c9b56d6598f608989 config-baseline.json
037bf4a873587adb8349f531c0ad79cd4f90e01712f5aa5d8b4387be73538a7f config-baseline.core.json
71ef32b7723f64d4a84ac43bb6d41ff21e0d77a099b42e026d8b0d3d5301f917 config-baseline.json
cfab1910132ed23777005e0c650a13f44626b0450963f733e9de56a13323ae2b config-baseline.core.json
22d7cd6d8279146b2d79c9531a55b80b52a2c99c81338c508104729154fdd02d config-baseline.channel.json
86f615b7d267b03888af0af7ccb3f8232a6b636f8a741d522ff425e46729ba81 config-baseline.plugin.json

View File

@@ -113,6 +113,11 @@ the summary:
/compact Focus on the API design decisions
```
When `agents.defaults.compaction.keepRecentTokens` is set, manual compaction
honors that Pi cut-point and keeps the recent tail in rebuilt context. Without
an explicit keep budget, manual compaction behaves as a hard checkpoint and
continues from the new summary alone.
## Using a different model
By default, compaction uses your agent's primary model. You can use a more

View File

@@ -542,8 +542,10 @@ Periodic heartbeat runs.
provider: "my-provider", // id of a registered compaction provider plugin (optional)
timeoutSeconds: 900,
reserveTokensFloor: 24000,
keepRecentTokens: 50000,
identifierPolicy: "strict", // strict | off | custom
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
qualityGuard: { enabled: true, maxRetries: 1 },
postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection
model: "openrouter/anthropic/claude-sonnet-4-6", // optional compaction-only model override
notifyUser: true, // send brief notices when compaction starts and completes (default: false)
@@ -562,8 +564,10 @@ Periodic heartbeat runs.
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
- `provider`: id of a registered compaction provider plugin. When set, the provider's `summarize()` is called instead of built-in LLM summarization. Falls back to built-in on failure. Setting a provider forces `mode: "safeguard"`. See [Compaction](/concepts/compaction).
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`.
- `keepRecentTokens`: Pi cut-point budget for keeping the most recent transcript tail verbatim. Manual `/compact` honors this when explicitly set; otherwise manual compaction is a hard checkpoint.
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
- `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit.
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `notifyUser`: when `true`, sends brief notices to the user when compaction starts and when it completes (for example, "Compacting context..." and "Compaction complete"). Disabled by default to keep compaction silent.

View File

@@ -267,6 +267,10 @@ OpenClaw also enforces a safety floor for embedded runs:
- Default floor is `20000` tokens.
- Set `agents.defaults.compaction.reserveTokensFloor: 0` to disable the floor.
- If its already higher, OpenClaw leaves it alone.
- Manual `/compact` honors an explicit `agents.defaults.compaction.keepRecentTokens`
and keeps Pi's recent-tail cut point. Without an explicit keep budget,
manual compaction remains a hard checkpoint and rebuilt context starts from
the new summary.
Why: leave enough headroom for multi-turn “housekeeping” (like memory writes) before compaction becomes unavoidable.
@@ -283,6 +287,10 @@ Plugins can register a compaction provider via `registerCompactionProvider()` on
- Setting a `provider` forces `mode: "safeguard"`.
- Providers receive the same compaction instructions and identifier-preservation policy as the built-in path.
- The safeguard still preserves recent-turn and split-turn suffix context after provider output.
- Built-in safeguard summarization re-distills prior summaries with new messages
instead of preserving the full previous summary verbatim.
- Safeguard mode enables summary quality audits by default; set
`qualityGuard.enabled: false` to skip retry-on-malformed-output behavior.
- If the provider fails or returns an empty result, OpenClaw falls back to built-in LLM summarization automatically.
- Abort/timeout signals are re-thrown (not swallowed) to respect caller cancellation.

View File

@@ -1059,6 +1059,8 @@ export async function compactEmbeddedPiSessionDirect(
try {
const hardenedBoundary = await hardenManualCompactionBoundary({
sessionFile: params.sessionFile,
preserveRecentTail:
typeof params.config?.agents?.defaults?.compaction?.keepRecentTokens === "number",
});
if (hardenedBoundary.applied) {
effectiveFirstKeptEntryId =

View File

@@ -45,7 +45,7 @@ function expectSafeguardRuntime(
}
describe("buildEmbeddedExtensionFactories", () => {
it("does not opt safeguard mode into quality-guard retries", () => {
it("enables quality-guard retries by default in safeguard mode", () => {
const cfg = {
agents: {
defaults: {
@@ -55,6 +55,24 @@ describe("buildEmbeddedExtensionFactories", () => {
},
},
} as OpenClawConfig;
expectSafeguardRuntime(cfg, {
qualityGuardEnabled: true,
});
});
it("honors explicit safeguard quality-guard disablement", () => {
const cfg = {
agents: {
defaults: {
compaction: {
mode: "safeguard",
qualityGuard: {
enabled: false,
},
},
},
},
} as OpenClawConfig;
expectSafeguardRuntime(cfg, {
qualityGuardEnabled: false,
});

View File

@@ -162,7 +162,7 @@ export function buildEmbeddedExtensionFactories(params: {
identifierPolicy: compactionCfg?.identifierPolicy,
identifierInstructions: compactionCfg?.identifierInstructions,
customInstructions: compactionCfg?.customInstructions,
qualityGuardEnabled: qualityGuardCfg?.enabled ?? false,
qualityGuardEnabled: qualityGuardCfg?.enabled ?? true,
qualityGuardMaxRetries: qualityGuardCfg?.maxRetries,
model: params.model,
recentTurnsPreserve: compactionCfg?.recentTurnsPreserve,

View File

@@ -118,6 +118,39 @@ describe("hardenManualCompactionBoundary", () => {
expect(afterTexts.join("\n")).not.toContain("detailed new answer");
});
it("keeps the upstream recent tail when requested", async () => {
const dir = await makeTmpDir();
const session = SessionManager.create(dir, dir);
session.appendMessage({ role: "user", content: "old question", timestamp: 1 });
session.appendMessage(createAssistantTextMessage("old answer", 2));
const keepId = session.getBranch().at(-1)?.id;
expect(keepId).toBeTruthy();
const latestCompactionId = session.appendCompaction("fresh summary", keepId!, 200);
const sessionFile = session.getSessionFile();
expect(sessionFile).toBeTruthy();
const hardened = await hardenManualCompactionBoundary({
sessionFile: sessionFile!,
preserveRecentTail: true,
});
expect(hardened.applied).toBe(false);
expect(hardened.firstKeptEntryId).toBe(keepId);
const reopened = SessionManager.open(sessionFile!);
const latest = reopened.getLeafEntry();
expect(latest?.type).toBe("compaction");
if (!latest || latest.type !== "compaction") {
throw new Error("expected latest leaf to be a compaction entry");
}
expect(latest.id).toBe(latestCompactionId);
expect(latest.firstKeptEntryId).toBe(keepId);
expect(reopened.buildSessionContext().messages.map((message) => message.role)).toEqual([
"compactionSummary",
"assistant",
]);
});
it("is a no-op when the latest leaf is not a compaction entry", async () => {
const dir = await makeTmpDir();
const session = SessionManager.create(dir, dir);

View File

@@ -40,6 +40,7 @@ function replaceLatestCompactionBoundary(params: {
export async function hardenManualCompactionBoundary(params: {
sessionFile: string;
preserveRecentTail?: boolean;
}): Promise<HardenedManualCompactionBoundary> {
const sessionManager = SessionManager.open(params.sessionFile) as Partial<SessionManagerLike>;
if (
@@ -68,6 +69,19 @@ export async function hardenManualCompactionBoundary(params: {
};
}
if (params.preserveRecentTail) {
const sessionContext = sessionManager.buildSessionContext();
return {
applied: false,
firstKeptEntryId: leaf.firstKeptEntryId,
leafId:
typeof sessionManager.getLeafId === "function"
? (sessionManager.getLeafId() ?? undefined)
: undefined,
messages: sessionContext.messages,
};
}
if (leaf.firstKeptEntryId === leaf.id) {
const sessionContext = sessionManager.buildSessionContext();
return {

View File

@@ -60,6 +60,7 @@ export function buildCompactionStructureInstructions(
...REQUIRED_SUMMARY_SECTIONS,
identifierSectionInstruction,
"Do not omit unresolved asks from the user.",
"When prior compaction summaries are present, re-distill them with new messages and remove stale duplicate detail.",
].join("\n");
const custom = customInstructions?.trim();
if (!custom) {

View File

@@ -38,6 +38,7 @@ const {
formatPreservedTurnsSection,
buildCompactionStructureInstructions,
buildStructuredFallbackSummary,
prependPreviousSummaryForRedistill,
appendSummarySection,
resolveRecentTurnsPreserve,
resolveQualityGuardMaxRetries,
@@ -1198,6 +1199,20 @@ describe("compaction-safeguard recent-turn preservation", () => {
expect(buildStructuredFallbackSummary(structured)).toBe(structured);
});
it("converts previous summaries into redistill input instead of update-prompt state", () => {
const messages: AgentMessage[] = [{ role: "user", content: "new context", timestamp: 1 }];
const redistillMessages = prependPreviousSummaryForRedistill({
messages,
previousSummary: "## Goal\nold duplicate summary",
});
expect(redistillMessages).toHaveLength(2);
expect(redistillMessages[0]?.role).toBe("user");
expect(JSON.stringify(redistillMessages[0])).toContain("<previous-compaction-summary>");
expect(JSON.stringify(redistillMessages[0])).toContain("Prune stale, duplicate");
expect(redistillMessages[1]).toBe(messages[0]);
});
it("restructures summaries with near-match headings instead of reusing them", () => {
const nearMatch = [
"## Decisions",
@@ -1685,7 +1700,72 @@ describe("compaction-safeguard recent-turn preservation", () => {
expect(summary).toContain("legacy summary without headings");
});
it("re-distills prior summaries on the LLM path instead of preserving them verbatim", async () => {
mockSummarizeInStages.mockReset();
mockSummarizeInStages.mockResolvedValue(
[
"## Decisions",
"Condensed prior context with latest status.",
"## Open TODOs",
"None.",
"## Constraints/Rules",
"Preserve identifiers.",
"## Pending user asks",
"latest ask status",
"## Exact identifiers",
"None.",
].join("\n"),
);
const sessionManager = stubSessionManager();
const model = createAnthropicModelFixture();
setCompactionSafeguardRuntime(sessionManager, {
model,
recentTurnsPreserve: 0,
qualityGuardEnabled: true,
qualityGuardMaxRetries: 1,
});
const compactionHandler = createCompactionHandler();
const getApiKeyMock = vi.fn().mockResolvedValue("test-key");
const mockContext = createCompactionContext({
sessionManager,
getApiKeyMock,
});
const event = {
preparation: {
messagesToSummarize: [{ role: "user", content: "latest ask status", timestamp: 1 }],
turnPrefixMessages: [],
firstKeptEntryId: "entry-1",
tokensBefore: 1_500,
fileOps: {
read: [],
edited: [],
written: [],
},
settings: { reserveTokens: 4_000 },
previousSummary: "## Goal\nOld duplicated section that should be re-distilled.",
isSplitTurn: false,
},
customInstructions: "",
signal: new AbortController().signal,
};
const result = (await compactionHandler(event, mockContext)) as {
cancel?: boolean;
compaction?: { summary?: string };
};
expect(result.cancel).not.toBe(true);
expect(mockSummarizeInStages).toHaveBeenCalledTimes(1);
const call = mockSummarizeInStages.mock.calls[0]?.[0];
expect(call?.previousSummary).toBeUndefined();
expect(JSON.stringify(call?.messages[0])).toContain("<previous-compaction-summary>");
expect(JSON.stringify(call?.messages[0])).toContain("Old duplicated section");
});
it("passes compaction instructions to providers and preserves suffix context", async () => {
mockSummarizeInStages.mockReset();
const providerSummarize = vi.fn().mockResolvedValue("provider summary body");
registerCompactionProvider({
id: "test-provider",

View File

@@ -67,10 +67,37 @@ const DEFAULT_QUALITY_GUARD_MAX_RETRIES = 1;
const MAX_RECENT_TURNS_PRESERVE = 12;
const MAX_QUALITY_GUARD_MAX_RETRIES = 3;
const MAX_RECENT_TURN_TEXT_CHARS = 600;
const PREVIOUS_SUMMARY_REDISTILL_PREFIX =
"Previous compaction summary to re-distill with the current conversation. " +
"Prune stale, duplicate, or superseded details instead of preserving it verbatim.";
const compactionSafeguardDeps = {
summarizeInStages,
};
function buildPreviousSummaryMessage(previousSummary: string): AgentMessage {
return {
role: "user",
content: [
{
type: "text",
text: `<previous-compaction-summary>\n${PREVIOUS_SUMMARY_REDISTILL_PREFIX}\n\n${previousSummary.trim()}\n</previous-compaction-summary>`,
},
],
timestamp: 0,
} as AgentMessage;
}
function prependPreviousSummaryForRedistill(params: {
messages: AgentMessage[];
previousSummary?: string;
}): AgentMessage[] {
const previousSummary = params.previousSummary?.trim();
if (!previousSummary) {
return params.messages;
}
return [buildPreviousSummaryMessage(previousSummary), ...params.messages];
}
/**
* Attempt provider-based summarization. Returns the summary string on success,
* or `undefined` when the caller should fall back to built-in LLM summarization.
@@ -125,8 +152,12 @@ async function summarizeViaLLM(params: {
summarizationInstructions?: Parameters<typeof summarizeInStages>[0]["summarizationInstructions"];
previousSummary?: string;
}): Promise<string> {
return compactionSafeguardDeps.summarizeInStages({
const messages = prependPreviousSummaryForRedistill({
messages: params.messages,
previousSummary: params.previousSummary,
});
return compactionSafeguardDeps.summarizeInStages({
messages,
model: params.model,
apiKey: params.apiKey,
headers: params.headers,
@@ -136,7 +167,7 @@ async function summarizeViaLLM(params: {
contextWindow: params.contextWindow,
customInstructions: params.customInstructions,
summarizationInstructions: params.summarizationInstructions,
previousSummary: params.previousSummary,
previousSummary: undefined,
});
}
@@ -1166,6 +1197,7 @@ export const __testing = {
formatSplitTurnContextSection,
buildCompactionStructureInstructions,
buildStructuredFallbackSummary,
prependPreviousSummaryForRedistill,
appendSummarySection,
resolveRecentTurnsPreserve,
resolveQualityGuardMaxRetries,

View File

@@ -4722,7 +4722,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
type: "boolean",
title: "Compaction Quality Guard Enabled",
description:
"Enables summary quality audits and regeneration retries for safeguard compaction. Default: false, so safeguard mode alone does not turn on retry behavior.",
"Enables summary quality audits and regeneration retries for safeguard compaction. Default: true in safeguard mode.",
},
maxRetries: {
type: "integer",
@@ -4736,7 +4736,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
additionalProperties: false,
title: "Compaction Quality Guard",
description:
"Optional quality-audit retry settings for safeguard compaction summaries. Leave this disabled unless you explicitly want summary audits and one-shot regeneration on failed checks.",
"Quality-audit retry settings for safeguard compaction summaries. Safeguard mode enables this by default; set enabled: false to skip summary audits and regeneration.",
},
postIndexSync: {
type: "string",
@@ -26066,12 +26066,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
"agents.defaults.compaction.qualityGuard": {
label: "Compaction Quality Guard",
help: "Optional quality-audit retry settings for safeguard compaction summaries. Leave this disabled unless you explicitly want summary audits and one-shot regeneration on failed checks.",
help: "Quality-audit retry settings for safeguard compaction summaries. Safeguard mode enables this by default; set enabled: false to skip summary audits and regeneration.",
tags: ["advanced"],
},
"agents.defaults.compaction.qualityGuard.enabled": {
label: "Compaction Quality Guard Enabled",
help: "Enables summary quality audits and regeneration retries for safeguard compaction. Default: false, so safeguard mode alone does not turn on retry behavior.",
help: "Enables summary quality audits and regeneration retries for safeguard compaction. Default: true in safeguard mode.",
tags: ["advanced"],
},
"agents.defaults.compaction.qualityGuard.maxRetries": {

View File

@@ -1236,9 +1236,9 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.compaction.recentTurnsPreserve":
"Number of most recent user/assistant turns kept verbatim outside safeguard summarization (default: 3). Raise this to preserve exact recent dialogue context, or lower it to maximize compaction savings.",
"agents.defaults.compaction.qualityGuard":
"Optional quality-audit retry settings for safeguard compaction summaries. Leave this disabled unless you explicitly want summary audits and one-shot regeneration on failed checks.",
"Quality-audit retry settings for safeguard compaction summaries. Safeguard mode enables this by default; set enabled: false to skip summary audits and regeneration.",
"agents.defaults.compaction.qualityGuard.enabled":
"Enables summary quality audits and regeneration retries for safeguard compaction. Default: false, so safeguard mode alone does not turn on retry behavior.",
"Enables summary quality audits and regeneration retries for safeguard compaction. Default: true in safeguard mode.",
"agents.defaults.compaction.qualityGuard.maxRetries":
"Maximum number of regeneration retries after a failed safeguard summary quality audit. Use small values to bound extra latency and token cost.",
"agents.defaults.compaction.postIndexSync":