diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index eb63ce23d2f..0d5d71dc5c0 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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(); + 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, diff --git a/src/auto-reply/reply/post-compaction-audit.test.ts b/src/auto-reply/reply/post-compaction-audit.test.ts new file mode 100644 index 00000000000..d6fdf176372 --- /dev/null +++ b/src/auto-reply/reply/post-compaction-audit.test.ts @@ -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"); + }); +}); diff --git a/src/auto-reply/reply/post-compaction-audit.ts b/src/auto-reply/reply/post-compaction-audit.ts new file mode 100644 index 00000000000..aa347f6bced --- /dev/null +++ b/src/auto-reply/reply/post-compaction-audit.ts @@ -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 = [ + "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 = 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." + ); +}