fix(agents): address review feedback on post-compaction loop guard

- Add PostCompactionLoopPersistedError.fromVerdict factory.
- Add unit tests for the error class + fromVerdict adapter.
- Disabled guard is now truly dormant (no state mutation when enabled=false).
- Tighten help text for postCompactionGuard.enabled.

Refs #77474
This commit is contained in:
Eduardo Piva
2026-05-04 20:03:40 +00:00
committed by Peter Steinberger
parent 96e7461c81
commit 5b863c719e
3 changed files with 49 additions and 5 deletions

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
createPostCompactionLoopGuard,
PostCompactionLoopPersistedError,
type PostCompactionLoopGuard,
} from "./post-compaction-loop-guard.js";
@@ -111,3 +112,37 @@ describe("createPostCompactionLoopGuard", () => {
expect(guard.snapshot().remainingAttempts).toBe(0);
});
});
describe("PostCompactionLoopPersistedError", () => {
it("captures the detector, count, toolName, and message", () => {
const err = new PostCompactionLoopPersistedError("loop persisted", {
detector: "compaction_loop_persisted",
count: 4,
toolName: "gateway",
});
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(PostCompactionLoopPersistedError);
expect(err.name).toBe("PostCompactionLoopPersistedError");
expect(err.message).toBe("loop persisted");
expect(err.detector).toBe("compaction_loop_persisted");
expect(err.count).toBe(4);
expect(err.toolName).toBe("gateway");
});
it("can be built from a guard verdict via fromVerdict", () => {
const guard = createPostCompactionLoopGuard({ windowSize: 2 });
guard.armPostCompaction();
guard.observe(callOutcome("read", { path: "/x" }, "r1"));
const verdict = guard.observe(callOutcome("read", { path: "/x" }, "r1"));
expect(verdict.shouldAbort).toBe(true);
if (!verdict.shouldAbort) {
throw new Error("verdict was expected to abort");
}
const err = PostCompactionLoopPersistedError.fromVerdict(verdict);
expect(err).toBeInstanceOf(PostCompactionLoopPersistedError);
expect(err.detector).toBe(verdict.detector);
expect(err.count).toBe(verdict.count);
expect(err.toolName).toBe(verdict.toolName);
expect(err.message).toBe(verdict.message);
});
});

View File

@@ -62,6 +62,9 @@ export function createPostCompactionLoopGuard(
};
const observe = (call: PostCompactionGuardObservation): PostCompactionGuardVerdict => {
if (!state.enabled) {
return { shouldAbort: false, armed: false, remainingAttempts: 0 };
}
if (state.remainingAttempts <= 0) {
return { shouldAbort: false, armed: false, remainingAttempts: 0 };
}
@@ -69,10 +72,6 @@ export function createPostCompactionLoopGuard(
state.history.push(call);
const armedAfter = state.remainingAttempts > 0;
if (!state.enabled) {
return { shouldAbort: false, armed: armedAfter, remainingAttempts: state.remainingAttempts };
}
const matches = state.history.filter(
(entry) =>
entry.toolName === call.toolName &&
@@ -125,4 +124,14 @@ export class PostCompactionLoopPersistedError extends Error {
this.count = details.count;
this.toolName = details.toolName;
}
static fromVerdict(
verdict: Extract<PostCompactionGuardVerdict, { shouldAbort: true }>,
): PostCompactionLoopPersistedError {
return new PostCompactionLoopPersistedError(verdict.message, {
detector: verdict.detector,
count: verdict.count,
toolName: verdict.toolName,
});
}
}

View File

@@ -659,7 +659,7 @@ export const FIELD_HELP: Record<string, string> = {
"Enable known poll tool no-progress loop detection (default: true).",
"tools.loopDetection.detectors.pingPong": "Enable ping-pong loop detection (default: true).",
"tools.loopDetection.postCompactionGuard.enabled":
"Enable the post-compaction loop guard (default: true). When the runner has just retried a prompt after auto-compaction, this guard aborts the run if the agent emits the same (tool, args, result) windowSize times. Targets the failure mode where context-overflow + compaction does not break a tool-call loop.",
"Enable the post-compaction loop guard that aborts the run when the agent repeats the same (tool, args, result) triple windowSize times immediately after auto-compaction-retry (default: true).",
"tools.loopDetection.postCompactionGuard.windowSize":
"Number of post-compaction attempts during which the guard stays armed (default: 3). Lower values are stricter; higher values give the agent more attempts before abort.",
"tools.exec.notifyOnExit":