fix(agents): abort post-compaction loops out-of-band

This commit is contained in:
Peter Steinberger
2026-05-05 01:27:17 +01:00
parent 5dfaed1846
commit 7168896fdf
2 changed files with 45 additions and 9 deletions

View File

@@ -153,20 +153,26 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => {
});
});
it("aborts the run with PostCompactionLoopPersistedError when identical (tool, args, result) repeats windowSize times after compaction", async () => {
it("aborts the attempt out-of-band when identical (tool, args, result) repeats windowSize times after compaction", async () => {
const overflowError = makeOverflowError();
let attemptReturned = false;
let attemptSignalAborted = false;
let attemptSignalReason: unknown;
// Attempt 1: overflow → triggers compaction.
mockedRunEmbeddedAttempt.mockImplementationOnce(async () =>
makeAttemptResult({ promptError: overflowError }),
);
// Attempt 2: post-compaction. The live wrapped-tool path records each
// outcome while the prompt is still running; the third identical result
// aborts before the attempt can return.
// outcome while the prompt is still running. The third identical result
// must not rely on throwing out of tool execution (the dependency converts
// tool errors into tool results); instead it aborts the attempt signal and
// the runner raises the persisted-loop error after the attempt unwinds.
mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams: unknown) => {
const onToolOutcome = (attemptParams as { onToolOutcome?: ToolOutcomeObserver })
.onToolOutcome;
const { abortSignal, onToolOutcome } = attemptParams as {
abortSignal?: AbortSignal;
onToolOutcome?: ToolOutcomeObserver;
};
for (let i = 0; i < 3; i += 1) {
await executeWrappedToolOutcome(
"gateway",
@@ -175,6 +181,8 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => {
onToolOutcome,
);
}
attemptSignalAborted = abortSignal?.aborted ?? false;
attemptSignalReason = abortSignal?.reason;
attemptReturned = true;
return makeAttemptResult({
promptError: null,
@@ -196,7 +204,9 @@ describe("post-compaction loop guard wired into runEmbeddedPiAgent", () => {
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(attemptReturned).toBe(false);
expect(attemptReturned).toBe(true);
expect(attemptSignalAborted).toBe(true);
expect(attemptSignalReason).toBeInstanceOf(PostCompactionLoopPersistedError);
});
it("does not abort when the result hash changes across post-compaction attempts (progress was made)", async () => {

View File

@@ -799,12 +799,15 @@ export async function runEmbeddedPiAgent(
resolvedLoopDetectionConfig?.postCompactionGuard,
{ enabled: resolvedLoopDetectionConfig?.enabled !== false },
);
let postCompactionAbortController: AbortController | undefined;
let postCompactionAbortError: PostCompactionLoopPersistedError | undefined;
const observePostCompactionToolOutcome = (
observation: PostCompactionGuardObservation,
): void => {
const verdict = postCompactionGuard.observe(observation);
if (verdict.shouldAbort) {
throw PostCompactionLoopPersistedError.fromVerdict(verdict);
postCompactionAbortError ??= PostCompactionLoopPersistedError.fromVerdict(verdict);
postCompactionAbortController?.abort(postCompactionAbortError);
}
};
let lastRetryFailoverReason: FailoverReason | null = null;
@@ -1099,6 +1102,17 @@ export async function runEmbeddedPiAgent(
startupStagesEmitted = true;
}
const attemptAbortController = new AbortController();
postCompactionAbortController = attemptAbortController;
const parentAbortSignal = params.abortSignal;
const relayParentAbort = (): void => {
attemptAbortController.abort(parentAbortSignal?.reason);
};
if (parentAbortSignal?.aborted) {
relayParentAbort();
} else {
parentAbortSignal?.addEventListener("abort", relayParentAbort, { once: true });
}
const rawAttempt = await runEmbeddedAttemptWithBackend({
sessionId: activeSessionId,
sessionKey: resolvedSessionKey,
@@ -1177,7 +1191,7 @@ export async function runEmbeddedPiAgent(
bashElevated: params.bashElevated,
timeoutMs: params.timeoutMs,
runId: params.runId,
abortSignal: params.abortSignal,
abortSignal: attemptAbortController.signal,
replyOperation: params.replyOperation,
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
@@ -1216,7 +1230,19 @@ export async function runEmbeddedPiAgent(
bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1],
suppressNextUserMessagePersistence,
onUserMessagePersisted,
});
})
.catch((err: unknown): never => {
throw postCompactionAbortError ?? err;
})
.finally(() => {
parentAbortSignal?.removeEventListener?.("abort", relayParentAbort);
if (postCompactionAbortController === attemptAbortController) {
postCompactionAbortController = undefined;
}
});
if (postCompactionAbortError) {
throw postCompactionAbortError;
}
const attempt = normalizeEmbeddedRunAttemptResult(rawAttempt);
const {