diff --git a/src/agents/pi-embedded-runner/post-compaction-loop-guard.test.ts b/src/agents/pi-embedded-runner/post-compaction-loop-guard.test.ts index a665c964665..6fa832569f1 100644 --- a/src/agents/pi-embedded-runner/post-compaction-loop-guard.test.ts +++ b/src/agents/pi-embedded-runner/post-compaction-loop-guard.test.ts @@ -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); + }); +}); diff --git a/src/agents/pi-embedded-runner/post-compaction-loop-guard.ts b/src/agents/pi-embedded-runner/post-compaction-loop-guard.ts index ef8b5cc6120..b5f7f52ae2f 100644 --- a/src/agents/pi-embedded-runner/post-compaction-loop-guard.ts +++ b/src/agents/pi-embedded-runner/post-compaction-loop-guard.ts @@ -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, + ): PostCompactionLoopPersistedError { + return new PostCompactionLoopPersistedError(verdict.message, { + detector: verdict.detector, + count: verdict.count, + toolName: verdict.toolName, + }); + } } diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 5606dc7303d..c5d34294f8a 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -659,7 +659,7 @@ export const FIELD_HELP: Record = { "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":