Skip to content

Commit 8357fee

Browse files
committed
Harden Agent mode: plan persistence, transient-error retry, error cards
Persist the agent's set_plan checklist on the session so reopening a chat mid-task shows where the plan stood; silently retry a completion once when the failure is transient (network blip, rate limit, 5xx) while still failing fast on auth/validation errors; and render failed tool results as visually distinct error cards so a broken step is scannable instead of buried in result bodies.
1 parent 84fd280 commit 8357fee

6 files changed

Lines changed: 72 additions & 6 deletions

File tree

app/src/sessions-store.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export interface ChatSession {
1616
systemPrompt?: string | null;
1717
agentMode?: boolean;
1818
agentWorkspace?: string | null;
19+
// The agent's last set_plan checklist for this conversation, persisted so
20+
// reopening a chat mid-task shows where the plan stood.
21+
planSteps?: { text: string; done: boolean }[];
1922
tags?: string[];
2023
createdAt: string;
2124
updatedAt: string;
@@ -63,7 +66,7 @@ export function updateSession(
6366
partial: Partial<
6467
Pick<
6568
ChatSession,
66-
"title" | "model" | "messages" | "params" | "projectId" | "systemPrompt" | "agentMode" | "agentWorkspace" | "tags"
69+
"title" | "model" | "messages" | "params" | "projectId" | "systemPrompt" | "agentMode" | "agentWorkspace" | "planSteps" | "tags"
6770
>
6871
>
6972
): ChatSession | null {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, it, expect } from "vitest";
2+
import { isTransientError } from "./transient-errors";
3+
4+
describe("isTransientError", () => {
5+
it("treats network-level failures as transient", () => {
6+
expect(isTransientError("Can't reach Ollama at http://127.0.0.1:11434 — is it running? (fetch failed)")).toBe(true);
7+
expect(isTransientError("request to https://api.openai.com failed, reason: ECONNRESET")).toBe(true);
8+
expect(isTransientError("The request timed out after 30s")).toBe(true);
9+
});
10+
11+
it("treats rate limits and server errors as transient", () => {
12+
expect(isTransientError("OpenAI request failed (HTTP 529): overloaded_error")).toBe(true);
13+
expect(isTransientError("Rate limit exceeded, HTTP 429")).toBe(true);
14+
expect(isTransientError("Gemini request failed (HTTP 503)")).toBe(true);
15+
});
16+
17+
it("does not retry auth or validation errors", () => {
18+
expect(isTransientError("Incorrect API key provided (HTTP 401)")).toBe(false);
19+
expect(isTransientError("model 'gpt-99' not found (HTTP 404)")).toBe(false);
20+
expect(isTransientError("Agent mode isn't supported yet for Gemini")).toBe(false);
21+
});
22+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Whether a provider error is worth one silent automatic retry — network
2+
// blips, timeouts, rate limits, and 5xx-class server hiccups usually succeed
3+
// on a second attempt, whereas auth/validation errors ("invalid API key",
4+
// "model not found") never will and should surface immediately.
5+
export function isTransientError(message: string): boolean {
6+
return /timed? ?out|timeout|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch failed|network error|HTTP 5\d\d|\(5\d\d\)|\b429\b|rate limit|overloaded|temporarily unavailable|try again/i.test(
7+
message
8+
);
9+
}

frontend/src/lib/translations.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface Dictionary {
2424
allow: string;
2525
deny: string;
2626
toolResult: string;
27+
toolFailed: string;
28+
transientErrorRetrying: string;
2729
alwaysAllowThisSession: string;
2830
agentStep: string;
2931
agentStepTooltip: string;
@@ -320,6 +322,8 @@ export const en: Dictionary = {
320322
allow: "Allow",
321323
deny: "Deny",
322324
toolResult: "result",
325+
toolFailed: "failed",
326+
transientErrorRetrying: "Temporary provider error — retrying once...",
323327
alwaysAllowThisSession: "Always allow this session",
324328
agentStep: "Agent step",
325329
agentStepTooltip: "How many automatic tool-result → model-continuation round trips have happened for this turn.",
@@ -626,6 +630,8 @@ export const tr: Dictionary = {
626630
allow: "İzin ver",
627631
deny: "Reddet",
628632
toolResult: "sonucu",
633+
toolFailed: "başarısız oldu",
634+
transientErrorRetrying: "Geçici sağlayıcı hatası — bir kez yeniden deneniyor...",
629635
alwaysAllowThisSession: "Bu oturumda her zaman izin ver",
630636
agentStep: "Ajan adımı",
631637
agentStepTooltip: "Bu tur için kaç otomatik araç sonucu → model devamı gidiş-dönüşü gerçekleşti.",

frontend/src/pages/Chat.tsx

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import { ScreenshotPickerDialog } from "@/components/screenshot-picker-dialog";
6969
import { speakText, stopSpeaking } from "@/lib/tts";
7070
import { computeLineDiff } from "@/lib/diff";
7171
import { useToast } from "@/components/toast";
72+
import { isTransientError } from "@/lib/transient-errors";
7273
import type {
7374
ChatMessage,
7475
OllamaModel,
@@ -223,11 +224,20 @@ const MessageBubble = memo(function MessageBubble({
223224
const { t } = useI18n();
224225

225226
if (m.role === "tool") {
227+
// Tool failures get a visually distinct card — an agent run that hit
228+
// an error mid-way should be scannable at a glance, not require
229+
// reading every result body to find where things went wrong.
230+
const isError = m.content.startsWith("Error:") || m.content === "The user denied this tool call.";
226231
return (
227232
<div className="flex flex-col items-start">
228-
<div className="max-w-[85%] rounded-lg border border-border bg-muted/50 px-3 py-2 font-mono text-xs text-muted-foreground">
229-
<div className="mb-1 font-sans font-medium text-foreground">
230-
🔧 {m.toolName} {t.toolResult}
233+
<div
234+
className={cn(
235+
"max-w-[85%] rounded-lg border px-3 py-2 font-mono text-xs text-muted-foreground",
236+
isError ? "border-destructive/40 bg-destructive/5" : "border-border bg-muted/50"
237+
)}
238+
>
239+
<div className={cn("mb-1 font-sans font-medium", isError ? "text-destructive" : "text-foreground")}>
240+
{isError ? "⚠️" : "🔧"} {m.toolName} {isError ? t.toolFailed : t.toolResult}
231241
</div>
232242
<pre className="max-h-48 overflow-auto whitespace-pre-wrap">{m.content}</pre>
233243
</div>
@@ -492,7 +502,7 @@ export default function Chat() {
492502
setAgentWorkspace(session.agentWorkspace ?? null);
493503
setPendingToolCalls([]);
494504
setAgentStepCount(0);
495-
setPlanSteps([]);
505+
setPlanSteps(session.planSteps ?? []);
496506
setAutoApprovedTools(new Set());
497507
setWriteDiffPreviews({});
498508
setUndoMessage(null);
@@ -832,7 +842,7 @@ export default function Chat() {
832842
async function runCompletion(
833843
history: ChatMessage[],
834844
baseMessages: ChatMessage[],
835-
opts: { isFirstMessage: boolean; titleSource: string }
845+
opts: { isFirstMessage: boolean; titleSource: string; attempt?: number }
836846
) {
837847
const parsed = parseModelRef(model);
838848
if (!parsed || !sessionId) return;
@@ -883,6 +893,18 @@ export default function Chat() {
883893
const result = await promise;
884894
setActiveRequestId(null);
885895

896+
// One silent retry for errors that usually clear on their own
897+
// (network blips, rate limits, 5xx) — but never for a user-initiated
898+
// stop, and never more than once, so a genuinely down provider still
899+
// fails fast instead of looping.
900+
if (result.error && !result.aborted && (opts.attempt ?? 0) === 0 && isTransientError(result.error)) {
901+
toast.info(t.transientErrorRetrying);
902+
setIsStreaming(false);
903+
window.api.app.setBusy(false);
904+
await new Promise((resolve) => setTimeout(resolve, 1500));
905+
return runCompletion(history, baseMessages, { ...opts, attempt: 1 });
906+
}
907+
886908
if (result.error) {
887909
setMessages((m) => {
888910
const next = [...m];
@@ -999,6 +1021,7 @@ export default function Chat() {
9991021
done: Boolean((s as { done?: unknown })?.done),
10001022
}));
10011023
setPlanSteps(steps);
1024+
if (sessionId) window.api.sessions.update(sessionId, { planSteps: steps });
10021025
resolveToolCall(call, "Plan updated.");
10031026
}
10041027

@@ -1098,6 +1121,7 @@ export default function Chat() {
10981121
setImageAttachments([]);
10991122
setAgentStepCount(0);
11001123
setPlanSteps([]);
1124+
window.api.sessions.update(sessionId, { planSteps: [] });
11011125
await runCompletion(history, baseMessages, { isFirstMessage, titleSource });
11021126
}
11031127

frontend/src/types/electron.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ export interface ChatSession {
247247
systemPrompt?: string | null;
248248
agentMode?: boolean;
249249
agentWorkspace?: string | null;
250+
planSteps?: { text: string; done: boolean }[];
250251
tags?: string[];
251252
createdAt: string;
252253
updatedAt: string;
@@ -391,6 +392,7 @@ export interface ElectronApi {
391392
| "systemPrompt"
392393
| "agentMode"
393394
| "agentWorkspace"
395+
| "planSteps"
394396
| "tags"
395397
>
396398
>

0 commit comments

Comments
 (0)