fix: simplify post compaction refresh prompt

This commit is contained in:
Tyler Yust
2026-03-12 00:57:05 -07:00
parent 4dd4e36450
commit 3043a7886f
2 changed files with 61 additions and 409 deletions

View File

@@ -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."]);
});
});

View File

@@ -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<string | null> {
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;
}