mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 17:22:55 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1382735166 |
No files matched your search
@@ -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<AgentRetry>) {
|
||||
{
|
||||
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<String>) {
|
||||
{
|
||||
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<Self>, 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<AgentRetry> {
|
||||
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<String> {
|
||||
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
|
||||
}
|
||||
|
||||
@@ -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<f64>,
|
||||
/// 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<AgentRetry>,
|
||||
}
|
||||
|
||||
/// 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<u32>,
|
||||
/// When the next attempt starts (ms since the epoch).
|
||||
pub retry_at_ms: f64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// One value of a select-type session setting.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 `<config>/agents/<id>.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 = {
|
||||
|
||||
@@ -437,6 +437,10 @@
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.retrying {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.composerArea {
|
||||
flex: none;
|
||||
display: flex;
|
||||
|
||||
@@ -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 && (
|
||||
<div className={styles.working}>
|
||||
<Spinner size={12} />
|
||||
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
|
||||
{task.retry ? (
|
||||
<Retrying retry={task.retry} />
|
||||
) : (
|
||||
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
|
||||
)}
|
||||
{lastUser?.sentMs != null && !lastUser.doneMs && <Elapsed since={lastUser.sentMs} />}
|
||||
</div>
|
||||
)}
|
||||
@@ -393,6 +397,20 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
type UserEntry = Extract<AgentEntry, { type: "user" }>;
|
||||
|
||||
/** 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 (
|
||||
<span className={styles.retrying} title={retry.error ?? undefined}>
|
||||
{wait > 0
|
||||
? tx`API request failed, retrying in ${wait}s (attempt ${attempt})`
|
||||
: tx`API request failed, retrying (attempt ${attempt})`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Elapsed({ since }: { since: number }) {
|
||||
const now = useNow(1000);
|
||||
return <span className={styles.muted}>{formatDuration(now - since)}</span>;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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` };
|
||||
|
||||
@@ -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": "命中后即移除的行断点",
|
||||
|
||||
Reference in new issue
Block a user