mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
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:
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI: make read-only SecretRef status flows degrade safely (#37023) thanks @joshavant.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### Breaking
|
||||
|
||||
|
||||
@@ -1003,6 +1003,7 @@ Periodic heartbeat runs.
|
||||
reserveTokensFloor: 24000,
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
|
||||
postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection
|
||||
memoryFlush: {
|
||||
enabled: true,
|
||||
softThresholdTokens: 6000,
|
||||
@@ -1018,6 +1019,7 @@ Periodic heartbeat runs.
|
||||
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
|
||||
- `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`.
|
||||
- `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.
|
||||
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only.
|
||||
|
||||
### `agents.defaults.contextPruning`
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -375,6 +375,7 @@ const TARGET_KEYS = [
|
||||
"agents.defaults.compaction.qualityGuard",
|
||||
"agents.defaults.compaction.qualityGuard.enabled",
|
||||
"agents.defaults.compaction.qualityGuard.maxRetries",
|
||||
"agents.defaults.compaction.postCompactionSections",
|
||||
"agents.defaults.compaction.memoryFlush",
|
||||
"agents.defaults.compaction.memoryFlush.enabled",
|
||||
"agents.defaults.compaction.memoryFlush.softThresholdTokens",
|
||||
@@ -795,6 +796,11 @@ describe("config help copy quality", () => {
|
||||
expect(identifierPolicy.includes('"off"')).toBe(true);
|
||||
expect(identifierPolicy.includes('"custom"')).toBe(true);
|
||||
|
||||
const postCompactionSections = FIELD_HELP["agents.defaults.compaction.postCompactionSections"];
|
||||
expect(/Session Startup|Red Lines/i.test(postCompactionSections)).toBe(true);
|
||||
expect(/Every Session|Safety/i.test(postCompactionSections)).toBe(true);
|
||||
expect(/\[\]|disable/i.test(postCompactionSections)).toBe(true);
|
||||
|
||||
const flush = FIELD_HELP["agents.defaults.compaction.memoryFlush.enabled"];
|
||||
expect(/pre-compaction|memory flush|token/i.test(flush)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1003,6 +1003,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Enables summary quality audits and regeneration retries for safeguard compaction. Default: false, so safeguard mode alone does not turn on retry behavior.",
|
||||
"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.postCompactionSections":
|
||||
'AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset to use "Session Startup"/"Red Lines" with legacy fallback to "Every Session"/"Safety"; set to [] to disable reinjection entirely.',
|
||||
"agents.defaults.compaction.memoryFlush":
|
||||
"Pre-compaction memory flush settings that run an agentic memory write before heavy compaction. Keep enabled for long sessions so salient context is persisted before aggressive trimming.",
|
||||
"agents.defaults.compaction.memoryFlush.enabled":
|
||||
|
||||
@@ -454,6 +454,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"agents.defaults.compaction.qualityGuard": "Compaction Quality Guard",
|
||||
"agents.defaults.compaction.qualityGuard.enabled": "Compaction Quality Guard Enabled",
|
||||
"agents.defaults.compaction.qualityGuard.maxRetries": "Compaction Quality Guard Max Retries",
|
||||
"agents.defaults.compaction.postCompactionSections": "Post-Compaction Context Sections",
|
||||
"agents.defaults.compaction.memoryFlush": "Compaction Memory Flush",
|
||||
"agents.defaults.compaction.memoryFlush.enabled": "Compaction Memory Flush Enabled",
|
||||
"agents.defaults.compaction.memoryFlush.softThresholdTokens":
|
||||
|
||||
@@ -314,6 +314,12 @@ export type AgentCompactionConfig = {
|
||||
qualityGuard?: AgentCompactionQualityGuardConfig;
|
||||
/** Pre-compaction memory flush (agentic turn). Default: enabled. */
|
||||
memoryFlush?: AgentCompactionMemoryFlushConfig;
|
||||
/**
|
||||
* H2/H3 section names from AGENTS.md to inject after compaction.
|
||||
* Defaults to ["Session Startup", "Red Lines"] when unset.
|
||||
* Set to [] to disable post-compaction context injection entirely.
|
||||
*/
|
||||
postCompactionSections?: string[];
|
||||
};
|
||||
|
||||
export type AgentCompactionMemoryFlushConfig = {
|
||||
|
||||
@@ -102,6 +102,7 @@ export const AgentDefaultsSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
postCompactionSections: z.array(z.string()).optional(),
|
||||
memoryFlush: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
Reference in New Issue
Block a user