Skip to content

Commit 03b9aba

Browse files
efe-arvjalehman
andauthored
feat(compaction): make post-compaction context sections configurable (openclaw#34556)
Merged via squash. Prepared head SHA: 491bb28 Co-authored-by: efe-arv <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
1 parent 455430a commit 03b9aba

File tree

9 files changed

+256
-62
lines changed

9 files changed

+256
-62
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
2727
- CLI: make read-only SecretRef status flows degrade safely (#37023) thanks @joshavant.
2828
- Docker/Podman extension dependency baking: add `OPENCLAW_EXTENSIONS` so container builds can preinstall selected bundled extension npm dependencies into the image for faster and more reproducible startup in container deployments. (#32223) Thanks @sallyom.
2929
- Onboarding/web search: add provider selection step and full provider list in configure wizard, with SecretRef ref-mode support during onboarding. (#34009) Thanks @kesku and @thewilloftheshadow.
30+
- Agents/compaction post-context configurability: add `agents.defaults.compaction.postCompactionSections` so deployments can choose which `AGENTS.md` sections are re-injected after compaction, while preserving legacy fallback behavior when the documented default pair is configured in any order. (#34556) thanks @efe-arv.
3031

3132
### Breaking
3233

docs/gateway/configuration-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,7 @@ Periodic heartbeat runs.
10031003
reserveTokensFloor: 24000,
10041004
identifierPolicy: "strict", // strict | off | custom
10051005
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
1006+
postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection
10061007
memoryFlush: {
10071008
enabled: true,
10081009
softThresholdTokens: 6000,
@@ -1018,6 +1019,7 @@ Periodic heartbeat runs.
10181019
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
10191020
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
10201021
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
1022+
- `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.
10211023
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only.
10221024

10231025
### `agents.defaults.contextPruning`

src/auto-reply/reply/post-compaction-context.test.ts

Lines changed: 157 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -228,56 +228,162 @@ Read WORKFLOW.md on startup.
228228
expect(result).toContain("Current time:");
229229
});
230230

231-
it("falls back to legacy section names (Every Session / Safety)", async () => {
232-
const content = `# Rules
233-
234-
## Every Session
235-
236-
Read SOUL.md and USER.md.
237-
238-
## Safety
239-
240-
Don't exfiltrate private data.
241-
242-
## Other
243-
244-
Ignore this.
245-
`;
246-
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
247-
const result = await readPostCompactionContext(tmpDir);
248-
expect(result).not.toBeNull();
249-
expect(result).toContain("Every Session");
250-
expect(result).toContain("Read SOUL.md");
251-
expect(result).toContain("Safety");
252-
expect(result).toContain("Don't exfiltrate");
253-
expect(result).not.toContain("Other");
254-
});
255-
256-
it("prefers new section names over legacy when both exist", async () => {
257-
const content = `# Rules
258-
259-
## Session Startup
260-
261-
New startup instructions.
262-
263-
## Every Session
264-
265-
Old startup instructions.
266-
267-
## Red Lines
268-
269-
New red lines.
270-
271-
## Safety
272-
273-
Old safety rules.
274-
`;
275-
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
276-
const result = await readPostCompactionContext(tmpDir);
277-
expect(result).not.toBeNull();
278-
expect(result).toContain("New startup instructions");
279-
expect(result).toContain("New red lines");
280-
expect(result).not.toContain("Old startup instructions");
281-
expect(result).not.toContain("Old safety rules");
231+
// -------------------------------------------------------------------------
232+
// postCompactionSections config
233+
// -------------------------------------------------------------------------
234+
describe("agents.defaults.compaction.postCompactionSections", () => {
235+
it("uses default sections (Session Startup + Red Lines) when config is not set", async () => {
236+
const content = `## Session Startup\n\nDo startup.\n\n## Red Lines\n\nDo not break.\n\n## Other\n\nIgnore.\n`;
237+
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
238+
const result = await readPostCompactionContext(tmpDir);
239+
expect(result).toContain("Session Startup");
240+
expect(result).toContain("Red Lines");
241+
expect(result).not.toContain("Other");
242+
});
243+
244+
it("uses custom section names from config instead of defaults", async () => {
245+
const content = `## Session Startup\n\nDo startup.\n\n## Critical Rules\n\nMy custom rules.\n\n## Red Lines\n\nDefault section.\n`;
246+
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
247+
const cfg = {
248+
agents: {
249+
defaults: {
250+
compaction: { postCompactionSections: ["Critical Rules"] },
251+
},
252+
},
253+
} as OpenClawConfig;
254+
const result = await readPostCompactionContext(tmpDir, cfg);
255+
expect(result).not.toBeNull();
256+
expect(result).toContain("Critical Rules");
257+
expect(result).toContain("My custom rules");
258+
// Default sections must not be included when overridden
259+
expect(result).not.toContain("Do startup");
260+
expect(result).not.toContain("Default section");
261+
});
262+
263+
it("supports multiple custom section names", async () => {
264+
const content = `## Onboarding\n\nOnboard things.\n\n## Safety\n\nSafe things.\n\n## Noise\n\nIgnore.\n`;
265+
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
266+
const cfg = {
267+
agents: {
268+
defaults: {
269+
compaction: { postCompactionSections: ["Onboarding", "Safety"] },
270+
},
271+
},
272+
} as OpenClawConfig;
273+
const result = await readPostCompactionContext(tmpDir, cfg);
274+
expect(result).not.toBeNull();
275+
expect(result).toContain("Onboard things");
276+
expect(result).toContain("Safe things");
277+
expect(result).not.toContain("Ignore");
278+
});
279+
280+
it("returns null when postCompactionSections is explicitly set to [] (opt-out)", async () => {
281+
const content = `## Session Startup\n\nDo startup.\n\n## Red Lines\n\nDo not break.\n`;
282+
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
283+
const cfg = {
284+
agents: {
285+
defaults: {
286+
compaction: { postCompactionSections: [] },
287+
},
288+
},
289+
} as OpenClawConfig;
290+
const result = await readPostCompactionContext(tmpDir, cfg);
291+
// Empty array = opt-out: no post-compaction context injection
292+
expect(result).toBeNull();
293+
});
294+
295+
it("returns null when custom sections are configured but none found in AGENTS.md", async () => {
296+
const content = `## Session Startup\n\nDo startup.\n`;
297+
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
298+
const cfg = {
299+
agents: {
300+
defaults: {
301+
compaction: { postCompactionSections: ["Nonexistent Section"] },
302+
},
303+
},
304+
} as OpenClawConfig;
305+
const result = await readPostCompactionContext(tmpDir, cfg);
306+
expect(result).toBeNull();
307+
});
308+
309+
it("does NOT reference 'Session Startup' in prose when custom sections are configured", async () => {
310+
// Greptile review finding: hardcoded prose mentioned "Execute your Session Startup
311+
// sequence now" even when custom section names were configured, causing agents to
312+
// look for a non-existent section. Prose must adapt to the configured section names.
313+
const content = `## Boot Sequence\n\nDo custom boot things.\n`;
314+
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
315+
const cfg = {
316+
agents: {
317+
defaults: {
318+
compaction: { postCompactionSections: ["Boot Sequence"] },
319+
},
320+
},
321+
} as OpenClawConfig;
322+
const result = await readPostCompactionContext(tmpDir, cfg);
323+
expect(result).not.toBeNull();
324+
// Must not reference the hardcoded default section name
325+
expect(result).not.toContain("Session Startup");
326+
// Must reference the actual configured section names
327+
expect(result).toContain("Boot Sequence");
328+
});
329+
330+
it("uses default 'Session Startup' prose when default sections are active", async () => {
331+
const content = `## Session Startup\n\nDo startup.\n`;
332+
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
333+
const result = await readPostCompactionContext(tmpDir);
334+
expect(result).not.toBeNull();
335+
expect(result).toContain("Execute your Session Startup sequence now");
336+
});
337+
338+
it("falls back to legacy sections when defaults are explicitly configured", async () => {
339+
// Older AGENTS.md templates use "Every Session" / "Safety" instead of
340+
// "Session Startup" / "Red Lines". Explicitly setting the defaults should
341+
// still trigger the legacy fallback — same behavior as leaving the field unset.
342+
const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`;
343+
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
344+
const cfg = {
345+
agents: {
346+
defaults: {
347+
compaction: { postCompactionSections: ["Session Startup", "Red Lines"] },
348+
},
349+
},
350+
} as OpenClawConfig;
351+
const result = await readPostCompactionContext(tmpDir, cfg);
352+
expect(result).not.toBeNull();
353+
expect(result).toContain("Do startup things");
354+
expect(result).toContain("Be safe");
355+
});
356+
357+
it("falls back to legacy sections when default sections are configured in a different order", async () => {
358+
const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`;
359+
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
360+
const cfg = {
361+
agents: {
362+
defaults: {
363+
compaction: { postCompactionSections: ["Red Lines", "Session Startup"] },
364+
},
365+
},
366+
} as OpenClawConfig;
367+
const result = await readPostCompactionContext(tmpDir, cfg);
368+
expect(result).not.toBeNull();
369+
expect(result).toContain("Do startup things");
370+
expect(result).toContain("Be safe");
371+
expect(result).toContain("Execute your Session Startup sequence now");
372+
});
373+
374+
it("custom section names are matched case-insensitively", async () => {
375+
const content = `## WORKFLOW INIT\n\nInit things.\n`;
376+
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
377+
const cfg = {
378+
agents: {
379+
defaults: {
380+
compaction: { postCompactionSections: ["workflow init"] },
381+
},
382+
},
383+
} as OpenClawConfig;
384+
const result = await readPostCompactionContext(tmpDir, cfg);
385+
expect(result).not.toBeNull();
386+
expect(result).toContain("Init things");
387+
});
282388
});
283389
});

