fix(agents): record what agents re-read after auto-compaction (#128148)

* feat(agents): record post-compaction re-read facts in guard window summary

The post-compaction loop guard observed up to three tool outcomes after
auto-compaction but recorded nothing unless an exact args+result loop
persisted. Re-reads of content the compaction just summarized — the common
quality signal — left no trace at all.

Keep a bounded recent-call tail so arming snapshots the pre-compaction
baseline, count window observations that repeat pre-compaction signatures,
and log one bounded summary line when the window closes without aborting.

* test(agents): remove redundant compaction guard cases
This commit is contained in:
Vyctor H. Brzezowski
2026-08-23 07:47:02 -03:00
committed by GitHub
parent 2b27b4bcf9
commit 33ed38bbf1
2 changed files with 125 additions and 1 deletions
@@ -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", {
@@ -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<string> | undefined;
windowObserved: number;
windowRepeats: number;
repeatTools: Set<string>;
};
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<string>(),
};
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<string>();
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<string>();
}
return { shouldAbort: false, armed: armedAfter, remainingAttempts: state.remainingAttempts };
};