fix(agent): skipped compaction timeout retries

Stopped auto context-full maintenance from retrying repeated summarization timeouts on the same model before fallback. Added a regression test for timeout fast-fallback behavior.\n\nFixes #2913
This commit is contained in:
roboomp
2026-06-18 00:25:17 +00:00
parent 7251ca16d4
commit 492c528aa3
3 changed files with 72 additions and 14 deletions
+1
View File
@@ -42,6 +42,7 @@
- Fixed the `tools.format` setting schema so `minimax` can be selected as an owned tool-calling dialect, and taught auto mode to route tool-less MiniMax-family models to the MiniMax owned dialect. ([#2759](https://github.com/can1357/oh-my-pi/issues/2759))
- Fixed WSL2 TUI stutter by adding a `git.enabled` setting and skipping footer/status-line git probes when disabled or when no git-backed status segment is visible ([#2847](https://github.com/can1357/oh-my-pi/issues/2847)).
- Fixed JSON-mode startup notices (export/resume/session-picker messages) writing to stdout before the JSON event stream; they now route to stderr so stdout remains newline-delimited JSON.
- Fixed auto context-full maintenance to stop retrying the same summarization timeout before falling back to the next compaction model ([#2913](https://github.com/can1357/oh-my-pi/issues/2913)).
## [16.0.4] - 2026-06-17
@@ -9225,7 +9225,9 @@ export class AgentSession {
let compactResult: CompactionResult | undefined;
let lastError: unknown;
for (const candidate of candidates) {
for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
const candidate = candidates[candidateIndex];
const hasMoreCandidates = candidateIndex < candidates.length - 1;
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
if (!apiKey) continue;
@@ -9264,6 +9266,20 @@ export class AgentSession {
lastError = this.#buildCompactionAuthError();
break;
}
if (this.#isCompactionSummarizationTimeoutMessage(message)) {
logger.warn(
hasMoreCandidates
? "Auto-compaction summarization timed out, trying next model"
: "Auto-compaction summarization timed out, not retrying same model",
{
error: message,
model: `${candidate.provider}/${candidate.id}`,
},
);
lastError = error;
break;
}
const retryAfterMs = this.#parseRetryAfterMsFromError(message);
const shouldRetry =
retrySettings.enabled &&
@@ -9281,19 +9297,15 @@ export class AgentSession {
// If retry delay is too long (>30s), try next candidate instead of waiting
const maxAcceptableDelayMs = 30_000;
if (delayMs > maxAcceptableDelayMs) {
const hasMoreCandidates = candidates.indexOf(candidate) < candidates.length - 1;
if (hasMoreCandidates) {
logger.warn("Auto-compaction retry delay too long, trying next model", {
delayMs,
retryAfterMs,
error: message,
model: `${candidate.provider}/${candidate.id}`,
});
lastError = error;
break; // Exit retry loop, continue to next candidate
}
// No more candidates - we have to wait
if (delayMs > maxAcceptableDelayMs && hasMoreCandidates) {
logger.warn("Auto-compaction retry delay too long, trying next model", {
delayMs,
retryAfterMs,
error: message,
model: `${candidate.provider}/${candidate.id}`,
});
lastError = error;
break; // Exit retry loop, continue to next candidate
}
attempt++;
@@ -9709,6 +9721,10 @@ export class AgentSession {
return /anthropic stream envelope error:/i.test(errorMessage) && /before message_start/i.test(errorMessage);
}
#isCompactionSummarizationTimeoutMessage(errorMessage: string): boolean {
return /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream stall\b/i.test(errorMessage);
}
#isTransientTransportErrorMessage(errorMessage: string): boolean {
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
// service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed,
@@ -239,6 +239,47 @@ describe("AgentSession handoff", () => {
expect(events).toContainEqual({ type: "auto_compaction_start", reason: "threshold", action: "context-full" });
expect(events.some(event => event.type === "auto_compaction_end" && event.aborted === false)).toBe(true);
});
it("falls back after one auto-compaction timeout instead of retrying the same model", async () => {
session.settings.set("compaction.thresholdTokens", 50);
session.settings.set("compaction.keepRecentTokens", 1);
session.settings.set("contextPromotion.enabled", false);
session.settings.set("retry.baseDelayMs", 1);
let firstCandidateKey: string | undefined;
let fallbackCandidateKey: string | undefined;
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, candidate) => {
const candidateKey = `${candidate.provider}/${candidate.id}`;
firstCandidateKey ??= candidateKey;
if (candidateKey === firstCandidateKey) {
throw new Error("Summarization failed: The operation timed out.");
}
fallbackCandidateKey = candidateKey;
return {
summary: "fallback compacted",
shortSummary: undefined,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: {},
};
});
const promptSpy = vi.spyOn(session.agent, "prompt").mockImplementation(async () => {
expect(sessionManager.getEntries().some(entry => entry.type === "compaction")).toBe(true);
});
await session.prompt("pending prompt ".repeat(120));
await waitFor(
() =>
fallbackCandidateKey !== undefined &&
events.some(event => event.type === "auto_compaction_end" && event.aborted === false),
);
expect(
compactSpy.mock.calls.filter(call => `${call[1].provider}/${call[1].id}` === firstCandidateKey),
).toHaveLength(1);
expect(fallbackCandidateKey).toBeDefined();
expect(promptSpy).toHaveBeenCalledTimes(1);
});
it("keeps pre-prompt context-full checks aligned with provider-anchored usage", async () => {
await session.dispose();
authStorage.setRuntimeApiKey("openai", "test-key");