diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index a7d7962c..621ab64d 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -46,6 +46,8 @@ const STDERR_LINES: usize = 20; /// Updates of a session's state rather than its conversation. const SESSION_STATE: [&str; 5] = ["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"]; +/// A native agent's own update: it is about to retry a failed API request (see [`AgentRetry`]). +pub(crate) const RETRY_UPDATE: &str = "openidea_retry"; /// Referenced files up to this size are embedded in the prompt (when the agent takes it). const MAX_EMBED: u64 = 256 * 1024; /// How long a probe waits for the agent to answer `initialize`. @@ -264,6 +266,7 @@ impl AgentManager { if matches!(f.task.status, AgentStatus::Running | AgentStatus::NeedsInput | AgentStatus::Starting) { f.task.status = AgentStatus::Ready; } + f.task.retry = None; let task = Task::restore(f, shared.clone()); task.state.lock().conversation.settle(now_ms()); tasks.insert(task.id.clone(), task); @@ -389,6 +392,7 @@ impl AgentManager { queue: Vec::new(), capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(), resets_ms: None, + retry: None, }; let file = TaskFile { task: task.clone(), @@ -577,6 +581,7 @@ impl AgentManager { usage: None, queue: Vec::new(), resets_ms: None, + retry: None, ..s.task.clone() }; TaskFile { @@ -830,9 +835,22 @@ impl Task { self.shared.emit(AgentEvent::Task { task }); } + /// Shows (or clears) the agent's pending retry; emitted only when it changes. + fn set_retry(&self, retry: Option) { + { + let mut s = self.state.lock(); + if s.task.retry == retry { + return; + } + s.task.retry = retry; + } + self.emit_task(); + } + fn set_status(&self, status: AgentStatus, error: Option) { { let mut s = self.state.lock(); + s.task.retry = None; s.task.status = status; s.task.error = error; s.task.updated_ms = now_ms(); @@ -1264,6 +1282,12 @@ impl Task { } return; } + if kind == RETRY_UPDATE { + if !self.replaying.load(Ordering::Acquire) { + self.set_retry(retry_of(update, now_ms())); + } + return; + } if kind == "session_info_update" { // The agent's own title for the session replaces the first line of the prompt. let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty()); @@ -1332,6 +1356,8 @@ impl Task { } fn apply(self: &Arc, session: Option<&str>, kind: &str, update: &Value) { + // Output again: the retried request went through. + self.set_retry(None); let applied = self.state.lock().conversation.apply(session, update, now_ms()); self.mark_dirty(applied); // A subagent can start or finish after the turn that started it: saved right away. @@ -1556,6 +1582,17 @@ fn is_zero(n: &u32) -> bool { *n == 0 } +/// The retry a [`RETRY_UPDATE`] announces, timed from `now`. +fn retry_of(update: &Value, now: f64) -> Option { + let n = |k: &str| update.get(k).and_then(Value::as_u64); + Some(AgentRetry { + attempt: u32::try_from(n("attempt")?).unwrap_or(u32::MAX), + max_retries: n("maxRetries").and_then(|m| u32::try_from(m).ok()), + retry_at_ms: now + n("retryDelayMs").unwrap_or(0) as f64, + error: update.get("error").and_then(Value::as_str).filter(|e| !e.is_empty()).map(str::to_owned), + }) +} + fn model_of(config: &[AgentConfigOption]) -> Option { config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone()) } diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index 5df6a47a..c777ef2e 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -81,6 +81,21 @@ pub struct AgentTask { /// When a [`AgentStatus::Limited`] task's limit resets (ms since the epoch), if the agent said. #[serde(default)] pub resets_ms: Option, + /// The agent is retrying a failed API request (connection lost, overloaded…); cleared + /// by its next output or any status change. + #[serde(default)] + pub retry: Option, +} + +/// An API request the agent is about to retry. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentRetry { + pub attempt: u32, + pub max_retries: Option, + /// When the next attempt starts (ms since the epoch). + pub retry_at_ms: f64, + pub error: Option, } /// One value of a select-type session setting. diff --git a/crates/ide-agent/src/native/claude/tests.rs b/crates/ide-agent/src/native/claude/tests.rs index 8faf50db..5e52741f 100644 --- a/crates/ide-agent/src/native/claude/tests.rs +++ b/crates/ide-agent/src/native/claude/tests.rs @@ -917,6 +917,20 @@ fn pending_starts_round_trip() { } } +#[test] +fn api_retries_are_announced() { + let mut t = Translator::new("s", PathBuf::from("/w"), true); + let out = t.on_message(&json!({"type": "system", "subtype": "api_retry", "attempt": 3, "max_retries": 10, + "retry_delay_ms": 4000, "error_status": null, "error": "Connection error."})); + assert_eq!(out.len(), 1); + assert_eq!(out[0].session, "s"); + assert_eq!( + out[0].update, + json!({"sessionUpdate": crate::RETRY_UPDATE, "attempt": 3, "maxRetries": 10, "retryDelayMs": 4000, + "error": "Connection error."}) + ); +} + #[test] fn streamed_text_is_not_sent_twice() { let mut t = Translator::new("s", PathBuf::from("/w"), true); diff --git a/crates/ide-agent/src/native/claude/translate.rs b/crates/ide-agent/src/native/claude/translate.rs index 534e0eee..ac8158f0 100644 --- a/crates/ide-agent/src/native/claude/translate.rs +++ b/crates/ide-agent/src/native/claude/translate.rs @@ -5,6 +5,7 @@ //! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`. use super::tools; +use crate::RETRY_UPDATE; use serde_json::{Value, json}; use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; @@ -427,6 +428,23 @@ impl Translator { } Vec::new() } + // Claude Code retries a failed API request on its own; without this the turn looks stuck. + Some("api_retry") => { + let n = |k: &str| msg.get(k).and_then(Value::as_u64); + let error = msg.get("error").and_then(|e| { + e.as_str().map(str::to_owned).or_else(|| e.get("message")?.as_str().map(str::to_owned)) + }); + vec![Update { + session: self.root.clone(), + update: json!({ + "sessionUpdate": RETRY_UPDATE, + "attempt": n("attempt").unwrap_or(1), + "maxRetries": n("max_retries"), + "retryDelayMs": n("retry_delay_ms").unwrap_or(0), + "error": error, + }), + }] + } Some("task_notification") if self.children.get(&tool_use).is_some_and(|c| c.background != Some(false)) => { let stop = match msg.get("status").and_then(Value::as_str) { Some("failed") => "error", diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index 24e84f96..67067573 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -709,3 +709,14 @@ async fn usage_limits_and_late_settings() { let config = mgr.task(&task.id).unwrap().state.lock().task.config.clone(); assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high")); } + +#[test] +fn retry_updates_become_a_countdown() { + let update = + json!({"sessionUpdate": RETRY_UPDATE, "attempt": 2, "maxRetries": 10, "retryDelayMs": 1500, "error": ""}); + assert_eq!( + retry_of(&update, 1000.0), + Some(AgentRetry { attempt: 2, max_retries: Some(10), retry_at_ms: 2500.0, error: None }) + ); + assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt"); +} diff --git a/src/bindings.ts b/src/bindings.ts index 70bcd3d2..217678da 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1179,6 +1179,15 @@ export type AgentRegistry = { warning: string | null, }; +/** An API request the agent is about to retry. */ +export type AgentRetry = { + attempt: number, + maxRetries: number | null, + /** When the next attempt starts (ms since the epoch). */ + retryAtMs: number | null, + error: string | null, +}; + /** Where an agent comes from. */ export type AgentSource = { kind: "builtin" } | /** Defined by the user in `/agents/.toml`. */ @@ -1238,6 +1247,11 @@ export type AgentTask = { capabilities?: AgentCapabilities, /** When a [`AgentStatus::Limited`] task's limit resets (ms since the epoch), if the agent said. */ resetsMs?: number | null, + /** + * The agent is retrying a failed API request (connection lost, overloaded…); cleared + * by its next output or any status change. + */ + retry?: AgentRetry | null, }; export type AgentToolDiff = { diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index f7230811..420f835b 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -437,6 +437,10 @@ color: var(--fg-muted); } +.retrying { + color: var(--warning); +} + .composerArea { flex: none; display: flex; diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index b26a517a..e60faa8f 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -2,7 +2,7 @@ import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; import clsx from "clsx"; import { useShallow } from "zustand/react/shallow"; -import type { AgentChange, AgentEntry, AgentTask, AgentUsage } from "@/bindings"; +import type { AgentChange, AgentEntry, AgentRetry, AgentTask, AgentUsage } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands, onIdeEvent } from "@/platform/ipc"; import { basename, relativeDir, useProject } from "@/platform/project"; @@ -25,7 +25,7 @@ import { import { useTx, tx } from "@/platform/i18n"; import { TaskStatusIcon } from "./AgentsView"; import { Composer } from "./Composer"; -import { canSend, emptyDraft, usageLabel, workTime } from "./composerModel"; +import { canSend, emptyDraft, retryParts, usageLabel, workTime } from "./composerModel"; import { ConfigPickers } from "./ConfigPickers"; import { findRanges, formatDuration, formatSentAt } from "./conversation"; import { Markdown } from "./Markdown"; @@ -381,7 +381,11 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o {!subagent && (task.status === "running" || task.status === "starting") && !waiting && (
- {task.status === "starting" ? "Starting the agent…" : "Working…"} + {task.retry ? ( + + ) : ( + {task.status === "starting" ? "Starting the agent…" : "Working…"} + )} {lastUser?.sentMs != null && !lastUser.doneMs && }
)} @@ -393,6 +397,20 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o type UserEntry = Extract; /** Time since `since`, ticking every second. */ +/** The agent lost its API connection (or was refused) and retries on its own. */ +function Retrying({ retry }: { retry: AgentRetry }) { + const tx = useTx(); + const now = useNow(1000); + const { attempt, wait } = retryParts(retry, now); + return ( + + {wait > 0 + ? tx`API request failed, retrying in ${wait}s (attempt ${attempt})` + : tx`API request failed, retrying (attempt ${attempt})`} + + ); +} + function Elapsed({ since }: { since: number }) { const now = useNow(1000); return {formatDuration(now - since)}; diff --git a/src/features/agents/agents.test.ts b/src/features/agents/agents.test.ts index d9b23b27..e130796e 100644 --- a/src/features/agents/agents.test.ts +++ b/src/features/agents/agents.test.ts @@ -13,6 +13,7 @@ import { nextValue, recall, toPrompt, + retryParts, usageLabel, workTime, } from "./composerModel"; @@ -264,3 +265,11 @@ describe("turns", () => { ]); }); }); + +describe("retryParts", () => { + it("counts down to the next attempt and shows the limit when known", () => { + const retry = { attempt: 3, maxRetries: 10, retryAtMs: 5_400, error: "Connection error." }; + expect(retryParts(retry, 1_000)).toEqual({ attempt: "3/10", wait: 5 }); + expect(retryParts({ ...retry, maxRetries: null }, 9_000)).toEqual({ attempt: "3", wait: 0 }); + }); +}); diff --git a/src/features/agents/composerModel.ts b/src/features/agents/composerModel.ts index 86d14cc9..c030288d 100644 --- a/src/features/agents/composerModel.ts +++ b/src/features/agents/composerModel.ts @@ -1,5 +1,13 @@ /** Pure composer helpers: `@` / `/` completion, attachments, history recall, settings. */ -import type { AgentCommand, AgentConfigOption, AgentEntry, AgentImage, AgentPrompt, AgentUsage } from "@/bindings"; +import type { + AgentCommand, + AgentConfigOption, + AgentEntry, + AgentImage, + AgentPrompt, + AgentRetry, + AgentUsage, +} from "@/bindings"; import { tx } from "@/platform/i18n"; export interface FileRef { @@ -131,6 +139,14 @@ export function nextValue(option: AgentConfigOption, current = option.current): } /** "12%" of the context window and a tooltip with the numbers. */ +/** A pending API retry: "3/10" (or "3" when the agent gives no limit) and the seconds left. */ +export function retryParts(r: AgentRetry, now: number): { attempt: string; wait: number } { + return { + attempt: r.maxRetries != null ? `${r.attempt}/${r.maxRetries}` : String(r.attempt), + wait: r.retryAtMs == null ? 0 : Math.max(0, Math.ceil((r.retryAtMs - now) / 1000)), + }; +} + export function usageLabel(u: AgentUsage): { short: string; detail: string } { const pct = Math.min(100, Math.round((u.used / u.size) * 100)); return { short: `${pct}%`, detail: tx`${tokens(u.used)} / ${tokens(u.size)} tokens` }; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c722229d..a3edf155 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3,6 +3,8 @@ ", e.g.": ",例如", ", then press Tab.": ",然后按 Tab。", "? This cannot be undone.": "?此操作无法撤消。", + "API request failed, retrying (attempt {attempt})": "API 请求失败,正在重试(第 {attempt} 次)", + "API request failed, retrying in {wait}s (attempt {attempt})": "API 请求失败,{wait} 秒后重试(第 {attempt} 次)", "A charset in .editorconfig takes precedence. Change one file's encoding from the status bar.": ".editorconfig 中的 charset 优先。可以从状态栏更改单个文件的编码。", "A command line, e.g. /bin/zsh --login. Leave empty to use the login shell.": "命令行,例如 /bin/zsh --login。留空则使用登录 Shell。", "A line breakpoint that is removed after it is hit": "命中后即移除的行断点",