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