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:
can1357
2026-01-10 00:43:56 +01:00
parent 86ef2bd34b
commit 6e7ca2fc14
23 changed files with 471 additions and 70 deletions
+4 -1
View File
@@ -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.
+2 -1
View File
@@ -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();
}
+2 -1
View File
@@ -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();
}
+2 -1
View File
@@ -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();
}
+110
View File
@@ -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);
}
+23 -1
View File
@@ -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
+195 -26
View File
@@ -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
+7 -2
View File
@@ -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 || "";
+1 -1
View File
@@ -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
+3
View File
@@ -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));
}