diff --git a/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts b/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts index 24b7720ed81..b92c31ba602 100644 --- a/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts @@ -231,6 +231,45 @@ describe("timeout-triggered compaction", () => { expect(mockedCompactDirect).not.toHaveBeenCalled(); }); + it("falls through to failover rotation after max timeout compaction attempts", async () => { + // First attempt: timeout with high usage (150k / 200k = 75%) + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { total: 150000 }, + } as never, + }), + ); + // Compaction succeeds on first timeout + mockedCompactDirect.mockResolvedValueOnce( + makeCompactionSuccess({ + summary: "timeout recovery compaction", + tokensBefore: 150000, + tokensAfter: 80000, + }), + ); + // Second attempt after compaction: also times out with high usage + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { total: 140000 }, + } as never, + }), + ); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + // Compaction was only attempted once (first timeout); second timeout + // should NOT trigger compaction because the counter is exhausted. + expect(mockedCompactDirect).toHaveBeenCalledTimes(1); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + // Falls through to timeout error payload (failover rotation path) + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("timed out"); + }); + it("catches thrown errors from contextEngine.compact during timeout recovery", async () => { // Timeout with high usage mockedRunEmbeddedAttempt.mockResolvedValueOnce( diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 3309a7af1fb..f3b47d7dbb5 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -815,6 +815,7 @@ export async function runEmbeddedPiAgent( } }; + const MAX_TIMEOUT_COMPACTION_ATTEMPTS = 1; const MAX_OVERFLOW_COMPACTION_ATTEMPTS = 3; const MAX_RUN_LOOP_ITERATIONS = resolveMaxRunRetryIterations(profileCandidates.length); let overflowCompactionAttempts = 0; @@ -827,6 +828,7 @@ export async function runEmbeddedPiAgent( let autoCompactionCount = 0; let runLoopIterations = 0; let overloadFailoverAttempts = 0; + let timeoutCompactionAttempts = 0; const maybeMarkAuthProfileFailure = async (failure: { profileId?: string; reason?: AuthProfileFailureReason | null; @@ -1098,7 +1100,11 @@ export async function runEmbeddedPiAgent( if (timedOut && !aborted && !timedOutDuringCompaction) { const tokenUsedRatio = lastTurnTotal != null && ctxInfo.tokens > 0 ? lastTurnTotal / ctxInfo.tokens : 0; - if (tokenUsedRatio > 0.65) { + if (timeoutCompactionAttempts >= MAX_TIMEOUT_COMPACTION_ATTEMPTS) { + log.warn( + `[timeout-compaction] already compacted ${timeoutCompactionAttempts} time(s) for timeouts; falling through to failover rotation`, + ); + } else if (tokenUsedRatio > 0.65) { const timeoutDiagId = createCompactionDiagId(); log.warn( `[timeout-compaction] LLM timed out with high context usage (${Math.round(tokenUsedRatio * 100)}%); ` + @@ -1148,6 +1154,7 @@ export async function runEmbeddedPiAgent( await runOwnsCompactionAfterHook("timeout recovery", timeoutCompactResult); if (timeoutCompactResult.compacted) { autoCompactionCount += 1; + timeoutCompactionAttempts += 1; log.info( `[timeout-compaction] compaction succeeded for ${provider}/${modelId}; retrying prompt`, );