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
+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));
}