feat: add post-compaction read audit (Layer 3)

This commit is contained in:
康熙
2026-02-16 21:41:03 +08:00
committed by Peter Steinberger
parent 3296a25cc6
commit 811c4f5e91
3 changed files with 337 additions and 0 deletions

View File

@@ -37,6 +37,12 @@ import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-utils.j
import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js";
import { resolveBlockStreamingCoalescing } from "./block-streaming.js";
import { createFollowupRunner } from "./followup-runner.js";
import {
auditPostCompactionReads,
extractReadPaths,
formatAuditWarning,
readSessionMessages,
} from "./post-compaction-audit.js";
import { readPostCompactionContext } from "./post-compaction-context.js";
import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js";
import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js";
@@ -80,6 +86,9 @@ function appendUnscheduledReminderNote(payloads: ReplyPayload[]): ReplyPayload[]
});
}
// Track sessions pending post-compaction read audit (Layer 3)
const pendingPostCompactionAudits = new Map<string, boolean>();
export async function runReplyAgent(params: {
commandBody: string;
followupRun: FollowupRun;
@@ -562,6 +571,9 @@ export async function runReplyAgent(params: {
.catch(() => {
// Silent failure — post-compaction context is best-effort
});
// Set pending audit flag for Layer 3 (post-compaction read audit)
pendingPostCompactionAudits.set(sessionKey, true);
}
if (verboseEnabled) {
@@ -576,6 +588,25 @@ export async function runReplyAgent(params: {
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
}
// Post-compaction read audit (Layer 3)
if (sessionKey && pendingPostCompactionAudits.get(sessionKey)) {
pendingPostCompactionAudits.delete(sessionKey); // Delete FIRST — one-shot only
try {
const sessionFile = activeSessionEntry?.sessionFile;
if (sessionFile) {
const messages = readSessionMessages(sessionFile);
const readPaths = extractReadPaths(messages);
const workspaceDir = process.cwd();
const audit = auditPostCompactionReads(readPaths, workspaceDir);
if (!audit.passed) {
enqueueSystemEvent(formatAuditWarning(audit.missingPatterns), { sessionKey });
}
}
} catch {
// Silent failure — audit is best-effort
}
}
return finalizeWithFollowup(
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
queueKey,

View File

@@ -0,0 +1,197 @@
import { describe, it, expect } from "vitest";
import {
auditPostCompactionReads,
extractReadPaths,
formatAuditWarning,
} from "./post-compaction-audit.js";
describe("extractReadPaths", () => {
it("extracts file paths from Read tool calls", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool_use",
name: "read",
input: { file_path: "WORKFLOW_AUTO.md" },
},
],
},
{
role: "assistant",
content: [
{
type: "tool_use",
name: "read",
input: { file_path: "memory/2026-02-16.md" },
},
],
},
];
const paths = extractReadPaths(messages);
expect(paths).toEqual(["WORKFLOW_AUTO.md", "memory/2026-02-16.md"]);
});
it("handles path parameter (alternative to file_path)", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool_use",
name: "read",
input: { path: "AGENTS.md" },
},
],
},
];
const paths = extractReadPaths(messages);
expect(paths).toEqual(["AGENTS.md"]);
});
it("ignores non-assistant messages", () => {
const messages = [
{
role: "user",
content: [
{
type: "tool_use",
name: "read",
input: { file_path: "should_be_ignored.md" },
},
],
},
];
const paths = extractReadPaths(messages);
expect(paths).toEqual([]);
});
it("ignores non-read tool calls", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool_use",
name: "exec",
input: { command: "cat WORKFLOW_AUTO.md" },
},
],
},
];
const paths = extractReadPaths(messages);
expect(paths).toEqual([]);
});
it("handles empty messages array", () => {
const paths = extractReadPaths([]);
expect(paths).toEqual([]);
});
it("handles messages with non-array content", () => {
const messages = [
{
role: "assistant",
content: "text only",
},
];
const paths = extractReadPaths(messages);
expect(paths).toEqual([]);
});
});
describe("auditPostCompactionReads", () => {
const workspaceDir = "/Users/test/workspace";
it("passes when all required files are read", () => {
const readPaths = ["WORKFLOW_AUTO.md", "memory/2026-02-16.md"];
const result = auditPostCompactionReads(readPaths, workspaceDir);
expect(result.passed).toBe(true);
expect(result.missingPatterns).toEqual([]);
});
it("fails when no files are read", () => {
const result = auditPostCompactionReads([], workspaceDir);
expect(result.passed).toBe(false);
expect(result.missingPatterns).toContain("WORKFLOW_AUTO.md");
expect(result.missingPatterns.some((p) => p.includes("memory"))).toBe(true);
});
it("reports only missing files", () => {
const readPaths = ["WORKFLOW_AUTO.md"];
const result = auditPostCompactionReads(readPaths, workspaceDir);
expect(result.passed).toBe(false);
expect(result.missingPatterns).not.toContain("WORKFLOW_AUTO.md");
expect(result.missingPatterns.some((p) => p.includes("memory"))).toBe(true);
});
it("matches RegExp patterns against relative paths", () => {
const readPaths = ["memory/2026-02-16.md"];
const result = auditPostCompactionReads(readPaths, workspaceDir);
expect(result.passed).toBe(false);
expect(result.missingPatterns).toContain("WORKFLOW_AUTO.md");
expect(result.missingPatterns.length).toBe(1);
});
it("normalizes relative paths when matching", () => {
const readPaths = ["./WORKFLOW_AUTO.md", "memory/2026-02-16.md"];
const result = auditPostCompactionReads(readPaths, workspaceDir);
expect(result.passed).toBe(true);
expect(result.missingPatterns).toEqual([]);
});
it("normalizes absolute paths when matching", () => {
const readPaths = [
"/Users/test/workspace/WORKFLOW_AUTO.md",
"/Users/test/workspace/memory/2026-02-16.md",
];
const result = auditPostCompactionReads(readPaths, workspaceDir);
expect(result.passed).toBe(true);
expect(result.missingPatterns).toEqual([]);
});
it("accepts custom required reads list", () => {
const readPaths = ["custom.md"];
const customRequired = ["custom.md"];
const result = auditPostCompactionReads(readPaths, workspaceDir, customRequired);
expect(result.passed).toBe(true);
expect(result.missingPatterns).toEqual([]);
});
});
describe("formatAuditWarning", () => {
it("formats warning message with missing patterns", () => {
const missingPatterns = ["WORKFLOW_AUTO.md", "memory\\/\\d{4}-\\d{2}-\\d{2}\\.md"];
const message = formatAuditWarning(missingPatterns);
expect(message).toContain("⚠️ Post-Compaction Audit");
expect(message).toContain("WORKFLOW_AUTO.md");
expect(message).toContain("memory");
expect(message).toContain("Please read them now");
});
it("formats single missing pattern", () => {
const missingPatterns = ["WORKFLOW_AUTO.md"];
const message = formatAuditWarning(missingPatterns);
expect(message).toContain("WORKFLOW_AUTO.md");
// Check that the missing patterns list only contains WORKFLOW_AUTO.md
const lines = message.split("\n");
const patternLines = lines.filter((l) => l.trim().startsWith("- "));
expect(patternLines).toHaveLength(1);
expect(patternLines[0]).toContain("WORKFLOW_AUTO.md");
});
});

