diff --git a/apps/desktop/electron/main/runtime/provider-catalog.ts b/apps/desktop/electron/main/runtime/provider-catalog.ts
index 013b24962..8da390e3d 100644
--- a/apps/desktop/electron/main/runtime/provider-catalog.ts
+++ b/apps/desktop/electron/main/runtime/provider-catalog.ts
@@ -180,6 +180,10 @@ export function createProviderCatalogRuntime({
) as T & { defaultCommandShell?: unknown };
return {
...(value as T),
+ infiniteProviderRetry: (value as T & { infiniteProviderRetry?: unknown })
+ .infiniteProviderRetry === true
+ ? true
+ : undefined,
defaultCommandShell: isCommandShellId(value.defaultCommandShell)
? value.defaultCommandShell
: defaultCommandShellForPlatform(process.platform),
@@ -192,6 +196,7 @@ export function createProviderCatalogRuntime({
}
const value = settings as T & {
defaultCommandShell?: unknown;
+ infiniteProviderRetry?: unknown;
networkProxy?: unknown;
};
if (
@@ -202,6 +207,14 @@ export function createProviderCatalogRuntime({
errorCode: ErrorCodes.COMMAND_SHELL_INVALID,
});
}
+ if (
+ Object.prototype.hasOwnProperty.call(value, "infiniteProviderRetry") &&
+ typeof value.infiniteProviderRetry !== "boolean"
+ ) {
+ throw Object.assign(new Error("infiniteProviderRetry is invalid"), {
+ errorCode: ErrorCodes.INVALID_PARAMS,
+ });
+ }
if (Object.prototype.hasOwnProperty.call(value, "networkProxy")) {
const proxy = validateNetworkProxy(value.networkProxy);
if (!proxy.ok) {
diff --git a/apps/desktop/electron/main/runtime/session-launch.ts b/apps/desktop/electron/main/runtime/session-launch.ts
index e8a5a986b..d901c2e82 100644
--- a/apps/desktop/electron/main/runtime/session-launch.ts
+++ b/apps/desktop/electron/main/runtime/session-launch.ts
@@ -611,6 +611,7 @@ export function createSessionLaunchRuntime({
),
...(overrides.turnId ? { turnId: overrides.turnId } : {}),
thinkingLevel,
+ infiniteProviderRetry: settings.infiniteProviderRetry === true,
commandShell,
scratchDir: join(dataDir, "scratch", sessionId),
attachmentsDir: join(dataDir, "attachments"),
diff --git a/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx b/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx
index 7a9fdb6b6..9b95b5442 100644
--- a/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx
+++ b/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx
@@ -143,7 +143,7 @@ export function runActivityLabel(
return t("chat.retryingModel", {
delaySeconds: retryDelaySeconds(activity, now),
attempt: activity.attempt,
- maxAttempts: PROVIDER_RETRY_MAX_RETRIES,
+ maxAttempts: activity.infinite ? "∞" : PROVIDER_RETRY_MAX_RETRIES,
});
case "waiting-subagents":
return waitingSubagentsLabel(activity, t);
diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx
index f49fcaad6..d426bb1e4 100644
--- a/apps/desktop/src/features/settings/SettingsPage.tsx
+++ b/apps/desktop/src/features/settings/SettingsPage.tsx
@@ -461,6 +461,28 @@ export function SettingsPage() {
+
+
+
{
assert.match(aiSource, /settings\.defaultsTitle/);
assert.match(aiSource, /CommandShellRow/);
assert.match(aiSource, /enterToSend: !settings\.enterToSend/);
+ assert.match(aiSource, /infiniteProviderRetry: settings\.infiniteProviderRetry !== true/);
assert.match(aiSource, /LargePasteThresholdRow/);
assert.match(aiSource, /ContextUsageDisplayRow/);
assert.match(aiSource, /PromptEnhancementCard/);
@@ -120,6 +121,8 @@ test("Basics and AI tabs expose their respective app and AI controls", () => {
"settings.contextUsageDisplay",
"settings.contextUsageDisplayRemaining",
"settings.contextUsageDisplayUsed",
+ "settings.infiniteProviderRetry",
+ "settings.infiniteProviderRetryDesc",
]) {
assert.match(settingsSearchSource, new RegExp(key.replaceAll(".", "\\.")));
assert.match(enLocaleSource, new RegExp(`${key.split(".").at(-1)}:`));
@@ -127,6 +130,7 @@ test("Basics and AI tabs expose their respective app and AI controls", () => {
assert.match(trLocaleSource, new RegExp(`${key.split(".").at(-1)}:`));
}
assert.match(sharedTypesSource, /contextUsageDisplay\?: ContextUsageDisplay/);
+ assert.match(sharedTypesSource, /infiniteProviderRetry\?: boolean/);
assert.match(sharedTypesSource, /ContextUsageDisplay = "remaining" \| "used"/);
assert.match(sharedTypesSource, /chatContentMaxWidth\?: number/);
assert.match(settingsPageSource, /largePasteThreshold/);
diff --git a/apps/desktop/test/turn-process.test.mjs b/apps/desktop/test/turn-process.test.mjs
index 8108cc752..661d74fcc 100644
--- a/apps/desktop/test/turn-process.test.mjs
+++ b/apps/desktop/test/turn-process.test.mjs
@@ -177,6 +177,12 @@ test("settings writes validate the mode without changing other preferences", asy
onboardingDismissed: false,
};
assert.equal(validateSettingsWrite(settings), settings);
+ const infiniteSettings = { ...settings, infiniteProviderRetry: true };
+ assert.equal(validateSettingsWrite(infiniteSettings), infiniteSettings);
+ assert.throws(
+ () => validateSettingsWrite({ ...settings, infiniteProviderRetry: "yes" }),
+ /infiniteProviderRetry is invalid/,
+ );
for (const thinkingDisplayMode of ["detailed", "compact"]) {
const next = { ...settings, thinkingDisplayMode };
assert.equal(validateSettingsWrite(next), next);
diff --git a/crates/host-core/src/rpc/mod.rs b/crates/host-core/src/rpc/mod.rs
index 9e4e9766c..ef448b811 100644
--- a/crates/host-core/src/rpc/mod.rs
+++ b/crates/host-core/src/rpc/mod.rs
@@ -692,6 +692,15 @@ fn validate_settings_value(value: &Value) -> Result<(), JsonRpcError> {
return Err(rpc_err(1002, message, "INVALID_PARAMS"));
}
}
+ if let Some(infinite_retry) = object.get("infiniteProviderRetry") {
+ if !infinite_retry.is_boolean() {
+ return Err(rpc_err(
+ 1002,
+ "infiniteProviderRetry must be a boolean",
+ "INVALID_PARAMS",
+ ));
+ }
+ }
if let Some(threshold_value) = object.get("largePasteThreshold") {
let Some(threshold) = threshold_value.as_i64() else {
return Err(rpc_err(
@@ -6045,6 +6054,29 @@ mod tests {
.unwrap();
assert_eq!(updated["largePasteThreshold"], 801);
+ handle_request(
+ state.clone(),
+ "settings.set",
+ json!({ "infiniteProviderRetry": true }),
+ tx.clone(),
+ )
+ .await
+ .unwrap();
+ let retry_settings = handle_request(state.clone(), "settings.get", json!({}), tx.clone())
+ .await
+ .unwrap();
+ assert_eq!(retry_settings["infiniteProviderRetry"], true);
+
+ let invalid_retry = handle_request(
+ state.clone(),
+ "settings.set",
+ json!({ "infiniteProviderRetry": "yes" }),
+ tx.clone(),
+ )
+ .await
+ .unwrap_err();
+ assert_eq!(invalid_retry.data.unwrap()["errorCode"], "INVALID_PARAMS");
+
let invalid_threshold = handle_request(
state.clone(),
"settings.set",
diff --git a/docs/adr/0206-ten-provider-retries-and-progress-status.md b/docs/adr/0206-ten-provider-retries-and-progress-status.md
index 1e0f107bb..d7220b17a 100644
--- a/docs/adr/0206-ten-provider-retries-and-progress-status.md
+++ b/docs/adr/0206-ten-provider-retries-and-progress-status.md
@@ -15,6 +15,16 @@ product's target behavior. The active-turn row also shows only the retry
number, so it does not tell the user how long the current wait is or where the
retry sits in the budget.
+### Amendment: opt-in infinite provider retry
+
+The default ten-retry budgets remain unchanged. AppSettings may opt into
+`infiniteProviderRetry`; the runtime then skips only the retry-count ceiling for
+admitted network/transient provider failures in the main session and builtin
+subagents. Backoff, server retry hints, response-only reset, cancellation,
+terminal classification, and failed-request-only replay remain unchanged. The
+Settings copy warns that the user can stop the turn but API usage may continue
+while the switch is enabled. One-shot completions keep the bounded budget.
+
## Decision
1. `PROVIDER_RATE_LIMITED` and the admitted non-429 transient provider errors
diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md
index abfa9d6a4..020bd9e2b 100644
--- a/docs/spec/03-runtime/01-ipc-protocol.md
+++ b/docs/spec/03-runtime/01-ipc-protocol.md
@@ -492,7 +492,7 @@ type AgentActivity =
reason: "manual" | "threshold" | "overflow" }
| { phase: "recovering"; since: number }
| { phase: "retrying"; since: number; attempt: number;
- retryDelayMs?: number; error?: AgentActivityError }
+ infinite?: boolean; retryDelayMs?: number; error?: AgentActivityError }
| { phase: "waiting-subagents"; since: number; subagentCount: number;
agents?: AgentActivityAgent[] };
diff --git a/docs/spec/03-runtime/02-agent-runtime.md b/docs/spec/03-runtime/02-agent-runtime.md
index b46e4af30..6cbca8b4d 100644
--- a/docs/spec/03-runtime/02-agent-runtime.md
+++ b/docs/spec/03-runtime/02-agent-runtime.md
@@ -218,6 +218,15 @@ Only the failed request is replayed. The session, its transcript, and its tool
state are untouched: the failed assistant is removed from the next model context
and the same visible message id is reused, so a retry never restarts the turn or
re-runs a completed tool call.
+
+The application setting `infiniteProviderRetry` is off by default. When enabled,
+the main session and its builtin subagents skip only the ten-retry ceiling for
+`NETWORK_ERROR`, `TIMEOUT`, `STREAM_FAILED`, retryable `PROVIDER_ERROR`
+(including 5xx gateway failures), and `PROVIDER_RATE_LIMITED`. The same backoff,
+`Retry-After` precedence, visible retry status, and abort/Stop path remain in
+force. Non-retryable errors, context recovery, compaction, tool execution, and
+one-shot completions are unchanged. The setting can keep billing requests alive
+indefinitely until the user stops the turn.
Each retry is abortable and reports its current backoff through the normalized
status event. The `retrying` activity carries the classified error code, the
bounded/redacted provider message, and the HTTP status when known. The main
diff --git a/docs/spec/03-runtime/08-error-codes.md b/docs/spec/03-runtime/08-error-codes.md
index cd9f72213..05d449b2c 100644
--- a/docs/spec/03-runtime/08-error-codes.md
+++ b/docs/spec/03-runtime/08-error-codes.md
@@ -328,7 +328,11 @@ non-429 path applies the same precedence with an 8-second cap and otherwise
waits 1, 2, 4, then remains at 8 seconds for later retries. Only the failed
request is replayed; the session and its tool state are untouched. A
non-retryable `PROVIDER_ERROR` from a
-malformed 400/422 request never enters either budget.
+malformed 400/422 request never enters either budget. The persisted
+`infiniteProviderRetry` setting is false by default; when true it removes only
+the retry-count ceiling for the admitted transient/network classes (including
+429). Backoff, `Retry-After`, cancellation, and terminal classification remain
+unchanged, and the setting may continue API usage until the user stops the turn.
A `NETWORK_ERROR` carries the failing transport layer as bounded `details`:
`networkCategory` (`dns`, `tls`, `timeout`, `refused`, `unreachable`, `reset`,
diff --git a/docs/spec/04-ux/06-settings-ia.md b/docs/spec/04-ux/06-settings-ia.md
index 5cb1f0b75..d043475ad 100644
--- a/docs/spec/04-ux/06-settings-ia.md
+++ b/docs/spec/04-ux/06-settings-ia.md
@@ -152,8 +152,11 @@ Settings is a **full-window page** that replaces the app sidebar + main chrome (
control column.
- **Defaults** card: the host-backed default operating mode (Agent / Plan / Goal),
command shell selection, Link open destination, context usage display
- (remaining or used), thinking display mode, Enter-to-send control, and the large text paste
- threshold. Link open destination uses the Work panel browser by default
+ (remaining or used), thinking display mode, Enter-to-send control, the
+ infinite provider retry switch, and the large text paste threshold. The
+ retry switch is off by default and explains that network/transient provider
+ failures keep retrying until success; Stop still cancels the turn and the
+ setting may continue API usage while enabled. Link open destination uses the Work panel browser by default
and routes chat, transcript, and plugin HTTP(S) clicks to the system
browser when set to Default OS browser. Plugin/settings clicks that want
the work panel return to chat first so the dock is visible, without
diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md
index 48ae8abf8..6ea070820 100644
--- a/docs/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/spec/06-delivery/04-e2e-test-plan.md
@@ -5483,6 +5483,10 @@ identify the platform validation still needed.
no repair request starts.
9. Reload the session and verify that only the completed response or the
single terminal failed assistant remains durable.
+ 10. Open Settings → AI → Defaults, enable infinite provider retry, and repeat
+ a network fixture that fails beyond ten attempts before recovering. Stop the
+ turn during backoff and verify no later request starts; disable the setting
+ and verify a fresh persistent outage stops after ten retries.
- **Expected**:
- `terminated` is classified as `STREAM_FAILED`, and an upstream gateway
`502`/`503`/`504` as retryable `PROVIDER_ERROR`.
@@ -5521,7 +5525,9 @@ identify the platform validation still needed.
- Authentication, model-selection, context, and descriptive
malformed-request failures do not enter either provider replay path. The
opaque empty-body 400/422 case is the bounded repair exception described
- above.
+ above. Infinite mode changes no classification and only removes the retry
+ ceiling for admitted network/transient failures; it remains abortable and
+ is visibly marked with an unbounded retry indicator.
- **Specs linked**: `03-runtime/01-ipc-protocol.md`,
`03-runtime/02-agent-runtime.md`, `03-runtime/08-error-codes.md`,
`08-meta/decisions-log.md` (D186, D259, D378), ADR 0050, ADR 0128, ADR 0206
diff --git a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
index 202c3b1d6..07927847d 100644
--- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
+++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
@@ -440,7 +440,7 @@ type AgentActivity =
reason: "manual" | "threshold" | "overflow" }
| { phase: "recovering"; since: number }
| { phase: "retrying"; since: number; attempt: number;
- retryDelayMs?: number; error?: AgentActivityError }
+ infinite?: boolean; retryDelayMs?: number; error?: AgentActivityError }
| { phase: "waiting-subagents"; since: number; subagentCount: number;
agents?: AgentActivityAgent[] };
diff --git a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md
index eb923da2c..d80bc2e15 100644
--- a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md
+++ b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md
@@ -183,6 +183,11 @@ HTTP 429 处理是一个逻辑回合策略。此路径禁用了 pi-ai 的嵌套
子代理和一次性 composer 提示增强使用相同的错误码、预算大小和
优先级。
+应用设置 `infiniteProviderRetry` 默认关闭。开启后,主会话及其内置子代理只跳过可重试
+网络/瞬时故障(含 `PROVIDER_RATE_LIMITED`)的次数上限。退避、`Retry-After`、可见重试状态和
+停止路径不变。不可重试错误、上下文恢复、压缩、工具执行和一次性补全仍走原有有界预算。
+开启后可能在用户停止回合前持续消耗 API 用量。
+
当 429 预算耗尽时,最终的助手错误和生命周期 `error` 只发出一次。
提供程序故障在可用时于 `AppError.details` 中携带有界诊断:
`phase`(`request` 或 `stream`)、`providerStatus`、`providerCode`、
diff --git a/docs/zh-CN/spec/03-runtime/08-error-codes.md b/docs/zh-CN/spec/03-runtime/08-error-codes.md
index 450dc3bd1..9ea39ce05 100644
--- a/docs/zh-CN/spec/03-runtime/08-error-codes.md
+++ b/docs/zh-CN/spec/03-runtime/08-error-codes.md
@@ -322,7 +322,8 @@ Node sidecar 将提供商 SDK 错误映射到:
上限为 8 秒,在其他情况下依次等待 1 秒、2 秒、4 秒,然后是 8 秒。只有失败
的请求会被重放;会话及其工具状态保持不变。来自格式错误的 400/422 请求的
不可重试 `PROVIDER_ERROR` 永远不会进入任何预算。预算耗尽后的失败仍然是
-致命的。
+致命的。设置 `infiniteProviderRetry` 默认关闭;开启后只移除上述可重试网络/瞬时类别的次数上限,
+不会改变退避、`Retry-After`、取消或终止分类,并可能在用户停止回合前持续消耗 API 用量。
`NETWORK_ERROR` 以有界的 `details` 携带真正失败的传输层:
`networkCategory`(`dns`、`tls`、`timeout`、`refused`、`unreachable`、
diff --git a/docs/zh-CN/spec/04-ux/06-settings-ia.md b/docs/zh-CN/spec/04-ux/06-settings-ia.md
index 1ac036601..44bead94d 100644
--- a/docs/zh-CN/spec/04-ux/06-settings-ia.md
+++ b/docs/zh-CN/spec/04-ux/06-settings-ia.md
@@ -89,7 +89,8 @@
都以相同方式展开。关闭时的触发器按当前文案收缩,不超过设置控件列。
- **默认项**卡:主机支持的默认运行模式(Agent / Plan / Goal)、
命令 Shell 选择、链接打开目标、上下文用量显示(剩余或已用)、
- 思考显示模式、回车发送控制和大段文本粘贴阈值。链接打开目标默认使用工作面板浏览器,
+ 思考显示模式、回车发送控制、无尽重试开关和大段文本粘贴阈值。无尽重试默认关闭;开启后网络/临时提供商故障会持续到成功,
+ 但停止按钮仍可取消回合,开启期间可能持续消耗 API 用量。链接打开目标默认使用工作面板浏览器,
可将对话、会话记录和插件页的 HTTP(S) 点击路由到系统浏览器。插件/设置页
若目标是工作面板,会先回到对话再打开(不记入导航栈),避免被遮罩挡住;没有会话时才回退到
系统浏览器。工作区 HTML
diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
index 504aea0d0..dcedd5e6a 100644
--- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
@@ -3676,6 +3676,7 @@ IPC 请求无法关闭。
6. 运行 503 `Retry-After` 装置并检查观察到的等待。
7. 重新加载会话并验证是否只有已完成的响应或
单终端故障助手依然耐用。
+ 8. 打开设置中的「无尽重试」后,故障超过默认次数仍继续;停止回合时不再发起后续请求,关闭后恢复有界上限。
- **预期**:
- `terminated` 被分类为 `STREAM_FAILED`,上游网关
`502`/`503`/`504` 被分类为可重试的 `PROVIDER_ERROR`。
@@ -3705,7 +3706,8 @@ IPC 请求无法关闭。
预算互不占用。
- 身份验证、模型选择、上下文和格式错误的请求失败
不进入任何提供程序重播路径,包括来自格式错误的 400/422
- 请求的不可重试 `PROVIDER_ERROR`。
+ 请求的不可重试 `PROVIDER_ERROR`。无尽模式不改变分类,只去掉已准入网络/瞬时故障的次数上限;
+ 仍可中止,并以无界重试指示标出。
- **链接规格**:`03-runtime/01-ipc-protocol.md`,
`03-runtime/02-agent-runtime.md`、`03-runtime/08-error-codes.md`、
`08-meta/decisions-log.md`(D186、D259)、ADR 0050、ADR 0128
diff --git a/packages/agent-runtime/src/runtime.test.ts b/packages/agent-runtime/src/runtime.test.ts
index 1c116fa5e..4aaab9148 100644
--- a/packages/agent-runtime/src/runtime.test.ts
+++ b/packages/agent-runtime/src/runtime.test.ts
@@ -3862,6 +3862,33 @@ describe("DesktopAgentRuntime assistant thinking events", () => {
await runtime.dispose();
});
+ it("allows the opt-in retry mode to claim beyond both bounded budgets", async () => {
+ const runtime = createRuntime({ onEvent: vi.fn() });
+ runtime.setInfiniteProviderRetry(true);
+ const claim = (runtime as any).claimProviderRetry.bind(runtime);
+ const transient = classifyAgentError("fetch failed");
+ const rateLimited = classifyAgentError("429: too many requests");
+
+ for (let attempt = 1; attempt <= PROVIDER_TRANSIENT_MAX_RETRIES + 2; attempt += 1) {
+ expect(claim(transient, attempt % 2 === 0 ? "stream" : "request")).toBe(attempt);
+ }
+ for (let attempt = 1; attempt <= PROVIDER_RATE_LIMIT_MAX_RETRIES + 2; attempt += 1) {
+ expect(claim(rateLimited, attempt % 2 === 0 ? "stream" : "request")).toBe(attempt);
+ }
+ await runtime.dispose();
+ });
+
+ it("keeps the bounded retry policy when infinite mode is disabled", async () => {
+ const runtime = createRuntime({ onEvent: vi.fn() });
+ const claim = (runtime as any).claimProviderRetry.bind(runtime);
+ const transient = classifyAgentError("fetch failed");
+ for (let attempt = 1; attempt <= PROVIDER_TRANSIENT_MAX_RETRIES; attempt += 1) {
+ expect(claim(transient, "request")).toBe(attempt);
+ }
+ expect(claim(transient, "request")).toBeUndefined();
+ await runtime.dispose();
+ });
+
it("retries a mid-stream rate-limit (429) failure in the same turn", async () => {
const onEvent = vi.fn();
const runtime = createRuntime({ onEvent });
diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts
index ab9dc1fe3..a6d30872d 100644
--- a/packages/agent-runtime/src/runtime.ts
+++ b/packages/agent-runtime/src/runtime.ts
@@ -823,6 +823,8 @@ export type AgentRuntimeOptions = {
turnId?: string;
provider: RuntimeProviderConfig;
thinkingLevel: SessionThinkingLevel;
+ /** Persisted opt-in for retrying transient provider failures until success. */
+ infiniteProviderRetry?: boolean;
systemPrompt?: string;
/** pi-compatible SYSTEM.md / APPEND_SYSTEM.md resolved for the session (issue #542). */
customSystemPrompt?: CustomSystemPrompt;
@@ -1581,6 +1583,8 @@ export class DesktopAgentRuntime {
private providerTransientRetryAttempt = 0;
/** Shared OpenCode-style 429 retry count across setup and stream phases. */
private providerRateLimitRetryAttempt = 0;
+ /** Opt-in mode removes only the retry-count ceiling; abort and backoff stay intact. */
+ private infiniteProviderRetry = false;
private activeProviderRetryAttempt = 0;
private providerRetryInProgress = false;
private suppressProviderRetryRunEnd = false;
@@ -1668,6 +1672,7 @@ export class DesktopAgentRuntime {
this.planningState = proposalKindForMode(this.mode) ? "planning" : "inactive";
this.provider = opts.provider;
this.thinkingLevel = clampThinkingLevel(opts.provider, opts.thinkingLevel);
+ this.infiniteProviderRetry = opts.infiniteProviderRetry === true;
this.host = opts.host;
this.hostCloseUnsubscribe = this.host.onClose?.(() => {
this.cleanupActiveToolProgress();
@@ -1877,6 +1882,7 @@ Delegation rules:
phase: "retrying",
since: Date.now(),
attempt,
+ ...(this.infiniteProviderRetry ? { infinite: true } : {}),
retryDelayMs: delayMs,
error: this.retryActivityError(error),
});
@@ -1952,6 +1958,12 @@ Delegation rules:
);
}
+ /** Update the opt-in retry policy without rebuilding an idle runtime. */
+ setInfiniteProviderRetry(enabled: boolean): void {
+ if (this.disposed) throw new Error("runtime disposed");
+ this.infiniteProviderRetry = enabled;
+ }
+
/** Switch the planning state on this Agent without creating another Agent. */
setMode(mode: Mode): void {
if (this.disposed) throw new Error("runtime disposed");
@@ -4199,6 +4211,7 @@ Delegation rules:
parentToolCallId: toolCallId,
task,
provider,
+ infiniteProviderRetry: this.infiniteProviderRetry,
thinkingLevel,
fallbackModels: (definition.fallbackModels ?? []).map((pin) => ({
key: subagentModelKey(pin),
@@ -5336,8 +5349,10 @@ Delegation rules:
phase: "request" | "stream",
): number | undefined {
if (!error.retriable) return undefined;
+ const infinite = this.infiniteProviderRetry;
if (error.code === "PROVIDER_RATE_LIMITED") {
if (
+ !infinite &&
this.providerRateLimitRetryAttempt >=
PROVIDER_RATE_LIMIT_MAX_RETRIES
) {
@@ -5353,7 +5368,10 @@ Delegation rules:
// next must not multiply the budget or reset it by changing phase.
void phase;
if (!isTransientProviderRetryCode(error.code)) return undefined;
- if (this.providerTransientRetryAttempt >= PROVIDER_TRANSIENT_MAX_RETRIES) {
+ if (
+ !infinite &&
+ this.providerTransientRetryAttempt >= PROVIDER_TRANSIENT_MAX_RETRIES
+ ) {
return undefined;
}
const attempt = ++this.providerTransientRetryAttempt;
@@ -5520,6 +5538,7 @@ Delegation rules:
phase: "retrying",
since: Date.now(),
attempt: retryAttempt,
+ ...(this.infiniteProviderRetry ? { infinite: true } : {}),
retryDelayMs: delayMs,
error: this.retryActivityError(retryError),
});
diff --git a/packages/agent-runtime/src/sidecar.ts b/packages/agent-runtime/src/sidecar.ts
index 885c3c7a2..fcff5a285 100644
--- a/packages/agent-runtime/src/sidecar.ts
+++ b/packages/agent-runtime/src/sidecar.ts
@@ -96,6 +96,7 @@ type RuntimeParams = {
/** Durable host turn ID for the prompt currently being executed. */
turnId?: string;
thinkingLevel?: SessionThinkingLevel;
+ infiniteProviderRetry?: boolean;
provider: RuntimeProviderConfig;
commandShell: CommandShellOption;
pluginTools?: PluginToolDef[];
@@ -349,6 +350,7 @@ async function runtimeFor(
}
if (reusable) {
reusable.setCompactionSettings(params.compactionSettings);
+ reusable.setInfiniteProviderRetry(params.infiniteProviderRetry === true);
reusable.setMode(mode);
return reusable;
}
@@ -385,6 +387,7 @@ async function runtimeFor(
provider,
commandShell: params.commandShell,
thinkingLevel,
+ infiniteProviderRetry: params.infiniteProviderRetry === true,
history,
compaction,
compactionSettings: params.compactionSettings,
diff --git a/packages/agent-runtime/src/subagent.test.ts b/packages/agent-runtime/src/subagent.test.ts
index 4cc0e96d2..90b28518d 100644
--- a/packages/agent-runtime/src/subagent.test.ts
+++ b/packages/agent-runtime/src/subagent.test.ts
@@ -521,6 +521,13 @@ describe("SubagentRun watchdogs", () => {
expect(freshClaim(rateLimited, "stream")).toBeUndefined();
// Permanent failures stay terminal.
expect(freshClaim(classifyAgentError("401: invalid api key"), "request")).toBeUndefined();
+
+ const { run: infinite } = createRun({ infiniteProviderRetry: true });
+ const infiniteClaim = (error: unknown, phase: string) =>
+ (infinite as any).claimProviderRetry(error, phase);
+ for (let attempt = 1; attempt <= PROVIDER_TRANSIENT_MAX_RETRIES + 2; attempt += 1) {
+ expect(infiniteClaim(gateway502, "stream")).toBe(attempt);
+ }
});
it("never terminates the delegate on a turn count", async () => {
diff --git a/packages/agent-runtime/src/subagent.ts b/packages/agent-runtime/src/subagent.ts
index bf138e773..edd44e052 100644
--- a/packages/agent-runtime/src/subagent.ts
+++ b/packages/agent-runtime/src/subagent.ts
@@ -124,6 +124,8 @@ export type SubagentRunOptions = {
/** Provider resolved by Electron main (the definition's pin, or the
* session's provider when the definition pins nothing). */
provider: RuntimeProviderConfig;
+ /** Inherited session policy for retrying transient provider failures. */
+ infiniteProviderRetry?: boolean;
thinkingLevel: SubagentThinkingLevel;
/** User-owned definition pins only, in configured order. Missing bindings fail visibly. */
fallbackModels?: Array<{ key: string; provider?: RuntimeProviderConfig }>;
@@ -457,8 +459,9 @@ export class SubagentRun {
phase: "request" | "stream",
): number | undefined {
if (!error.retriable) return undefined;
+ const infinite = this.opts.infiniteProviderRetry === true;
if (error.code === "PROVIDER_RATE_LIMITED") {
- if (this.providerRateLimitRetryAttempt >= PROVIDER_RATE_LIMIT_MAX_RETRIES) {
+ if (!infinite && this.providerRateLimitRetryAttempt >= PROVIDER_RATE_LIMIT_MAX_RETRIES) {
return undefined;
}
return ++this.providerRateLimitRetryAttempt;
@@ -467,7 +470,7 @@ export class SubagentRun {
// session does, so a delegate is not abandoned on a single gateway 502.
void phase;
if (!isTransientProviderRetryCode(error.code)) return undefined;
- if (this.providerTransientRetryAttempt >= PROVIDER_TRANSIENT_MAX_RETRIES) {
+ if (!infinite && this.providerTransientRetryAttempt >= PROVIDER_TRANSIENT_MAX_RETRIES) {
return undefined;
}
return ++this.providerTransientRetryAttempt;
diff --git a/packages/host-runtime/src/launch-resolver.test.ts b/packages/host-runtime/src/launch-resolver.test.ts
index a58ca4902..5f1e08ca9 100644
--- a/packages/host-runtime/src/launch-resolver.test.ts
+++ b/packages/host-runtime/src/launch-resolver.test.ts
@@ -73,9 +73,21 @@ describe("createHeadlessLaunchResolver", () => {
expect(launch.sidecarParams.pluginTools).toEqual([]);
expect(launch.sidecarParams.projectMemory).toBe("remember me");
expect(launch.sidecarParams.commandShell).toMatchObject({ id: "bash" });
+ expect(launch.sidecarParams.infiniteProviderRetry).toBe(false);
expect(calls.some((call) => call.method === "providers.getSecret")).toBe(true);
});
+ it("forwards the opt-in infinite provider retry setting", async () => {
+ const { host } = hostWith([provider], { p1: "sk-test" });
+ const resolver = createHeadlessLaunchResolver({ getHost: () => host, dataDir: "/data", log: () => undefined });
+ const launch = await resolver.resolve(
+ "s1",
+ { providerId: "p1", modelId: "gpt-a" },
+ { defaultMode: "agent", infiniteProviderRetry: true },
+ );
+ expect(launch.sidecarParams.infiniteProviderRetry).toBe(true);
+ });
+
it("falls back to the default provider and refuses a provider without a secret", async () => {
const { host } = hostWith([provider, { ...provider, id: "p2", name: "Other", hasSecret: false }], { p1: "sk" });
const resolver = createHeadlessLaunchResolver({ getHost: () => host, dataDir: "/data", log: () => undefined });
diff --git a/packages/host-runtime/src/launch-resolver.ts b/packages/host-runtime/src/launch-resolver.ts
index 423a22f60..5dc1003c4 100644
--- a/packages/host-runtime/src/launch-resolver.ts
+++ b/packages/host-runtime/src/launch-resolver.ts
@@ -375,6 +375,7 @@ export function createHeadlessLaunchResolver(options: HeadlessLaunchResolverOpti
mode: normalizeMode(overrides.mode ?? sessionMode ?? defaultMode ?? "agent"),
...(overrides.turnId ? { turnId: overrides.turnId } : {}),
thinkingLevel,
+ infiniteProviderRetry: settings.infiniteProviderRetry === true,
commandShell,
scratchDir: join(dataDir, "scratch", sessionId),
attachmentsDir: join(dataDir, "attachments"),
diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts
index ba4577e30..4c4fba2d2 100644
--- a/packages/i18n/src/locales/de/index.ts
+++ b/packages/i18n/src/locales/de/index.ts
@@ -954,6 +954,8 @@ sklm: {
"noResults": "Keine passenden Einstellungen",
"enterToSend": "Zum Senden eingeben",
"enterToSendDesc": "Aus: Zum Senden ⌘/Strg+Eingabe drücken.",
+ "infiniteProviderRetry": "Bis zum Erfolg wiederholen",
+ "infiniteProviderRetryDesc": "Netzwerk- und vorübergehende Anbieterfehler werden bis zum Erfolg wiederholt. Du kannst den Lauf weiterhin stoppen; der API-Verbrauch kann fortlaufen.",
"linkOpenTarget": "Ziel für Link-Öffnung",
"linkOpenTargetWorkpanel": "Arbeitsbereich-Browser",
"linkOpenTargetExternal": "Standard-Betriebssystem-Browser",
diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts
index 07cf1b72c..e3b69ebcc 100644
--- a/packages/i18n/src/locales/en/index.ts
+++ b/packages/i18n/src/locales/en/index.ts
@@ -963,6 +963,8 @@ sklm: {
noResults: "No matching settings",
enterToSend: "Enter to send",
enterToSendDesc: "Off: press ⌘/Ctrl+Enter to send.",
+ infiniteProviderRetry: "Retry until success",
+ infiniteProviderRetryDesc: "Keep retrying network and transient provider failures until the request succeeds. You can still stop the turn; API usage may continue while enabled.",
linkOpenTarget: "Link open destination",
linkOpenTargetWorkpanel: "Work panel browser",
linkOpenTargetExternal: "Default OS browser",
diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts
index 747d90272..667a54c07 100644
--- a/packages/i18n/src/locales/es/index.ts
+++ b/packages/i18n/src/locales/es/index.ts
@@ -954,6 +954,8 @@ sklm: {
"noResults": "No hay configuraciones coincidentes",
"enterToSend": "Ingresar para enviar",
"enterToSendDesc": "Desactivado: presione ⌘/Ctrl+Entrar para enviar.",
+ "infiniteProviderRetry": "Reintentar hasta tener éxito",
+ "infiniteProviderRetryDesc": "Reintenta los errores de red y temporales hasta que la solicitud tenga éxito. Puedes detener el turno; el uso de la API puede continuar.",
"linkOpenTarget": "Destino al abrir enlaces",
"linkOpenTargetWorkpanel": "Navegador del panel de trabajo",
"linkOpenTargetExternal": "Navegador predeterminado del sistema",
diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts
index 4c80869f3..d69c1f39a 100644
--- a/packages/i18n/src/locales/fr/index.ts
+++ b/packages/i18n/src/locales/fr/index.ts
@@ -954,6 +954,8 @@ sklm: {
"noResults": "Aucun paramètre correspondant",
"enterToSend": "Entrez pour envoyer",
"enterToSendDesc": "Désactivé : appuyez sur ⌘/Ctrl+Entrée pour envoyer.",
+ "infiniteProviderRetry": "Réessayer jusqu’à réussite",
+ "infiniteProviderRetryDesc": "Réessaie les erreurs réseau et temporaires jusqu’à la réussite. Vous pouvez toujours arrêter le tour ; la consommation d’API peut continuer.",
"linkOpenTarget": "Destination d'ouverture des liens",
"linkOpenTargetWorkpanel": "Navigateur du panneau de travail",
"linkOpenTargetExternal": "Navigateur par défaut du système",
diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts
index 229fea665..9c9cb78c7 100644
--- a/packages/i18n/src/locales/ko/index.ts
+++ b/packages/i18n/src/locales/ko/index.ts
@@ -964,6 +964,8 @@ sklm: {
noResults: "일치하는 설정 없음",
enterToSend: "Enter로 보내기",
enterToSendDesc: "끔: ⌘/Ctrl+Enter를 눌러 보내세요.",
+ infiniteProviderRetry: "성공할 때까지 재시도",
+ infiniteProviderRetryDesc: "네트워크 및 일시적인 제공자 오류가 성공할 때까지 계속 재시도됩니다. 언제든 중지할 수 있지만 API 사용량이 계속 발생할 수 있습니다.",
linkOpenTarget: "링크 열기 위치",
linkOpenTargetWorkpanel: "작업 패널 브라우저",
linkOpenTargetExternal: "시스템 기본 브라우저",
diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts
index 521738144..a3e57db14 100644
--- a/packages/i18n/src/locales/tr/index.ts
+++ b/packages/i18n/src/locales/tr/index.ts
@@ -964,6 +964,8 @@ sklm: {
noResults: "Eşleşen ayar yok",
enterToSend: "Enter ile gönder",
enterToSendDesc: "Kapalı: göndermek için ⌘/Ctrl+Enter.",
+ infiniteProviderRetry: "Başarılı olana kadar yeniden dene",
+ infiniteProviderRetryDesc: "Ağ ve geçici sağlayıcı hataları istek başarılı olana kadar yeniden denenir. Çalışmayı yine durdurabilirsiniz; açıkken API kullanımı sürebilir.",
linkOpenTarget: "Bağlantı açma hedefi",
linkOpenTargetWorkpanel: "Çalışma paneli tarayıcısı",
linkOpenTargetExternal: "Varsayılan sistem tarayıcısı",
diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts
index bf8ce368b..6e6020c2d 100644
--- a/packages/i18n/src/locales/zh-CN/index.ts
+++ b/packages/i18n/src/locales/zh-CN/index.ts
@@ -953,6 +953,8 @@ sklm: {
noResults: "没有匹配的设置项",
enterToSend: "回车发送",
enterToSendDesc: "关闭后按 ⌘/Ctrl+Enter 发送。",
+ infiniteProviderRetry: "无尽重试",
+ infiniteProviderRetryDesc: "网络或临时服务故障会一直重试直到成功。仍可随时停止,但开启后可能持续消耗 API 用量。",
linkOpenTarget: "链接打开位置",
linkOpenTargetWorkpanel: "工作区浏览器",
linkOpenTargetExternal: "系统默认浏览器",
diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts
index 6665915eb..6bd827035 100644
--- a/packages/i18n/src/locales/zh-TW/index.ts
+++ b/packages/i18n/src/locales/zh-TW/index.ts
@@ -953,6 +953,8 @@ sklm: {
noResults: "沒有匹配的設定項",
enterToSend: "回車傳送",
enterToSendDesc: "關閉後按 ⌘/Ctrl+Enter 傳送。",
+ infiniteProviderRetry: "無盡重試",
+ infiniteProviderRetryDesc: "網路或暫時性服務故障會持續重試直到成功。仍可隨時停止,但開啟後可能持續消耗 API 用量。",
linkOpenTarget: "連結開啟位置",
linkOpenTargetWorkpanel: "工作區瀏覽器",
linkOpenTargetExternal: "系統預設瀏覽器",
diff --git a/packages/shared/src/changelog-de.ts b/packages/shared/src/changelog-de.ts
index 278a0352e..7931ce40b 100644
--- a/packages/shared/src/changelog-de.ts
+++ b/packages/shared/src/changelog-de.ts
@@ -10,6 +10,7 @@ export const deEntries: ChangelogEntry[] = [
"Das ausgewählte Standardmodell bleibt beim Bearbeiten von Anbietern erhalten und fällt sicher zurück, wenn es entfernt wird.",
"Verschachtelte Denk- und Werkzeugaktivitäten lassen sich leichter lesen, navigieren und wiederherstellen.",
"Composer-Layouts, Reasoning-Steuerung und die Theme-Konsistenz im Arbeitsbereich wurden verbessert.",
+ "Optionale Einstellung, Netzwerk- und vorübergehende Anbieterfehler bis zum Erfolg zu wiederholen.",
],
},
diff --git a/packages/shared/src/changelog-es.ts b/packages/shared/src/changelog-es.ts
index 8a320d66f..c5fc55490 100644
--- a/packages/shared/src/changelog-es.ts
+++ b/packages/shared/src/changelog-es.ts
@@ -10,6 +10,7 @@ export const esEntries: ChangelogEntry[] = [
"El modelo predeterminado seleccionado se conserva al editar proveedores y se aplica un respaldo seguro si se elimina.",
"Las secciones anidadas de pensamiento y actividad de herramientas son más fáciles de leer, recorrer y recuperar.",
"Mejora los diseños del Composer, los controles de razonamiento y la coherencia de temas del espacio de trabajo.",
+ "Añade un interruptor opcional en Ajustes para reintentar fallos de red y temporales del proveedor hasta que tengan éxito.",
],
},
diff --git a/packages/shared/src/changelog-fr.ts b/packages/shared/src/changelog-fr.ts
index ffc7b3273..aa9aaf9fa 100644
--- a/packages/shared/src/changelog-fr.ts
+++ b/packages/shared/src/changelog-fr.ts
@@ -10,6 +10,7 @@ export const frEntries: ChangelogEntry[] = [
"Le modèle par défaut sélectionné est conservé lors de la modification des fournisseurs, avec un repli sûr s'il est supprimé.",
"Les sections imbriquées de réflexion et d'activité des outils sont plus faciles à lire, parcourir et restaurer.",
"Améliore les mises en page du Composer, les contrôles de raisonnement et la cohérence des thèmes de l'espace de travail.",
+ "Ajoute un interrupteur optionnel dans Réglages pour réessayer les erreurs réseau et temporaires jusqu’à réussite.",
],
},
diff --git a/packages/shared/src/changelog-ko.ts b/packages/shared/src/changelog-ko.ts
index f1de12736..8d53800e3 100644
--- a/packages/shared/src/changelog-ko.ts
+++ b/packages/shared/src/changelog-ko.ts
@@ -10,6 +10,7 @@ export const koEntries: ChangelogEntry[] = [
"제공자를 편집할 때 선택한 기본 모델을 유지하며, 모델이 삭제되면 안전하게 대체 모델로 전환합니다.",
"중첩된 사고와 도구 활동을 더 쉽게 읽고 탐색하고 복구할 수 있습니다.",
"Composer 레이아웃, 추론 수준 제어, 작업 공간 테마 일관성을 개선했습니다.",
+ "설정에서 네트워크 및 일시적인 제공자 오류를 성공할 때까지 재시도하는 선택 스위치를 추가했습니다.",
],
},
diff --git a/packages/shared/src/changelog-tr.ts b/packages/shared/src/changelog-tr.ts
index 30bda233a..18ee14192 100644
--- a/packages/shared/src/changelog-tr.ts
+++ b/packages/shared/src/changelog-tr.ts
@@ -10,6 +10,7 @@ export const trEntries: ChangelogEntry[] = [
"Sağlayıcıları düzenlerken seçili varsayılan model korunur; model kaldırılırsa güvenli bir yedek kullanılır.",
"İç içe düşünme ve araç etkinliği bölümlerini okumak, gezinmek ve kurtarmak kolaylaşır.",
"Composer düzenleri, akıl yürütme kontrolleri ve çalışma alanı tema tutarlılığı iyileştirilir.",
+ "Ayarlara, ağ ve geçici sağlayıcı hatalarını başarılı olana kadar yeniden deneyen isteğe bağlı bir anahtar ekler.",
],
},
diff --git a/packages/shared/src/changelog.ts b/packages/shared/src/changelog.ts
index ca4d4d16a..828aa0386 100644
--- a/packages/shared/src/changelog.ts
+++ b/packages/shared/src/changelog.ts
@@ -37,6 +37,7 @@ const enEntries: ChangelogEntry[] = [
"Keep the selected default model when editing providers, and fall back safely when it is removed.",
"Make nested thinking and tool activity disclosures easier to read, navigate, and recover.",
"Improve Composer layouts, reasoning controls, and theme consistency across the workspace.",
+ "Add an opt-in Settings switch to keep retrying network and transient provider failures until they succeed.",
],
},
@@ -831,6 +832,7 @@ const zhCNEntries: ChangelogEntry[] = [
"编辑提供商时保留已选的默认模型;模型被移除后安全回退。",
"优化嵌套思考和工具活动的展开收起,更易阅读、导航和恢复。",
"改进 Composer 布局、推理强度控制和工作区主题一致性。",
+ "设置里新增可选的无尽重试:网络或临时服务故障会一直重试到成功。",
],
},
@@ -1625,6 +1627,7 @@ const zhTWEntries: ChangelogEntry[] = [
"編輯提供商時保留已選的預設模型;模型被移除後安全回退。",
"優化巢狀思考和工具活動的展開收合,更易閱讀、導覽和恢復。",
"改進 Composer 版面、推理強度控制和工作區主題一致性。",
+ "設定裡新增可選的無盡重試:網路或暫時性服務故障會持續重試到成功。",
],
},
diff --git a/packages/shared/src/types/sessions.ts b/packages/shared/src/types/sessions.ts
index 24e87febc..119625396 100644
--- a/packages/shared/src/types/sessions.ts
+++ b/packages/shared/src/types/sessions.ts
@@ -178,6 +178,8 @@ export type AgentActivity =
phase: "retrying";
since: number;
attempt: number;
+ /** The retry budget is unbounded for this active turn. */
+ infinite?: boolean;
retryDelayMs?: number;
error?: AgentActivityError;
}
diff --git a/packages/shared/src/types/settings.ts b/packages/shared/src/types/settings.ts
index 2f409ad0b..b351f1ef3 100644
--- a/packages/shared/src/types/settings.ts
+++ b/packages/shared/src/types/settings.ts
@@ -27,6 +27,11 @@ export type AppSettings = {
/** Host speech bindings. Absent means voice actions stay disabled. */
speech?: SpeechSettings;
defaultMode: Mode;
+ /**
+ * Keep retryable provider/network failures retrying until the request succeeds.
+ * Absent and false use the bounded ten-retry policy.
+ */
+ infiniteProviderRetry?: boolean;
/** Configured command shell for the agent Bash protocol tool. */
defaultCommandShell?: CommandShellId;
/**