feat(coding-agent): added retry logic with exponential backoff
- Added retry logic with exponential backoff and model fallback for auto-compaction failures. - Added support for pi/<role> model aliases and automatic model inheritance for subtasks. - Enhanced error messages with retry-after timing from rate limit headers across all providers. - Fixed image attachments being dropped when steering messages are queued during streaming. - Changed edit tool to merge call and result displays into single block. - Changed model override behavior to persist in settings when explicitly set via CLI.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Enhanced error messages to include retry-after timing information from API rate limit headers
|
||||
|
||||
## [3.34.0] - 2026-01-09
|
||||
|
||||
@@ -314,4 +317,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
|
||||
|
||||
## [0.9.4] - 2025-11-26
|
||||
|
||||
Initial release with multi-provider LLM support.
|
||||
Initial release with multi-provider LLM support.
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
} from "../types";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { parseStreamingJson } from "../utils/json-parse";
|
||||
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
|
||||
|
||||
import { transformMessages } from "./transorm-messages";
|
||||
@@ -279,7 +280,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
||||
} catch (error) {
|
||||
for (const block of output.content) delete (block as any).index;
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
output.errorMessage = formatErrorMessageWithRetryAfter(error);
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ToolCall,
|
||||
} from "../types";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
|
||||
import {
|
||||
convertMessages,
|
||||
@@ -638,7 +639,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
}
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
output.errorMessage = formatErrorMessageWithRetryAfter(error);
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ToolCall,
|
||||
} from "../types";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
|
||||
import type { GoogleThinkingLevel } from "./google-gemini-cli";
|
||||
import {
|
||||
@@ -262,7 +263,7 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
|
||||
}
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
output.errorMessage = formatErrorMessageWithRetryAfter(error);
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ToolCall,
|
||||
} from "../types";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
|
||||
import type { GoogleThinkingLevel } from "./google-gemini-cli";
|
||||
import {
|
||||
@@ -250,7 +251,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
|
||||
}
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
output.errorMessage = formatErrorMessageWithRetryAfter(error);
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
} from "../types";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { parseStreamingJson } from "../utils/json-parse";
|
||||
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
|
||||
import {
|
||||
CODEX_BASE_URL,
|
||||
@@ -151,7 +152,9 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
|
||||
|
||||
if (!response.ok) {
|
||||
const info = await parseCodexError(response);
|
||||
throw new Error(info.friendlyMessage || info.message);
|
||||
const error = new Error(info.friendlyMessage || info.message);
|
||||
(error as { headers?: Headers }).headers = response.headers;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
@@ -362,7 +365,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
|
||||
} catch (error) {
|
||||
for (const block of output.content) delete (block as { index?: number }).index;
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
output.errorMessage = formatErrorMessageWithRetryAfter(error);
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
} from "../types";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { parseStreamingJson } from "../utils/json-parse";
|
||||
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
|
||||
import { transformMessages } from "./transorm-messages";
|
||||
|
||||
@@ -306,7 +307,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
} catch (error) {
|
||||
for (const block of output.content) delete (block as any).index;
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
output.errorMessage = formatErrorMessageWithRetryAfter(error);
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
} from "../types";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { parseStreamingJson } from "../utils/json-parse";
|
||||
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
|
||||
import { transformMessages } from "./transorm-messages";
|
||||
|
||||
@@ -303,7 +304,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
||||
} catch (error) {
|
||||
for (const block of output.content) delete (block as any).index;
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
output.errorMessage = formatErrorMessageWithRetryAfter(error);
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
export type HeadersLike = Headers | Record<string, string | undefined> | undefined | null;
|
||||
|
||||
const RETRY_AFTER_HINT = "retry-after-ms=";
|
||||
|
||||
export function formatErrorMessageWithRetryAfter(error: unknown, headers?: HeadersLike): string {
|
||||
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
if (message.includes(RETRY_AFTER_HINT)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const retryAfterMs = getRetryAfterMsFromHeaders(headers ?? getHeadersFromError(error));
|
||||
if (retryAfterMs === undefined) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return `${message} ${RETRY_AFTER_HINT}${retryAfterMs}`;
|
||||
}
|
||||
|
||||
export function getRetryAfterMsFromHeaders(headers: HeadersLike): number | undefined {
|
||||
if (!headers) return undefined;
|
||||
|
||||
const retryAfter = parseRetryAfterHeader(getHeaderValue(headers, "retry-after"));
|
||||
const resetMs = parseResetHeader(getHeaderValue(headers, "x-ratelimit-reset-ms"), "ms");
|
||||
const resetSeconds = parseResetHeader(getHeaderValue(headers, "x-ratelimit-reset"), "s");
|
||||
|
||||
const candidates = [retryAfter, resetMs, resetSeconds].filter((value): value is number => value !== undefined);
|
||||
if (candidates.length === 0) return undefined;
|
||||
return Math.max(...candidates);
|
||||
}
|
||||
|
||||
function getHeadersFromError(error: unknown): HeadersLike {
|
||||
if (!error || typeof error !== "object") return undefined;
|
||||
const record = error as { headers?: unknown; response?: { headers?: unknown }; cause?: unknown };
|
||||
const direct = extractHeaders(record.headers) ?? extractHeaders(record.response?.headers);
|
||||
if (direct) return direct;
|
||||
if (record.cause) return getHeadersFromError(record.cause);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractHeaders(value: unknown): HeadersLike {
|
||||
if (!value) return undefined;
|
||||
if (value instanceof Headers) return value;
|
||||
if (typeof value === "object") return value as Record<string, string | undefined>;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getHeaderValue(headers: Headers | Record<string, string | undefined>, name: string): string | undefined {
|
||||
if (headers instanceof Headers) {
|
||||
const value = headers.get(name);
|
||||
return value ?? undefined;
|
||||
}
|
||||
|
||||
const target = name.toLowerCase();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === target && typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseRetryAfterHeader(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric)) {
|
||||
if (numeric <= 0) return undefined;
|
||||
return Math.ceil(numeric * 1000);
|
||||
}
|
||||
|
||||
const dateMs = Date.parse(trimmed);
|
||||
if (!Number.isNaN(dateMs)) {
|
||||
const delay = dateMs - Date.now();
|
||||
return delay > 0 ? Math.ceil(delay) : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseResetHeader(value: string | undefined, unit: "ms" | "s"): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return undefined;
|
||||
|
||||
const nowMs = Date.now();
|
||||
let targetMs: number | undefined;
|
||||
|
||||
if (unit === "ms") {
|
||||
if (numeric > 1e12) {
|
||||
targetMs = numeric;
|
||||
} else if (numeric > 1e9) {
|
||||
targetMs = numeric * 1000;
|
||||
} else {
|
||||
return Math.ceil(numeric);
|
||||
}
|
||||
} else {
|
||||
if (numeric > 1e12) {
|
||||
targetMs = numeric;
|
||||
} else if (numeric > 1e9) {
|
||||
targetMs = numeric * 1000;
|
||||
} else {
|
||||
return Math.ceil(numeric * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetMs <= nowMs) return undefined;
|
||||
return Math.ceil(targetMs - nowMs);
|
||||
}
|
||||
@@ -1,6 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added retry logic with exponential backoff for auto-compaction failures
|
||||
- Added fallback to alternative models when auto-compaction fails with the primary model
|
||||
- Added support for `pi/<role>` model aliases in task tool (e.g., `pi/slow`, `pi/default`)
|
||||
- Added visual cycle indicator when switching between role models showing available roles
|
||||
- Added automatic model inheritance for subtasks when parent uses default model
|
||||
- Added `--` separator in grep tool to prevent pattern interpretation as flags
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed role model cycling to remember last selected role instead of matching current model
|
||||
- Changed edit tool to merge call and result displays into single block
|
||||
- Changed model override behavior to persist in settings when explicitly set via CLI
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed retry-after parsing from error messages supporting multiple header formats (retry-after, retry-after-ms, x-ratelimit-reset)
|
||||
- Fixed image attachments being dropped when steering/follow-up messages are queued during streaming
|
||||
- Fixed image auto-resize not applying to clipboard images before sending
|
||||
- Fixed clipboard image attachments being dropped when steering/follow-up messages are queued while streaming
|
||||
- Fixed clipboard image attachments ignoring the auto-resize setting before sending
|
||||
|
||||
## [3.34.0] - 2026-01-09
|
||||
|
||||
@@ -1954,4 +1976,4 @@ Initial public release.
|
||||
- Git branch display in footer
|
||||
- Message queueing during streaming responses
|
||||
- OAuth integration for Gmail and Google Calendar access
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
@@ -459,7 +459,10 @@ export class AgentSession {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") return content;
|
||||
const textBlocks = content.filter((c) => c.type === "text");
|
||||
return textBlocks.map((c) => (c as TextContent).text).join("");
|
||||
const text = textBlocks.map((c) => (c as TextContent).text).join("");
|
||||
if (text.length > 0) return text;
|
||||
const hasImages = content.some((c) => c.type === "image");
|
||||
return hasImages ? "[Image]" : "";
|
||||
}
|
||||
|
||||
/** Find the last assistant message in agent state (including aborted ones) */
|
||||
@@ -722,9 +725,9 @@ export class AgentSession {
|
||||
);
|
||||
}
|
||||
if (options.streamingBehavior === "followUp") {
|
||||
await this._queueFollowUp(expandedText);
|
||||
await this._queueFollowUp(expandedText, options?.images);
|
||||
} else {
|
||||
await this._queueSteer(expandedText);
|
||||
await this._queueSteer(expandedText, options?.images);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -953,11 +956,16 @@ export class AgentSession {
|
||||
/**
|
||||
* Internal: Queue a steering message (already expanded, no extension command check).
|
||||
*/
|
||||
private async _queueSteer(text: string): Promise<void> {
|
||||
this._steeringMessages.push(text);
|
||||
private async _queueSteer(text: string, images?: ImageContent[]): Promise<void> {
|
||||
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
||||
this._steeringMessages.push(displayText);
|
||||
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
|
||||
if (images && images.length > 0) {
|
||||
content.push(...images);
|
||||
}
|
||||
this.agent.steer({
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
@@ -965,11 +973,16 @@ export class AgentSession {
|
||||
/**
|
||||
* Internal: Queue a follow-up message (already expanded, no extension command check).
|
||||
*/
|
||||
private async _queueFollowUp(text: string): Promise<void> {
|
||||
this._followUpMessages.push(text);
|
||||
private async _queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
|
||||
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
||||
this._followUpMessages.push(displayText);
|
||||
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
|
||||
if (images && images.length > 0) {
|
||||
content.push(...images);
|
||||
}
|
||||
this.agent.followUp({
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
@@ -1175,7 +1188,7 @@ export class AgentSession {
|
||||
|
||||
/**
|
||||
* Cycle through configured role models in a fixed order.
|
||||
* Skips missing roles and deduplicates models.
|
||||
* Skips missing roles.
|
||||
* @param roleOrder - Order of roles to cycle through (e.g., ["slow", "default", "smol"])
|
||||
* @param options - Optional settings: `temporary` to not persist to settings
|
||||
*/
|
||||
@@ -1189,7 +1202,6 @@ export class AgentSession {
|
||||
const currentModel = this.model;
|
||||
if (!currentModel) return undefined;
|
||||
const roleModels: Array<{ role: string; model: Model<any> }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const role of roleOrder) {
|
||||
const roleModelStr =
|
||||
@@ -1208,15 +1220,15 @@ export class AgentSession {
|
||||
}
|
||||
if (!match) continue;
|
||||
|
||||
const key = `${match.provider}/${match.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
roleModels.push({ role, model: match });
|
||||
}
|
||||
|
||||
if (roleModels.length <= 1) return undefined;
|
||||
|
||||
let currentIndex = roleModels.findIndex((entry) => modelsAreEqual(entry.model, currentModel));
|
||||
const lastRole = this.sessionManager.getLastModelChangeRole();
|
||||
let currentIndex = lastRole
|
||||
? roleModels.findIndex((entry) => entry.role === lastRole)
|
||||
: roleModels.findIndex((entry) => modelsAreEqual(entry.model, currentModel));
|
||||
if (currentIndex === -1) currentIndex = 0;
|
||||
|
||||
const nextIndex = (currentIndex + 1) % roleModels.length;
|
||||
@@ -1558,6 +1570,60 @@ export class AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private _getModelKey(model: Model<any>): string {
|
||||
return `${model.provider}/${model.id}`;
|
||||
}
|
||||
|
||||
private _resolveRoleModel(
|
||||
role: string,
|
||||
availableModels: Model<any>[],
|
||||
currentModel: Model<any> | undefined,
|
||||
): Model<any> | undefined {
|
||||
const roleModelStr =
|
||||
role === "default"
|
||||
? (this.settingsManager.getModelRole("default") ??
|
||||
(currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined))
|
||||
: this.settingsManager.getModelRole(role);
|
||||
|
||||
if (!roleModelStr) return undefined;
|
||||
|
||||
const parsed = parseModelString(roleModelStr);
|
||||
if (parsed) {
|
||||
return availableModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
|
||||
}
|
||||
const roleLower = roleModelStr.toLowerCase();
|
||||
return availableModels.find((m) => m.id.toLowerCase() === roleLower);
|
||||
}
|
||||
|
||||
private _getCompactionModelCandidates(availableModels: Model<any>[]): Model<any>[] {
|
||||
const candidates: Model<any>[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const addCandidate = (model: Model<any> | undefined): void => {
|
||||
if (!model) return;
|
||||
const key = this._getModelKey(model);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
candidates.push(model);
|
||||
};
|
||||
|
||||
const currentModel = this.model;
|
||||
addCandidate(this._resolveRoleModel("default", availableModels, currentModel));
|
||||
addCandidate(this._resolveRoleModel("slow", availableModels, currentModel));
|
||||
addCandidate(this._resolveRoleModel("small", availableModels, currentModel));
|
||||
addCandidate(this._resolveRoleModel("smol", availableModels, currentModel));
|
||||
|
||||
const sortedByContext = [...availableModels].sort((a, b) => b.contextWindow - a.contextWindow);
|
||||
for (const model of sortedByContext) {
|
||||
if (!seen.has(this._getModelKey(model))) {
|
||||
addCandidate(model);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Run auto-compaction with events.
|
||||
*/
|
||||
@@ -1577,8 +1643,8 @@ export class AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = await this._modelRegistry.getApiKey(this.model);
|
||||
if (!apiKey) {
|
||||
const availableModels = this._modelRegistry.getAvailable();
|
||||
if (availableModels.length === 0) {
|
||||
this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
|
||||
return;
|
||||
}
|
||||
@@ -1626,14 +1692,68 @@ export class AgentSession {
|
||||
tokensBefore = hookCompaction.tokensBefore;
|
||||
details = hookCompaction.details;
|
||||
} else {
|
||||
// Generate compaction result
|
||||
const compactResult = await compact(
|
||||
preparation,
|
||||
this.model,
|
||||
apiKey,
|
||||
undefined,
|
||||
this._autoCompactionAbortController.signal,
|
||||
);
|
||||
const candidates = this._getCompactionModelCandidates(availableModels);
|
||||
const retrySettings = this.settingsManager.getRetrySettings();
|
||||
let compactResult: CompactionResult | undefined;
|
||||
let lastError: unknown;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const apiKey = await this._modelRegistry.getApiKey(candidate);
|
||||
if (!apiKey) continue;
|
||||
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
compactResult = await compact(
|
||||
preparation,
|
||||
candidate,
|
||||
apiKey,
|
||||
undefined,
|
||||
this._autoCompactionAbortController.signal,
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (this._autoCompactionAbortController.signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const retryAfterMs = this._parseRetryAfterMsFromError(message);
|
||||
const shouldRetry =
|
||||
retrySettings.enabled &&
|
||||
attempt < retrySettings.maxRetries &&
|
||||
(retryAfterMs !== undefined || this._isRetryableErrorMessage(message));
|
||||
if (!shouldRetry) {
|
||||
lastError = error;
|
||||
break;
|
||||
}
|
||||
|
||||
const baseDelayMs = retrySettings.baseDelayMs * 2 ** attempt;
|
||||
const delayMs = retryAfterMs !== undefined ? Math.max(baseDelayMs, retryAfterMs) : baseDelayMs;
|
||||
attempt++;
|
||||
logger.warn("Auto-compaction failed, retrying", {
|
||||
attempt,
|
||||
maxRetries: retrySettings.maxRetries,
|
||||
delayMs,
|
||||
retryAfterMs,
|
||||
error: message,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
if (compactResult) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!compactResult) {
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
throw new Error("Compaction failed: no available model");
|
||||
}
|
||||
|
||||
summary = compactResult.summary;
|
||||
firstKeptEntryId = compactResult.firstKeptEntryId;
|
||||
tokensBefore = compactResult.tokensBefore;
|
||||
@@ -1725,12 +1845,61 @@ export class AgentSession {
|
||||
if (isContextOverflow(message, contextWindow)) return false;
|
||||
|
||||
const err = message.errorMessage;
|
||||
return this._isRetryableErrorMessage(err);
|
||||
}
|
||||
|
||||
private _isRetryableErrorMessage(errorMessage: string): boolean {
|
||||
// Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection error
|
||||
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error/i.test(
|
||||
err,
|
||||
errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
private _parseRetryAfterMsFromError(errorMessage: string): number | undefined {
|
||||
const now = Date.now();
|
||||
const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
||||
if (retryAfterMsMatch) {
|
||||
return Math.max(0, Number(retryAfterMsMatch[1]));
|
||||
}
|
||||
|
||||
const retryAfterMatch = /retry-after\s*[:=]\s*([^\s,;]+)/i.exec(errorMessage);
|
||||
if (retryAfterMatch) {
|
||||
const value = retryAfterMatch[1];
|
||||
const seconds = Number(value);
|
||||
if (!Number.isNaN(seconds)) {
|
||||
return Math.max(0, seconds * 1000);
|
||||
}
|
||||
const dateMs = Date.parse(value);
|
||||
if (!Number.isNaN(dateMs)) {
|
||||
return Math.max(0, dateMs - now);
|
||||
}
|
||||
}
|
||||
|
||||
const resetMsMatch = /x-ratelimit-reset-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
||||
if (resetMsMatch) {
|
||||
const resetMs = Number(resetMsMatch[1]);
|
||||
if (!Number.isNaN(resetMs)) {
|
||||
if (resetMs > 1_000_000_000_000) {
|
||||
return Math.max(0, resetMs - now);
|
||||
}
|
||||
return Math.max(0, resetMs);
|
||||
}
|
||||
}
|
||||
|
||||
const resetMatch = /x-ratelimit-reset\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
||||
if (resetMatch) {
|
||||
const resetSeconds = Number(resetMatch[1]);
|
||||
if (!Number.isNaN(resetSeconds)) {
|
||||
if (resetSeconds > 1_000_000_000) {
|
||||
return Math.max(0, resetSeconds * 1000 - now);
|
||||
}
|
||||
return Math.max(0, resetSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle retryable errors with exponential backoff.
|
||||
* @returns true if retry was initiated, false if max retries exceeded or disabled
|
||||
|
||||
@@ -633,6 +633,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
|
||||
time("discoverContextFiles");
|
||||
|
||||
let agent: Agent;
|
||||
let session: AgentSession;
|
||||
|
||||
const toolSession: ToolSession = {
|
||||
cwd,
|
||||
hasUI: options.hasUI ?? false,
|
||||
@@ -643,6 +646,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
getSessionFile: () => sessionManager.getSessionFile() ?? null,
|
||||
getSessionSpawns: () => options.spawns ?? "*",
|
||||
getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
|
||||
getActiveModelString: () => {
|
||||
const activeModel = agent?.state.model;
|
||||
return activeModel ? formatModelString(activeModel) : undefined;
|
||||
},
|
||||
settings: settingsManager,
|
||||
};
|
||||
|
||||
@@ -782,8 +789,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
extensionRunner = new ExtensionRunner(extensionsResult.extensions, cwd, sessionManager, modelRegistry);
|
||||
}
|
||||
|
||||
let agent: Agent;
|
||||
let session: AgentSession;
|
||||
const getSessionContext = () => ({
|
||||
sessionManager,
|
||||
modelRegistry,
|
||||
|
||||
@@ -1338,6 +1338,21 @@ export class SessionManager {
|
||||
return this.leafId ? this.byId.get(this.leafId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent model role from the current session path.
|
||||
* Returns undefined if no model change has been recorded.
|
||||
*/
|
||||
getLastModelChangeRole(): string | undefined {
|
||||
let current = this.getLeafEntry();
|
||||
while (current) {
|
||||
if (current.type === "model_change") {
|
||||
return current.role ?? "default";
|
||||
}
|
||||
current = current.parentId ? this.byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getEntry(id: string): SessionEntry | undefined {
|
||||
return this.byId.get(id);
|
||||
}
|
||||
|
||||
@@ -371,7 +371,8 @@ export class SettingsManager {
|
||||
private settingsPath: string | null;
|
||||
private cwd: string | null;
|
||||
private globalSettings: Settings;
|
||||
private settings: Settings;
|
||||
private overrides: Settings;
|
||||
private settings!: Settings;
|
||||
private persist: boolean;
|
||||
|
||||
private constructor(settingsPath: string | null, cwd: string | null, initialSettings: Settings, persist: boolean) {
|
||||
@@ -379,8 +380,8 @@ export class SettingsManager {
|
||||
this.cwd = cwd;
|
||||
this.persist = persist;
|
||||
this.globalSettings = initialSettings;
|
||||
const projectSettings = this.loadProjectSettings();
|
||||
this.settings = normalizeSettings(deepMergeSettings(this.globalSettings, projectSettings));
|
||||
this.overrides = {};
|
||||
this.rebuildSettings();
|
||||
|
||||
// Apply environment variables from settings
|
||||
this.applyEnvironmentVariables();
|
||||
@@ -474,9 +475,17 @@ export class SettingsManager {
|
||||
return SettingsManager.migrateSettings(merged as Record<string, unknown>);
|
||||
}
|
||||
|
||||
private rebuildSettings(projectSettings?: Settings): void {
|
||||
const resolvedProjectSettings = projectSettings ?? this.loadProjectSettings();
|
||||
this.settings = normalizeSettings(
|
||||
deepMergeSettings(deepMergeSettings(this.globalSettings, resolvedProjectSettings), this.overrides),
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply additional overrides on top of current settings */
|
||||
applyOverrides(overrides: Partial<Settings>): void {
|
||||
this.settings = normalizeSettings(deepMergeSettings(this.settings, overrides));
|
||||
this.overrides = deepMergeSettings(this.overrides, overrides);
|
||||
this.rebuildSettings();
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
@@ -491,9 +500,9 @@ export class SettingsManager {
|
||||
// Save only global settings (project settings are read-only)
|
||||
writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8");
|
||||
|
||||
// Re-merge project settings into active settings
|
||||
// Re-merge project settings into active settings (preserve overrides)
|
||||
const projectSettings = this.loadProjectSettings();
|
||||
this.settings = normalizeSettings(deepMergeSettings(this.globalSettings, projectSettings));
|
||||
this.rebuildSettings(projectSettings);
|
||||
} catch (error) {
|
||||
console.error(`Warning: Could not save settings file: ${error}`);
|
||||
}
|
||||
@@ -523,6 +532,11 @@ export class SettingsManager {
|
||||
this.globalSettings.modelRoles = {};
|
||||
}
|
||||
this.globalSettings.modelRoles[role] = model;
|
||||
|
||||
if (this.overrides.modelRoles && this.overrides.modelRoles[role] !== undefined) {
|
||||
this.overrides.modelRoles[role] = model;
|
||||
}
|
||||
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ function formatMetadataLine(lineCount: number | null, language: string | undefin
|
||||
}
|
||||
|
||||
export const editToolRenderer = {
|
||||
mergeCallAndResult: true,
|
||||
renderCall(args: EditRenderArgs, uiTheme: Theme): Component {
|
||||
const ui = createToolUIKit(uiTheme);
|
||||
const rawPath = args.file_path || args.path || "";
|
||||
|
||||
@@ -196,7 +196,7 @@ export function createGrepTool(session: ToolSession): AgentTool<typeof grepSchem
|
||||
args.push("--type", type);
|
||||
}
|
||||
|
||||
args.push(pattern, searchPath);
|
||||
args.push("--", pattern, searchPath);
|
||||
|
||||
const child: Subprocess = Bun.spawn([rgPath, ...args], {
|
||||
stdin: "ignore",
|
||||
|
||||
@@ -98,6 +98,8 @@ export interface ToolSession {
|
||||
getSessionSpawns: () => string | null;
|
||||
/** Get resolved model string if explicitly set for this session */
|
||||
getModelString?: () => string | undefined;
|
||||
/** Get the current session model string, regardless of how it was chosen */
|
||||
getActiveModelString?: () => string | undefined;
|
||||
/** Settings manager (optional) */
|
||||
settings?: {
|
||||
getImageAutoResize(): boolean;
|
||||
|
||||
@@ -31,6 +31,7 @@ type ToolRenderer = {
|
||||
theme: Theme,
|
||||
args?: unknown,
|
||||
) => Component;
|
||||
mergeCallAndResult?: boolean;
|
||||
};
|
||||
|
||||
export const toolRenderers: Record<string, ToolRenderer> = {
|
||||
|
||||
@@ -135,7 +135,12 @@ export async function createTaskTool(
|
||||
const startTime = Date.now();
|
||||
const { agents, projectAgentsDir } = await discoverAgents(session.cwd);
|
||||
const { agent: agentName, context, model, output: outputSchema } = params;
|
||||
const modelOverride = model ?? session.getModelString?.();
|
||||
|
||||
const isDefaultModelAlias = (value: string | undefined): boolean => {
|
||||
if (!value) return true;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "default" || normalized === "pi/default" || normalized === "omp/default";
|
||||
};
|
||||
|
||||
// Validate agent exists
|
||||
const agent = getAgent(agents, agentName);
|
||||
@@ -156,6 +161,10 @@ export async function createTaskTool(
|
||||
};
|
||||
}
|
||||
|
||||
const shouldInheritSessionModel = model === undefined && isDefaultModelAlias(agent.model);
|
||||
const sessionModel = shouldInheritSessionModel ? session.getActiveModelString?.() : undefined;
|
||||
const modelOverride = model ?? sessionModel ?? session.getModelString?.();
|
||||
|
||||
// Handle empty or missing tasks
|
||||
if (!params.tasks || params.tasks.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - Fuzzy match: "opus" → "p-anthropic/claude-opus-4-5"
|
||||
* - Comma fallback: "gpt, opus" → tries gpt first, then opus
|
||||
* - "default" → undefined (use system default)
|
||||
* - "omp/slow" → configured slow model from settings
|
||||
* - "omp/slow" or "pi/slow" → configured slow model from settings
|
||||
*/
|
||||
|
||||
import { type Settings, settingsCapability } from "../../../capability/settings";
|
||||
@@ -145,9 +145,10 @@ export function resolveModelPattern(pattern: string | undefined, availableModels
|
||||
.filter(Boolean);
|
||||
|
||||
for (const p of patterns) {
|
||||
// Handle omp/<role> aliases - looks up role in settings.modelRoles
|
||||
if (p.toLowerCase().startsWith("omp/")) {
|
||||
const role = p.slice(4); // Remove "omp/" prefix
|
||||
// Handle omp/<role> or pi/<role> aliases - looks up role in settings.modelRoles
|
||||
const lower = p.toLowerCase();
|
||||
if (lower.startsWith("omp/") || lower.startsWith("pi/")) {
|
||||
const role = lower.startsWith("omp/") ? p.slice(4) : p.slice(3);
|
||||
const resolved = resolveOmpAlias(role, models);
|
||||
if (resolved) return resolved;
|
||||
continue; // Role not configured, try next pattern
|
||||
|
||||
@@ -289,6 +289,9 @@ async function buildSessionOptions(
|
||||
process.exit(1);
|
||||
}
|
||||
options.model = model;
|
||||
settingsManager.applyOverrides({
|
||||
modelRoles: { default: `${model.provider}/${model.id}` },
|
||||
});
|
||||
} else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
|
||||
options.model = scopedModels[0].model;
|
||||
}
|
||||
|
||||
@@ -369,20 +369,23 @@ export class ToolExecutionComponent extends Container {
|
||||
this.contentBox.setBgFn(bgFn);
|
||||
this.contentBox.clear();
|
||||
|
||||
// Render call component
|
||||
try {
|
||||
const callComponent = renderer.renderCall(this.args, theme);
|
||||
if (callComponent) {
|
||||
// Ensure component has invalidate() method for Component interface
|
||||
const component = callComponent as any;
|
||||
if (!component.invalidate) {
|
||||
component.invalidate = () => {};
|
||||
const shouldRenderCall = !this.result || !renderer.mergeCallAndResult;
|
||||
if (shouldRenderCall) {
|
||||
// Render call component
|
||||
try {
|
||||
const callComponent = renderer.renderCall(this.args, theme);
|
||||
if (callComponent) {
|
||||
// Ensure component has invalidate() method for Component interface
|
||||
const component = callComponent as any;
|
||||
if (!component.invalidate) {
|
||||
component.invalidate = () => {};
|
||||
}
|
||||
this.contentBox.addChild(component);
|
||||
}
|
||||
this.contentBox.addChild(component);
|
||||
} catch {
|
||||
// Fall back to default on error
|
||||
this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0));
|
||||
}
|
||||
} catch {
|
||||
// Fall back to default on error
|
||||
this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0));
|
||||
}
|
||||
|
||||
// Render result component if we have a result
|
||||
|
||||
@@ -38,6 +38,7 @@ import { VoiceSupervisor } from "../../core/voice-supervisor";
|
||||
import { disableProvider, enableProvider } from "../../discovery";
|
||||
import { getChangelogPath, parseChangelog } from "../../utils/changelog";
|
||||
import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
|
||||
import { resizeImage } from "../../utils/image-resize";
|
||||
import { registerAsyncCleanup } from "../cleanup";
|
||||
import { ArminComponent } from "./components/armin";
|
||||
import { AssistantMessageComponent } from "./components/assistant-message";
|
||||
@@ -1139,7 +1140,9 @@ export class InteractiveMode {
|
||||
if (this.session.isStreaming) {
|
||||
this.editor.addToHistory(text);
|
||||
this.editor.setText("");
|
||||
await this.session.prompt(text, { streamingBehavior: "steer" });
|
||||
const images = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined;
|
||||
this.pendingImages = [];
|
||||
await this.session.prompt(text, { streamingBehavior: "steer", images });
|
||||
this.updatePendingMessagesDisplay();
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
@@ -1504,22 +1507,24 @@ export class InteractiveMode {
|
||||
* If multiple status messages are emitted back-to-back (without anything else being added to the chat),
|
||||
* we update the previous status line instead of appending new ones to avoid log spam.
|
||||
*/
|
||||
private showStatus(message: string): void {
|
||||
private showStatus(message: string, options?: { dim?: boolean }): void {
|
||||
if (this.isBackgrounded) {
|
||||
return;
|
||||
}
|
||||
const children = this.chatContainer.children;
|
||||
const last = children.length > 0 ? children[children.length - 1] : undefined;
|
||||
const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
|
||||
const useDim = options?.dim ?? true;
|
||||
const rendered = useDim ? theme.fg("dim", message) : message;
|
||||
|
||||
if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) {
|
||||
this.lastStatusText.setText(theme.fg("dim", message));
|
||||
this.lastStatusText.setText(rendered);
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const spacer = new Spacer(1);
|
||||
const text = new Text(theme.fg("dim", message), 1, 0);
|
||||
const text = new Text(rendered, 1, 0);
|
||||
this.chatContainer.addChild(spacer);
|
||||
this.chatContainer.addChild(text);
|
||||
this.lastStatusSpacer = spacer;
|
||||
@@ -1822,10 +1827,24 @@ export class InteractiveMode {
|
||||
try {
|
||||
const image = await readImageFromClipboard();
|
||||
if (image) {
|
||||
let imageData = image;
|
||||
if (this.settingsManager.getImageAutoResize()) {
|
||||
try {
|
||||
const resized = await resizeImage({
|
||||
type: "image",
|
||||
data: image.data,
|
||||
mimeType: image.mimeType,
|
||||
});
|
||||
imageData = { data: resized.data, mimeType: resized.mimeType };
|
||||
} catch {
|
||||
imageData = image;
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingImages.push({
|
||||
type: "image",
|
||||
data: image.data,
|
||||
mimeType: image.mimeType,
|
||||
data: imageData.data,
|
||||
mimeType: imageData.mimeType,
|
||||
});
|
||||
// Insert styled placeholder at cursor like Claude does
|
||||
const imageNum = this.pendingImages.length;
|
||||
@@ -1980,7 +1999,8 @@ export class InteractiveMode {
|
||||
|
||||
private async cycleRoleModel(options?: { temporary?: boolean }): Promise<void> {
|
||||
try {
|
||||
const result = await this.session.cycleRoleModels(["slow", "default", "smol"], options);
|
||||
const roleOrder = ["slow", "default", "smol"];
|
||||
const result = await this.session.cycleRoleModels(roleOrder, options);
|
||||
if (!result) {
|
||||
this.showStatus("Only one role model available");
|
||||
return;
|
||||
@@ -1989,10 +2009,24 @@ export class InteractiveMode {
|
||||
this.statusLine.invalidate();
|
||||
this.updateEditorBorderColor();
|
||||
const roleLabel = result.role === "default" ? "default" : result.role;
|
||||
const roleLabelStyled = theme.bold(theme.fg("accent", roleLabel));
|
||||
const thinkingStr =
|
||||
result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
|
||||
const tempLabel = options?.temporary ? " (temporary)" : "";
|
||||
this.showStatus(`Switched to ${roleLabel}: ${result.model.name || result.model.id}${thinkingStr}${tempLabel}`);
|
||||
const cycleSeparator = theme.fg("dim", " > ");
|
||||
const cycleLabel = roleOrder
|
||||
.map((role) => {
|
||||
if (role === result.role) {
|
||||
return theme.bold(theme.fg("accent", role));
|
||||
}
|
||||
return theme.fg("muted", role);
|
||||
})
|
||||
.join(cycleSeparator);
|
||||
const orderLabel = ` (cycle: ${cycleLabel})`;
|
||||
this.showStatus(
|
||||
`Switched to ${roleLabelStyled}: ${result.model.name || result.model.id}${thinkingStr}${tempLabel}${orderLabel}`,
|
||||
{ dim: false },
|
||||
);
|
||||
} catch (error) {
|
||||
this.showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user