Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/desktop/electron/main/runtime/provider-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -192,6 +196,7 @@ export function createProviderCatalogRuntime({
}
const value = settings as T & {
defaultCommandShell?: unknown;
infiniteProviderRetry?: unknown;
networkProxy?: unknown;
};
if (
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron/main/runtime/session-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/features/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,28 @@ export function SettingsPage() {
<span className="settings-toggle-thumb" />
</button>
</SettingsRow>
<SettingsRow
title={t("settings.infiniteProviderRetry")}
description={t("settings.infiniteProviderRetryDesc")}
>
<button
type="button"
className={cx(
"settings-toggle",
settings.infiniteProviderRetry === true && "on",
)}
role="switch"
aria-checked={settings.infiniteProviderRetry === true}
aria-label={t("settings.infiniteProviderRetry")}
onClick={() =>
void saveSettings({
infiniteProviderRetry: settings.infiniteProviderRetry !== true,
})
}
>
<span className="settings-toggle-thumb" />
</button>
</SettingsRow>
<LargePasteThresholdRow
settings={settings}
saveSettings={saveSettings}
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,10 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
return {
...settings,
defaultMode: normalizeMode((settings as { defaultMode?: unknown }).defaultMode),
infiniteProviderRetry:
(settings as { infiniteProviderRetry?: unknown }).infiniteProviderRetry === true
? true
: undefined,
defaultCommandShell: isCommandShellId(
(settings as { defaultCommandShell?: unknown }).defaultCommandShell,
)
Expand Down Expand Up @@ -374,6 +378,7 @@ export function validateSettingsWrite(settings: AppSettings): AppSettings {
largePasteThreshold?: unknown;
fontScale?: unknown;
chatContentMaxWidth?: unknown;
infiniteProviderRetry?: unknown;
networkProxy?: unknown;
};
if (
Expand Down Expand Up @@ -409,6 +414,14 @@ export function validateSettingsWrite(settings: AppSettings): AppSettings {
});
}
}
if (
Object.prototype.hasOwnProperty.call(value, "infiniteProviderRetry") &&
typeof value.infiniteProviderRetry !== "boolean"
) {
throw Object.assign(new Error("infiniteProviderRetry is invalid"), {
errorCode: "INVALID_PARAMS",
});
}
if (Object.prototype.hasOwnProperty.call(value, "networkProxy")) {
const proxy = validateNetworkProxy(value.networkProxy);
if (!proxy.ok) {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/settings-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export const SETTINGS_NAV: SettingsNavEntry[] = [
"settings.commandShell",
"settings.linkOpenTarget",
"settings.enterToSend",
"settings.infiniteProviderRetry",
"settings.infiniteProviderRetryDesc",
"settings.thinkingDisplayMode",
"settings.thinkingDisplayDetailed",
"settings.thinkingDisplayCompact",
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/test/settings-general.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ test("Basics and AI tabs expose their respective app and AI controls", () => {
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/);
Expand All @@ -120,13 +121,16 @@ 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)}:`));
assert.match(zhLocaleSource, new RegExp(`${key.split(".").at(-1)}:`));
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/);
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/test/turn-process.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions crates/host-core/src/rpc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions docs/adr/0206-ten-provider-retries-and-progress-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/03-runtime/01-ipc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[] };

Expand Down
9 changes: 9 additions & 0 deletions docs/spec/03-runtime/02-agent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/spec/03-runtime/08-error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
7 changes: 5 additions & 2 deletions docs/spec/04-ux/06-settings-ia.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[] };

Expand Down
5 changes: 5 additions & 0 deletions docs/zh-CN/spec/03-runtime/02-agent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`、
Expand Down
3 changes: 2 additions & 1 deletion docs/zh-CN/spec/03-runtime/08-error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`、
Expand Down
3 changes: 2 additions & 1 deletion docs/zh-CN/spec/04-ux/06-settings-ia.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@
都以相同方式展开。关闭时的触发器按当前文案收缩,不超过设置控件列。
- **默认项**卡:主机支持的默认运行模式(Agent / Plan / Goal)、
命令 Shell 选择、链接打开目标、上下文用量显示(剩余或已用)、
思考显示模式、回车发送控制和大段文本粘贴阈值。链接打开目标默认使用工作面板浏览器,
思考显示模式、回车发送控制、无尽重试开关和大段文本粘贴阈值。无尽重试默认关闭;开启后网络/临时提供商故障会持续到成功,
但停止按钮仍可取消回合,开启期间可能持续消耗 API 用量。链接打开目标默认使用工作面板浏览器,
可将对话、会话记录和插件页的 HTTP(S) 点击路由到系统浏览器。插件/设置页
若目标是工作面板,会先回到对话再打开(不记入导航栈),避免被遮罩挡住;没有会话时才回退到
系统浏览器。工作区 HTML
Expand Down
4 changes: 3 additions & 1 deletion docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -3676,6 +3676,7 @@ IPC 请求无法关闭。
6. 运行 503 `Retry-After` 装置并检查观察到的等待。
7. 重新加载会话并验证是否只有已完成的响应或
单终端故障助手依然耐用。
8. 打开设置中的「无尽重试」后,故障超过默认次数仍继续;停止回合时不再发起后续请求,关闭后恢复有界上限。
- **预期**:
- `terminated` 被分类为 `STREAM_FAILED`,上游网关
`502`/`503`/`504` 被分类为可重试的 `PROVIDER_ERROR`。
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading