diff --git a/src/auto-reply/reply/post-compaction-context.test.ts b/src/auto-reply/reply/post-compaction-context.test.ts index 0c97df4d50b..b601ff94e08 100644 --- a/src/auto-reply/reply/post-compaction-context.test.ts +++ b/src/auto-reply/reply/post-compaction-context.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { readPostCompactionContext } from "./post-compaction-context.js"; +import { extractSections, readPostCompactionContext } from "./post-compaction-context.js"; describe("readPostCompactionContext", () => { const tmpDir = path.join("/tmp", "test-post-compaction-" + Date.now()); @@ -20,152 +20,37 @@ describe("readPostCompactionContext", () => { expect(result).toBeNull(); }); - it("returns null when AGENTS.md has no relevant sections", async () => { - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), "# My Agent\n\nSome content.\n"); + it("returns a concise refresh reminder when startup sections exist", async () => { + fs.writeFileSync( + path.join(tmpDir, "AGENTS.md"), + "## Session Startup\n\nRead AGENTS.md and USER.md.\n\n## Red Lines\n\nNever exfiltrate secrets.\n", + ); + const result = await readPostCompactionContext(tmpDir); + expect(result).toBe( + "[Post-compaction context refresh]\n\nSession was compacted. Re-read your startup files, AGENTS.md, SOUL.md, USER.md, and today's memory log, before responding.", + ); + }); + + it("respects explicit disable via postCompactionSections=[]", async () => { + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), "## Session Startup\n\nRead files.\n"); + + const cfg = { + agents: { defaults: { compaction: { postCompactionSections: [] } } }, + } as OpenClawConfig; + + const result = await readPostCompactionContext(tmpDir, cfg); expect(result).toBeNull(); }); - it("extracts Session Startup section", async () => { - const content = `# Agent Rules + it("falls back to legacy section names for default configs", async () => { + fs.writeFileSync( + path.join(tmpDir, "AGENTS.md"), + "## Every Session\n\nDo the startup sequence.\n\n## Safety\n\nStay safe.\n", + ); -## Session Startup - -Read these files: -1. WORKFLOW_AUTO.md -2. memory/today.md - -## Other Section - -Not relevant. -`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); - expect(result).toContain("Session Startup"); - expect(result).toContain("WORKFLOW_AUTO.md"); - expect(result).toContain("Post-compaction context refresh"); - expect(result).not.toContain("Other Section"); - }); - - it("extracts Red Lines section", async () => { - const content = `# Rules - -## Red Lines - -Never do X. -Never do Y. - -## Other - -Stuff. -`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); - expect(result).toContain("Red Lines"); - expect(result).toContain("Never do X"); - }); - - it("extracts both sections", async () => { - const content = `# Rules - -## Session Startup - -Do startup things. - -## Red Lines - -Never break things. - -## Other - -Ignore this. -`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); - expect(result).toContain("Session Startup"); - expect(result).toContain("Red Lines"); - expect(result).not.toContain("Other"); - }); - - it("truncates when content exceeds limit", async () => { - const longContent = "## Session Startup\n\n" + "A".repeat(4000) + "\n\n## Other\n\nStuff."; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), longContent); - const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); - expect(result).toContain("[truncated]"); - }); - - it("matches section names case-insensitively", async () => { - const content = `# Rules - -## session startup - -Read WORKFLOW_AUTO.md - -## Other -`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); - expect(result).toContain("WORKFLOW_AUTO.md"); - }); - - it("matches H3 headings", async () => { - const content = `# Rules - -### Session Startup - -Read these files. - -### Other -`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); - expect(result).toContain("Read these files"); - }); - - it("skips sections inside code blocks", async () => { - const content = `# Rules - -\`\`\`markdown -## Session Startup -This is inside a code block and should NOT be extracted. -\`\`\` - -## Red Lines - -Real red lines here. - -## Other -`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); - expect(result).toContain("Real red lines here"); - expect(result).not.toContain("inside a code block"); - }); - - it("includes sub-headings within a section", async () => { - const content = `## Red Lines - -### Rule 1 -Never do X. - -### Rule 2 -Never do Y. - -## Other Section -`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); - expect(result).not.toBeNull(); - expect(result).toContain("Rule 1"); - expect(result).toContain("Rule 2"); - expect(result).not.toContain("Other Section"); + expect(result).toContain("Session was compacted."); }); it.runIf(process.platform !== "win32")( @@ -179,211 +64,36 @@ Never do Y. expect(result).toBeNull(); }, ); +}); - it.runIf(process.platform !== "win32")( - "returns null when AGENTS.md is a hardlink alias", - async () => { - const outside = path.join(tmpDir, "outside-secret.txt"); - fs.writeFileSync(outside, "secret"); - fs.linkSync(outside, path.join(tmpDir, "AGENTS.md")); +describe("extractSections", () => { + it("matches headings case insensitively and keeps nested headings", () => { + const content = `## session startup - const result = await readPostCompactionContext(tmpDir); - expect(result).toBeNull(); - }, - ); +Read files. - it("substitutes YYYY-MM-DD with the actual date in extracted sections", async () => { - const content = `## Session Startup +### Checklist -Read memory/YYYY-MM-DD.md and memory/yesterday.md. +Do the thing. + +## Other`; + + expect(extractSections(content, ["Session Startup"])).toEqual([ + "## session startup\n\nRead files.\n\n### Checklist\n\nDo the thing.", + ]); + }); + + it("skips headings inside fenced code blocks", () => { + const content = `\ +\`\`\`md +## Session Startup +Ignore this. +\`\`\` ## Red Lines +Real section.`; -Never modify memory/YYYY-MM-DD.md destructively. -`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const cfg = { - agents: { defaults: { userTimezone: "America/New_York", timeFormat: "12" } }, - } as OpenClawConfig; - // 2026-03-03 14:00 UTC = 2026-03-03 09:00 EST - const nowMs = Date.UTC(2026, 2, 3, 14, 0, 0); - const result = await readPostCompactionContext(tmpDir, cfg, nowMs); - expect(result).not.toBeNull(); - expect(result).toContain("memory/2026-03-03.md"); - expect(result).not.toContain("memory/YYYY-MM-DD.md"); - expect(result).toContain( - "Current time: Tuesday, March 3rd, 2026 — 9:00 AM (America/New_York) / 2026-03-03 14:00 UTC", - ); - }); - - it("appends current time line even when no YYYY-MM-DD placeholder is present", async () => { - const content = `## Session Startup - -Read WORKFLOW.md on startup. -`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const nowMs = Date.UTC(2026, 2, 3, 14, 0, 0); - const result = await readPostCompactionContext(tmpDir, undefined, nowMs); - expect(result).not.toBeNull(); - expect(result).toContain("Current time:"); - }); - - // ------------------------------------------------------------------------- - // 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"); - }); - - 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"); - }); - - 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"); - }); - - 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(); - }); - - 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(); - }); - - 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"); - }); - - 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("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"); - }); - - 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"); - }); - - 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"); - }); + expect(extractSections(content, ["Session Startup"])).toEqual([]); + expect(extractSections(content, ["Red Lines"])).toEqual(["## Red Lines\nReal section."]); }); }); diff --git a/src/auto-reply/reply/post-compaction-context.ts b/src/auto-reply/reply/post-compaction-context.ts index 316ac3c29b1..1b7dfc2dd28 100644 --- a/src/auto-reply/reply/post-compaction-context.ts +++ b/src/auto-reply/reply/post-compaction-context.ts @@ -1,11 +1,8 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveCronStyleNow } from "../../agents/current-time.js"; -import { resolveUserTimezone } from "../../agents/date-time.js"; 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"]; @@ -38,32 +35,15 @@ function matchesSectionSet(sectionNames: string[], expectedSections: string[]): return counts.size === 0; } -function formatDateStamp(nowMs: number, timezone: string): string { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(new Date(nowMs)); - const year = parts.find((p) => p.type === "year")?.value; - const month = parts.find((p) => p.type === "month")?.value; - const day = parts.find((p) => p.type === "day")?.value; - if (year && month && day) { - return `${year}-${month}-${day}`; - } - return new Date(nowMs).toISOString().slice(0, 10); -} - /** - * Read critical sections from workspace AGENTS.md for post-compaction injection. - * Returns formatted system event text, or null if no AGENTS.md or no relevant sections. - * Substitutes YYYY-MM-DD placeholders with the real date so agents read the correct - * daily memory files instead of guessing based on training cutoff. + * Read workspace AGENTS.md for post-compaction injection. + * Returns a concise reminder to re-read startup files, or null when the + * workspace has no relevant startup sections configured. */ export async function readPostCompactionContext( workspaceDir: string, cfg?: OpenClawConfig, - nowMs?: number, + _nowMs?: number, ): Promise { const agentsPath = path.join(workspaceDir, "AGENTS.md"); @@ -76,6 +56,7 @@ export async function readPostCompactionContext( if (!opened.ok) { return null; } + const content = (() => { try { return fs.readFileSync(opened.fd, "utf-8"); @@ -84,8 +65,6 @@ export async function readPostCompactionContext( } })(); - // 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 @@ -95,59 +74,22 @@ export async function readPostCompactionContext( 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. + let sections = extractSections(content, sectionNames); const isDefaultSections = !Array.isArray(configuredSections) || matchesSectionSet(configuredSections, DEFAULT_POST_COMPACTION_SECTIONS); + if (sections.length === 0 && isDefaultSections) { - sections = extractSections(content, LEGACY_POST_COMPACTION_SECTIONS, foundSectionNames); + sections = extractSections(content, LEGACY_POST_COMPACTION_SECTIONS); } 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); - // Always append the real runtime timestamp — AGENTS.md content may itself contain - // "Current time:" as user-authored text, so we must not gate on that substring. - const { timeLine } = resolveCronStyleNow(cfg ?? {}, resolvedNowMs); - - const combined = sections.join("\n\n").replaceAll("YYYY-MM-DD", dateStamp); - const safeContent = - combined.length > MAX_CONTEXT_CHARS - ? 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" + - `${prose}\n\n` + - `${sectionLabel}\n\n${safeContent}\n\n${timeLine}` + "Session was compacted. Re-read your startup files, AGENTS.md, SOUL.md, USER.md, and today's memory log, before responding." ); } catch { return null; @@ -208,11 +150,11 @@ export function extractSections( continue; } } else { - // We're in section — stop if we hit a heading of same or higher level + // We're in section, stop if we hit a heading of same or higher level if (level <= sectionLevel) { break; } - // Lower-level heading (e.g., ### inside ##) — include it + // Lower-level heading (e.g., ### inside ##), include it sectionLines.push(line); continue; }