feat(compaction): make post-compaction context sections configurable (#34556)

Merged via squash.

Prepared head SHA: 491bb28544
Co-authored-by: efe-arv <259833796+efe-arv@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Efe Büken
2026-03-07 01:57:15 +03:00
committed by GitHub
parent 455430a6f8
commit 03b9abab84
9 changed files with 247 additions and 53 deletions

View File

@@ -228,56 +228,162 @@ Read WORKFLOW.md on startup.
expect(result).toContain("Current time:");
});
it("falls back to legacy section names (Every Session / Safety)", async () => {
const content = `# Rules
// -------------------------------------------------------------------------
// postCompactionSections config
// -------------------------------------------------------------------------
describe("agents.defaults.compaction.postCompactionSections", () => {
it("uses default sections (Session Startup + Red Lines) when config is not set", async () => {
const content = `## Session Startup\n\nDo startup.\n\n## Red Lines\n\nDo not break.\n\n## Other\n\nIgnore.\n`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const result = await readPostCompactionContext(tmpDir);
expect(result).toContain("Session Startup");
expect(result).toContain("Red Lines");
expect(result).not.toContain("Other");
});
## Every Session
it("uses custom section names from config instead of defaults", async () => {
const content = `## Session Startup\n\nDo startup.\n\n## Critical Rules\n\nMy custom rules.\n\n## Red Lines\n\nDefault section.\n`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const cfg = {
agents: {
defaults: {
compaction: { postCompactionSections: ["Critical Rules"] },
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
expect(result).not.toBeNull();
expect(result).toContain("Critical Rules");
expect(result).toContain("My custom rules");
// Default sections must not be included when overridden
expect(result).not.toContain("Do startup");
expect(result).not.toContain("Default section");
});
Read SOUL.md and USER.md.
it("supports multiple custom section names", async () => {
const content = `## Onboarding\n\nOnboard things.\n\n## Safety\n\nSafe things.\n\n## Noise\n\nIgnore.\n`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const cfg = {
agents: {
defaults: {
compaction: { postCompactionSections: ["Onboarding", "Safety"] },
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
expect(result).not.toBeNull();
expect(result).toContain("Onboard things");
expect(result).toContain("Safe things");
expect(result).not.toContain("Ignore");
});
## Safety
it("returns null when postCompactionSections is explicitly set to [] (opt-out)", async () => {
const content = `## Session Startup\n\nDo startup.\n\n## Red Lines\n\nDo not break.\n`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const cfg = {
agents: {
defaults: {
compaction: { postCompactionSections: [] },
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
// Empty array = opt-out: no post-compaction context injection
expect(result).toBeNull();
});
Don't exfiltrate private data.
it("returns null when custom sections are configured but none found in AGENTS.md", async () => {
const content = `## Session Startup\n\nDo startup.\n`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const cfg = {
agents: {
defaults: {
compaction: { postCompactionSections: ["Nonexistent Section"] },
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
expect(result).toBeNull();
});
## Other
it("does NOT reference 'Session Startup' in prose when custom sections are configured", async () => {
// Greptile review finding: hardcoded prose mentioned "Execute your Session Startup
// sequence now" even when custom section names were configured, causing agents to
// look for a non-existent section. Prose must adapt to the configured section names.
const content = `## Boot Sequence\n\nDo custom boot things.\n`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const cfg = {
agents: {
defaults: {
compaction: { postCompactionSections: ["Boot Sequence"] },
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
expect(result).not.toBeNull();
// Must not reference the hardcoded default section name
expect(result).not.toContain("Session Startup");
// Must reference the actual configured section names
expect(result).toContain("Boot Sequence");
});
Ignore this.
`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const result = await readPostCompactionContext(tmpDir);
expect(result).not.toBeNull();
expect(result).toContain("Every Session");
expect(result).toContain("Read SOUL.md");
expect(result).toContain("Safety");
expect(result).toContain("Don't exfiltrate");
expect(result).not.toContain("Other");
});
it("uses default 'Session Startup' prose when default sections are active", async () => {
const content = `## Session Startup\n\nDo startup.\n`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const result = await readPostCompactionContext(tmpDir);
expect(result).not.toBeNull();
expect(result).toContain("Execute your Session Startup sequence now");
});
it("prefers new section names over legacy when both exist", async () => {
const content = `# Rules
it("falls back to legacy sections when defaults are explicitly configured", async () => {
// Older AGENTS.md templates use "Every Session" / "Safety" instead of
// "Session Startup" / "Red Lines". Explicitly setting the defaults should
// still trigger the legacy fallback — same behavior as leaving the field unset.
const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const cfg = {
agents: {
defaults: {
compaction: { postCompactionSections: ["Session Startup", "Red Lines"] },
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
expect(result).not.toBeNull();
expect(result).toContain("Do startup things");
expect(result).toContain("Be safe");
});
## Session Startup
it("falls back to legacy sections when default sections are configured in a different order", async () => {
const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const cfg = {
agents: {
defaults: {
compaction: { postCompactionSections: ["Red Lines", "Session Startup"] },
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
expect(result).not.toBeNull();
expect(result).toContain("Do startup things");
expect(result).toContain("Be safe");
expect(result).toContain("Execute your Session Startup sequence now");
});
New startup instructions.
## Every Session
Old startup instructions.
## Red Lines
New red lines.
## Safety
Old safety rules.
`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const result = await readPostCompactionContext(tmpDir);
expect(result).not.toBeNull();
expect(result).toContain("New startup instructions");
expect(result).toContain("New red lines");
expect(result).not.toContain("Old startup instructions");
expect(result).not.toContain("Old safety rules");
it("custom section names are matched case-insensitively", async () => {
const content = `## WORKFLOW INIT\n\nInit things.\n`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const cfg = {
agents: {
defaults: {
compaction: { postCompactionSections: ["workflow init"] },
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
expect(result).not.toBeNull();
expect(result).toContain("Init things");
});
});
});

View File

@@ -6,6 +6,37 @@ import type { OpenClawConfig } from "../../config/config.js";
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
const MAX_CONTEXT_CHARS = 3000;
const DEFAULT_POST_COMPACTION_SECTIONS = ["Session Startup", "Red Lines"];
const LEGACY_POST_COMPACTION_SECTIONS = ["Every Session", "Safety"];
// Compare configured section names as a case-insensitive set so deployments can
// pin the documented defaults in any order without changing fallback semantics.
function matchesSectionSet(sectionNames: string[], expectedSections: string[]): boolean {
if (sectionNames.length !== expectedSections.length) {
return false;
}
const counts = new Map<string, number>();
for (const name of expectedSections) {
const normalized = name.trim().toLowerCase();
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
}
for (const name of sectionNames) {
const normalized = name.trim().toLowerCase();
const count = counts.get(normalized);
if (!count) {
return false;
}
if (count === 1) {
counts.delete(normalized);
} else {
counts.set(normalized, count - 1);
}
}
return counts.size === 0;
}
function formatDateStamp(nowMs: number, timezone: string): string {
const parts = new Intl.DateTimeFormat("en-US", {
@@ -53,19 +84,39 @@ export async function readPostCompactionContext(
}
})();
// Extract "## Session Startup" and "## Red Lines" sections.
// Also accept legacy names "Every Session" and "Safety" for backward
// compatibility with older AGENTS.md templates.
// Each section ends at the next "## " heading or end of file
let sections = extractSections(content, ["Session Startup", "Red Lines"]);
if (sections.length === 0) {
sections = extractSections(content, ["Every Session", "Safety"]);
// Extract configured sections from AGENTS.md (default: Session Startup + Red Lines).
// An explicit empty array disables post-compaction context injection entirely.
const configuredSections = cfg?.agents?.defaults?.compaction?.postCompactionSections;
const sectionNames = Array.isArray(configuredSections)
? configuredSections
: DEFAULT_POST_COMPACTION_SECTIONS;
if (sectionNames.length === 0) {
return null;
}
const foundSectionNames: string[] = [];
let sections = extractSections(content, sectionNames, foundSectionNames);
// Fall back to legacy section names ("Every Session" / "Safety") when using
// defaults and the current headings aren't found — preserves compatibility
// with older AGENTS.md templates. The fallback also applies when the user
// explicitly configures the default pair, so that pinning the documented
// defaults never silently changes behavior vs. leaving the field unset.
const isDefaultSections =
!Array.isArray(configuredSections) ||
matchesSectionSet(configuredSections, DEFAULT_POST_COMPACTION_SECTIONS);
if (sections.length === 0 && isDefaultSections) {
sections = extractSections(content, LEGACY_POST_COMPACTION_SECTIONS, foundSectionNames);
}
if (sections.length === 0) {
return null;
}
// Only reference section names that were actually found and injected.
const displayNames = foundSectionNames.length > 0 ? foundSectionNames : sectionNames;
const resolvedNowMs = nowMs ?? Date.now();
const timezone = resolveUserTimezone(cfg?.agents?.defaults?.userTimezone);
const dateStamp = formatDateStamp(resolvedNowMs, timezone);
@@ -79,11 +130,24 @@ export async function readPostCompactionContext(
? combined.slice(0, MAX_CONTEXT_CHARS) + "\n...[truncated]..."
: combined;
// When using the default section set, use precise prose that names the
// "Session Startup" sequence explicitly. When custom sections are configured,
// use generic prose — referencing a hardcoded "Session Startup" sequence
// would be misleading for deployments that use different section names.
const prose = isDefaultSections
? "Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence. " +
"Execute your Session Startup sequence now — read the required files before responding to the user."
: `Session was just compacted. The conversation summary above is a hint, NOT a substitute for your full startup sequence. ` +
`Re-read the sections injected below (${displayNames.join(", ")}) and follow your configured startup procedure before responding to the user.`;
const sectionLabel = isDefaultSections
? "Critical rules from AGENTS.md:"
: `Injected sections from AGENTS.md (${displayNames.join(", ")}):`;
return (
"[Post-compaction context refresh]\n\n" +
"Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence. " +
"Execute your Session Startup sequence now — read the required files before responding to the user.\n\n" +
`Critical rules from AGENTS.md:\n\n${safeContent}\n\n${timeLine}`
`${prose}\n\n` +
`${sectionLabel}\n\n${safeContent}\n\n${timeLine}`
);
} catch {
return null;
@@ -96,7 +160,11 @@ export async function readPostCompactionContext(
* Skips content inside fenced code blocks.
* Captures until the next heading of same or higher level, or end of string.
*/
export function extractSections(content: string, sectionNames: string[]): string[] {
export function extractSections(
content: string,
sectionNames: string[],
foundNames?: string[],
): string[] {
const results: string[] = [];
const lines = content.split("\n");
@@ -157,6 +225,7 @@ export function extractSections(content: string, sectionNames: string[]): string
if (sectionLines.length > 0) {
results.push(sectionLines.join("\n").trim());
foundNames?.push(name);
}
}