View File

@@ -0,0 +1,109 @@
import fs from "node:fs";
import path from "node:path";
// Default required files — constants, extensible to config later
const DEFAULT_REQUIRED_READS: Array<string | RegExp> = [
"WORKFLOW_AUTO.md",
/memory\/\d{4}-\d{2}-\d{2}\.md/, // daily memory files
];
/**
* Audit whether agent read required startup files after compaction.
* Returns list of missing file patterns.
*/
export function auditPostCompactionReads(
readFilePaths: string[],
workspaceDir: string,
requiredReads: Array<string | RegExp> = DEFAULT_REQUIRED_READS,
): { passed: boolean; missingPatterns: string[] } {
const normalizedReads = readFilePaths.map((p) => path.resolve(workspaceDir, p));
const missingPatterns: string[] = [];
for (const required of requiredReads) {
if (typeof required === "string") {
const requiredResolved = path.resolve(workspaceDir, required);
const found = normalizedReads.some((r) => r === requiredResolved);
if (!found) {
missingPatterns.push(required);
}
} else {
// RegExp — match against relative paths from workspace
const found = readFilePaths.some((p) => {
const rel = path.relative(workspaceDir, path.resolve(workspaceDir, p));
return required.test(rel);
});
if (!found) {
missingPatterns.push(required.source);
}
}
}
return { passed: missingPatterns.length === 0, missingPatterns };
}
/**
* Read messages from a session JSONL file.
* Returns messages from the last N lines (default 100).
*/
export function readSessionMessages(
sessionFile: string,
maxLines = 100,
): Array<{ role?: string; content?: unknown }> {
if (!fs.existsSync(sessionFile)) {
return [];
}
try {
const content = fs.readFileSync(sessionFile, "utf-8");
const lines = content.trim().split("\n");
const recentLines = lines.slice(-maxLines);
const messages: Array<{ role?: string; content?: unknown }> = [];
for (const line of recentLines) {
try {
const entry = JSON.parse(line);
if (entry.type === "message" && entry.message) {
messages.push(entry.message);
}
} catch {
// Skip malformed lines
}
}
return messages;
} catch {
return [];
}
}
/**
* Extract file paths from Read tool calls in agent messages.
* Looks for tool_use blocks with name="read" and extracts path/file_path args.
*/
export function extractReadPaths(messages: Array<{ role?: string; content?: unknown }>): string[] {
const paths: string[] = [];
for (const msg of messages) {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
continue;
}
for (const block of msg.content) {
if (block.type === "tool_use" && block.name === "read") {
const filePath = block.input?.file_path ?? block.input?.path;
if (typeof filePath === "string") {
paths.push(filePath);
}
}
}
}
return paths;
}
/** Format the audit warning message */
export function formatAuditWarning(missingPatterns: string[]): string {
const fileList = missingPatterns.map((p) => ` - ${p}`).join("\n");
return (
"⚠️ Post-Compaction Audit: The following required startup files were not read after context reset:\n" +
fileList +
"\n\nPlease read them now using the Read tool before continuing. " +
"This ensures your operating protocols are restored after memory compaction."
);
}