src/auto-reply/reply/post-compaction-context.ts

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,37 @@ import type { OpenClawConfig } from "../../config/config.js";
66
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
77

88
const MAX_CONTEXT_CHARS = 3000;
9+
const DEFAULT_POST_COMPACTION_SECTIONS = ["Session Startup", "Red Lines"];
10+
const LEGACY_POST_COMPACTION_SECTIONS = ["Every Session", "Safety"];
11+
12+
// Compare configured section names as a case-insensitive set so deployments can
13+
// pin the documented defaults in any order without changing fallback semantics.
14+
function matchesSectionSet(sectionNames: string[], expectedSections: string[]): boolean {
15+
if (sectionNames.length !== expectedSections.length) {
16+
return false;
17+
}
18+
19+
const counts = new Map<string, number>();
20+
for (const name of expectedSections) {
21+
const normalized = name.trim().toLowerCase();
22+
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
23+
}
24+
25+
for (const name of sectionNames) {
26+
const normalized = name.trim().toLowerCase();
27+
const count = counts.get(normalized);
28+
if (!count) {
29+
return false;
30+
}
31+
if (count === 1) {
32+
counts.delete(normalized);
33+
} else {
34+
counts.set(normalized, count - 1);
35+
}
36+
}
37+
38+
return counts.size === 0;
39+
}
940

