diff --git a/CHANGELOG.md b/CHANGELOG.md index 1840fd3cde2..8fc4a7cd81b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 30559b5d55d..749b0d2b261 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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` diff --git a/src/auto-reply/reply/post-compaction-context.test.ts b/src/auto-reply/reply/post-compaction-context.test.ts index 34da43f2e7e..0c97df4d50b 100644 --- a/src/auto-reply/reply/post-compaction-context.test.ts +++ b/src/auto-reply/reply/post-compaction-context.test.ts @@ -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"); + }); }); }); diff --git a/src/auto-reply/reply/post-compaction-context.ts b/src/auto-reply/reply/post-compaction-context.ts index 9a326b59323..316ac3c29b1 100644 --- a/src/auto-reply/reply/post-compaction-context.ts +++ b/src/auto-reply/reply/post-compaction-context.ts @@ -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(); + 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); } } diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 146ffc17101..2ef7d8aae3a 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -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); }); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index f2ef2ff4ab8..ee760f2d23f 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1003,6 +1003,8 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 64d444aab47..a5fec8dadcf 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -454,6 +454,7 @@ export const FIELD_LABELS: Record = { "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": diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 6ceba822362..a7c40a5016b 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -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 = { diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 276f97f586d..7c43a5a382d 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -102,6 +102,7 @@ export const AgentDefaultsSchema = z }) .strict() .optional(), + postCompactionSections: z.array(z.string()).optional(), memoryFlush: z .object({ enabled: z.boolean().optional(),