diff --git a/src/agents/embedded-agent-runner/post-compaction-loop-guard.test.ts b/src/agents/embedded-agent-runner/post-compaction-loop-guard.test.ts index b7b42dd46e6d..d288299c7892 100644 --- a/src/agents/embedded-agent-runner/post-compaction-loop-guard.test.ts +++ b/src/agents/embedded-agent-runner/post-compaction-loop-guard.test.ts @@ -1,5 +1,18 @@ // Coverage for detecting repeated tool loops immediately after compaction. -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const logInfo = vi.hoisted(() => vi.fn()); +const logError = vi.hoisted(() => vi.fn()); + +vi.mock("../../logging/subsystem.js", () => ({ + createSubsystemLogger: vi.fn(() => ({ + info: logInfo, + error: logError, + warn: vi.fn(), + debug: vi.fn(), + })), +})); + import { createPostCompactionLoopGuard, PostCompactionLoopPersistedError, @@ -120,6 +133,64 @@ describe("createPostCompactionLoopGuard", () => { }); }); +describe("post-compaction re-read instrumentation", () => { + beforeEach(() => { + logInfo.mockClear(); + logError.mockClear(); + }); + + it("summarizes post-compaction re-reads against the pre-compaction baseline", () => { + const guard = createPostCompactionLoopGuard(); + guard.observe(callOutcome("read", { path: "/report.md" }, "content-v1")); + guard.observe(callOutcome("exec", { cmd: "ls" }, "ok")); + guard.armPostCompaction(); + // The model immediately re-reads what compaction summarized away, then moves on. + guard.observe(callOutcome("read", { path: "/report.md" }, "content-v2")); + guard.observe(callOutcome("read", { path: "/other.md" }, "fresh")); + const last = guard.observe(callOutcome("gateway", { action: "probe" }, "r1")); + expect(last.shouldAbort).toBe(false); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining("post-compaction window closed")); + const summary = logInfo.mock.calls + .map((call) => call[0] as string) + .find((message) => message.includes("post-compaction window closed")); + expect(summary).toContain("toolCalls=3"); + expect(summary).toContain("preCompactionRepeats=1"); + expect(summary).toContain("read"); + expect(logError).not.toHaveBeenCalled(); + }); + + it("reports zero re-reads when the window introduces only fresh calls", () => { + const guard = createPostCompactionLoopGuard(); + guard.observe(callOutcome("read", { path: "/a.md" }, "v1")); + guard.armPostCompaction(); + guard.observe(callOutcome("read", { path: "/b.md" }, "v1")); + guard.observe(callOutcome("read", { path: "/c.md" }, "v1")); + guard.observe(callOutcome("exec", { cmd: "ls" }, "ok")); + const summary = logInfo.mock.calls + .map((call) => call[0] as string) + .find((message) => message.includes("post-compaction window closed")); + expect(summary).toContain("preCompactionRepeats=0"); + }); + + it("does not count signatures evicted from the bounded baseline", () => { + const guard = createPostCompactionLoopGuard(); + for (let i = 0; i < 20; i += 1) { + guard.observe(callOutcome("read", { path: `/old-${i}.md` }, "v1")); + } + guard.armPostCompaction(); + // Signature from before the baseline window: no longer comparable. + guard.observe(callOutcome("read", { path: "/old-0.md" }, "v2")); + // Signature still inside the baseline window tail. + guard.observe(callOutcome("read", { path: "/old-19.md" }, "v2")); + guard.observe(callOutcome("gateway", { action: "probe" }, "r1")); + const summary = logInfo.mock.calls + .map((call) => call[0] as string) + .find((message) => message.includes("post-compaction window closed")); + expect(summary).toContain("toolCalls=3"); + expect(summary).toContain("preCompactionRepeats=1"); + }); +}); + describe("PostCompactionLoopPersistedError", () => { it("captures the detector, count, toolName, and message", () => { const err = new PostCompactionLoopPersistedError("loop persisted", { diff --git a/src/agents/embedded-agent-runner/post-compaction-loop-guard.ts b/src/agents/embedded-agent-runner/post-compaction-loop-guard.ts index 2375db8a2a34..4bb18eb3553b 100644 --- a/src/agents/embedded-agent-runner/post-compaction-loop-guard.ts +++ b/src/agents/embedded-agent-runner/post-compaction-loop-guard.ts @@ -13,6 +13,11 @@ const log = createSubsystemLogger("agents/post-compaction-guard"); const DEFAULT_WINDOW_SIZE = 3; +// Bounded recent-call tail kept across the whole run so arming can snapshot what the +// model was doing right before compaction. Without it, re-reads of summarized content +// inside the post-compaction window leave no recorded fact at all. +const BASELINE_WINDOW_SIZE = 16; + type PostCompactionGuardObservation = { toolName: string; argsHash: string; @@ -42,8 +47,16 @@ type GuardState = { windowSize: number; remainingAttempts: number; history: PostCompactionGuardObservation[]; + recentCalls: PostCompactionGuardObservation[]; + baselineSignatures: Set | undefined; + windowObserved: number; + windowRepeats: number; + repeatTools: Set; }; +const observationSignature = (call: PostCompactionGuardObservation): string => + `${call.toolName}\0${call.argsHash}`; + /** Creates a stateful post-compaction loop detector for one embedded run. */ export function createPostCompactionLoopGuard(options?: { enabled?: boolean; @@ -53,24 +66,56 @@ export function createPostCompactionLoopGuard(options?: { windowSize: DEFAULT_WINDOW_SIZE, remainingAttempts: 0, history: [], + recentCalls: [], + baselineSignatures: undefined, + windowObserved: 0, + windowRepeats: 0, + repeatTools: new Set(), }; const armPostCompaction = (): void => { + // Snapshot the pre-compaction call tail before the new window starts. A re-arm + // mid-window replaces the unclosed window's counts; compaction success implies + // the prior attempt ended, so that loss is accepted. + state.baselineSignatures = + state.enabled && state.recentCalls.length > 0 + ? new Set(state.recentCalls.map(observationSignature)) + : undefined; state.remainingAttempts = state.windowSize; state.history = []; + state.windowObserved = 0; + state.windowRepeats = 0; + state.repeatTools = new Set(); if (state.enabled) { log.info(`post-compaction guard armed for ${state.windowSize} attempts`); } }; + const logWindowSummary = (): void => { + const tools = [...state.repeatTools].toSorted().join(","); + log.info( + `post-compaction window closed: toolCalls=${state.windowObserved} ` + + `preCompactionRepeats=${state.windowRepeats}${tools ? ` tools=${tools}` : ""}`, + ); + }; + const observe = (call: PostCompactionGuardObservation): PostCompactionGuardVerdict => { if (!state.enabled) { return { shouldAbort: false, armed: false, remainingAttempts: 0 }; } + state.recentCalls.push(call); + if (state.recentCalls.length > BASELINE_WINDOW_SIZE) { + state.recentCalls.shift(); + } if (state.remainingAttempts <= 0) { return { shouldAbort: false, armed: false, remainingAttempts: 0 }; } state.remainingAttempts -= 1; + state.windowObserved += 1; + if (state.baselineSignatures?.has(observationSignature(call))) { + state.windowRepeats += 1; + state.repeatTools.add(call.toolName); + } state.history.push(call); const armedAfter = state.remainingAttempts > 0; @@ -98,6 +143,14 @@ export function createPostCompactionLoopGuard(options?: { }; } + if (!armedAfter) { + logWindowSummary(); + state.baselineSignatures = undefined; + state.windowObserved = 0; + state.windowRepeats = 0; + state.repeatTools = new Set(); + } + return { shouldAbort: false, armed: armedAfter, remainingAttempts: state.remainingAttempts }; };