1041
function formatDateStamp(nowMs: number, timezone: string): string {
1142
const parts = new Intl.DateTimeFormat("en-US", {
@@ -53,19 +84,39 @@ export async function readPostCompactionContext(
5384
}
5485
})();
5586

56-
// Extract "## Session Startup" and "## Red Lines" sections.
57-
// Also accept legacy names "Every Session" and "Safety" for backward
58-
// compatibility with older AGENTS.md templates.
59-
// Each section ends at the next "## " heading or end of file
60-
let sections = extractSections(content, ["Session Startup", "Red Lines"]);
61-
if (sections.length === 0) {
62-
sections = extractSections(content, ["Every Session", "Safety"]);
87+
// Extract configured sections from AGENTS.md (default: Session Startup + Red Lines).
88+
// An explicit empty array disables post-compaction context injection entirely.
89+
const configuredSections = cfg?.agents?.defaults?.compaction?.postCompactionSections;
90+
const sectionNames = Array.isArray(configuredSections)
91+
? configuredSections
92+
: DEFAULT_POST_COMPACTION_SECTIONS;
93+
94+
if (sectionNames.length === 0) {
95+
return null;
96+
}
97+
98+
const foundSectionNames: string[] = [];
99+
let sections = extractSections(content, sectionNames, foundSectionNames);
100+
101+
// Fall back to legacy section names ("Every Session" / "Safety") when using
102+
// defaults and the current headings aren't found — preserves compatibility
103+
// with older AGENTS.md templates. The fallback also applies when the user
104+
// explicitly configures the default pair, so that pinning the documented
105+
// defaults never silently changes behavior vs. leaving the field unset.
106+
const isDefaultSections =
107+
!Array.isArray(configuredSections) ||
108+
matchesSectionSet(configuredSections, DEFAULT_POST_COMPACTION_SECTIONS);
109+
if (sections.length === 0 && isDefaultSections) {
110+
sections = extractSections(content, LEGACY_POST_COMPACTION_SECTIONS, foundSectionNames);
63111
}
64112

65113
if (sections.length === 0) {
66114
return null;
67115
}
68116

117+
// Only reference section names that were actually found and injected.
118+
const displayNames = foundSectionNames.length > 0 ? foundSectionNames : sectionNames;
119+
69120
const resolvedNowMs = nowMs ?? Date.now();
70121
const timezone = resolveUserTimezone(cfg?.agents?.defaults?.userTimezone);
71122
const dateStamp = formatDateStamp(resolvedNowMs, timezone);
@@ -79,11 +130,24 @@ export async function readPostCompactionContext(
79130
? combined.slice(0, MAX_CONTEXT_CHARS) + "\n...[truncated]..."
80131
: combined;
81132

133+
// When using the default section set, use precise prose that names the
134+
// "Session Startup" sequence explicitly. When custom sections are configured,
135+
// use generic prose — referencing a hardcoded "Session Startup" sequence
136+
// would be misleading for deployments that use different section names.
137+
const prose = isDefaultSections
138+
? "Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence. " +
139+
"Execute your Session Startup sequence now — read the required files before responding to the user."
140+
: `Session was just compacted. The conversation summary above is a hint, NOT a substitute for your full startup sequence. ` +
141+
`Re-read the sections injected below (${displayNames.join(", ")}) and follow your configured startup procedure before responding to the user.`;
142+
143+
const sectionLabel = isDefaultSections
144+
? "Critical rules from AGENTS.md:"
145+
: `Injected sections from AGENTS.md (${displayNames.join(", ")}):`;
146+
82147
return (
83148
"[Post-compaction context refresh]\n\n" +
84-
"Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence. " +
85-
"Execute your Session Startup sequence now — read the required files before responding to the user.\n\n" +
86-
`Critical rules from AGENTS.md:\n\n${safeContent}\n\n${timeLine}`
149+
`${prose}\n\n` +
150+
`${sectionLabel}\n\n${safeContent}\n\n${timeLine}`
87151
);
88152
} catch {
89153
return null;
@@ -96,7 +160,11 @@ export async function readPostCompactionContext(
96160
* Skips content inside fenced code blocks.
97161
* Captures until the next heading of same or higher level, or end of string.
98162
*/
99-
export function extractSections(content: string, sectionNames: string[]): string[] {
163+
export function extractSections(
164+
content: string,
165+
sectionNames: string[],
166+
foundNames?: string[],
167+
): string[] {
100168
const results: string[] = [];
101169
const lines = content.split("\n");
102170

@@ -157,6 +225,7 @@ export function extractSections(content: string, sectionNames: string[]): string
157225

158226
if (sectionLines.length > 0) {
159227
results.push(sectionLines.join("\n").trim());
228+
foundNames?.push(name);
160229
}
161230
}
162231

src/config/schema.help.quality.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ const TARGET_KEYS = [
375375
"agents.defaults.compaction.qualityGuard",
376376
"agents.defaults.compaction.qualityGuard.enabled",
377377
"agents.defaults.compaction.qualityGuard.maxRetries",
378+
"agents.defaults.compaction.postCompactionSections",
378379
"agents.defaults.compaction.memoryFlush",
379380
"agents.defaults.compaction.memoryFlush.enabled",
380381
"agents.defaults.compaction.memoryFlush.softThresholdTokens",
@@ -795,6 +796,11 @@ describe("config help copy quality", () => {
795796
expect(identifierPolicy.includes('"off"')).toBe(true);
796797
expect(identifierPolicy.includes('"custom"')).toBe(true);
797798

799+
const postCompactionSections = FIELD_HELP["agents.defaults.compaction.postCompactionSections"];
800+
expect(/Session Startup|Red Lines/i.test(postCompactionSections)).toBe(true);
801+
expect(/Every Session|Safety/i.test(postCompactionSections)).toBe(true);
802+
expect(/\[\]|disable/i.test(postCompactionSections)).toBe(true);
803+
798804
const flush = FIELD_HELP["agents.defaults.compaction.memoryFlush.enabled"];
799805
expect(/pre-compaction|memory flush|token/i.test(flush)).toBe(true);
800806
});

0 commit comments

Comments
 (0)