Skip to content

Commit a74ab44

Browse files
authored
feat: raise default per-step LLM retry budget to 10 attempts (#1740)
* feat: raise default per-step LLM retry budget to 10 attempts The default of 3 attempts only produced ~1.5s of backoff (0.5s/1s), so sustained provider overload (429) surfaced to the user almost immediately. With 10 attempts the exponential ramp (500ms base, x2, 32s cap, 25% jitter) waits out multi-minute overload windows before failing the turn. Applies to both agent-core (chatWithRetry) and agent-core-v2 (stepRetry service); loop_control.max_retries_per_step still overrides. * test(agent-core): pin retry-dependent tests to the new default budget - error-paths: the warn-log attempt field now reads 1/10 with the default 10-attempt budget - goal-session pause tests: every LLM call throws a retryable error, so the default 10-attempt backoff (~2min) blew the 5s test timeout; pin maxRetriesPerStep=1 since the pause behavior does not depend on the retry count
1 parent 6eb8e13 commit a74ab44

10 files changed

Lines changed: 92 additions & 27 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Increase the default per-step LLM retry budget from 3 to 10 attempts, so transient provider failures (429 / overload) are retried with exponential backoff for a few minutes before the turn fails. Tune with `loop_control.max_retries_per_step` in config.toml.

docs/en/configuration/config-files.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ effort = "high"
5252
keep = "all"
5353

5454
[loop_control]
55-
max_retries_per_step = 3
55+
max_retries_per_step = 10
5656
reserved_context_size = 50000
5757

5858
[background]
@@ -198,7 +198,7 @@ You can also switch models temporarily without touching the config file — by s
198198
| Field | Type | Default | Description |
199199
| --- | --- | --- | --- |
200200
| `max_steps_per_turn` | `integer` || Maximum steps per turn; unset or `0` means unlimited |
201-
| `max_retries_per_step` | `integer` | `3` | Maximum retries after a step failure |
201+
| `max_retries_per_step` | `integer` | `10` | Maximum retries after a step failure |
202202
| `reserved_context_size` | `integer` || Number of tokens reserved for model output; automatic compaction is triggered when the remaining context window falls below this value |
203203

204204
## `background`

docs/zh/configuration/config-files.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ effort = "high"
5252
keep = "all"
5353

5454
[loop_control]
55-
max_retries_per_step = 3
55+
max_retries_per_step = 10
5656
reserved_context_size = 50000
5757

5858
[background]
@@ -198,7 +198,7 @@ display_name = "Kimi for Coding (custom)"
198198
| 字段 | 类型 | 默认值 | 说明 |
199199
| --- | --- | --- | --- |
200200
| `max_steps_per_turn` | `integer` || 单轮最大步数;不设或设为 `0` 则无上限 |
201-
| `max_retries_per_step` | `integer` | `3` | 单步失败后的最大重试次数 |
201+
| `max_retries_per_step` | `integer` | `10` | 单步失败后的最大重试次数 |
202202
| `reserved_context_size` | `integer` || 预留给模型输出的 token 数;上下文窗口剩余量低于此值时触发自动压缩 |
203203

204204
## `background`

packages/agent-core-v2/src/_base/utils/retry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66

77
import { abortable } from '#/_base/utils/abort';
88

9-
export const DEFAULT_MAX_RETRY_ATTEMPTS = 3;
9+
// Default retry budget per step: 10 attempts (9 retries). Kept in sync with
10+
// v1 (`agent-core/loop/retry.ts`): the exponential ramp below climbs 0.5s,
11+
// 1s, 2s … up to the 32s cap, giving roughly 2–3 minutes of total wait to
12+
// ride out a typical provider overload window (sustained 429s).
13+
export const DEFAULT_MAX_RETRY_ATTEMPTS = 10;
1014

1115
const BASE_DELAY_MS = 500;
1216
const MAX_DELAY_MS = 32_000;

packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe('stepRetry plugin', () => {
7070
step: 1,
7171
failedAttempt: 1,
7272
nextAttempt: 2,
73-
maxAttempts: 3,
73+
maxAttempts: 10,
7474
delayMs: expect.any(Number),
7575
errorName: 'APIConnectionError',
7676
errorMessage: 'terminated',
@@ -102,11 +102,11 @@ describe('stepRetry plugin', () => {
102102
const result = await runTurn(1);
103103

104104
expect(result.type).toBe('failed');
105-
expect(calls).toBe(3);
106-
expect(rpcEvents('turn.step.retrying')).toHaveLength(2);
105+
expect(calls).toBe(10);
106+
expect(rpcEvents('turn.step.retrying')).toHaveLength(9);
107107
expect(rpcEvents('turn.step.interrupted')).toEqual([
108108
expect.objectContaining({
109-
args: expect.objectContaining({ reason: 'error', step: 3 }),
109+
args: expect.objectContaining({ reason: 'error', step: 10 }),
110110
}),
111111
]);
112112
});
@@ -221,7 +221,7 @@ describe('stepRetry plugin', () => {
221221

222222
const first = await runTurn(1);
223223
expect(first.type).toBe('failed');
224-
expect(calls).toBe(3);
224+
expect(calls).toBe(10);
225225

226226
failing = false;
227227
const second = await runTurn(2);

packages/agent-core/src/loop/retry.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@ import type { LoopEventDispatcher } from './events';
77
import { isAbortError } from './errors';
88
import type { LLM, LLMChatParams, LLMChatResponse } from './llm';
99

10-
export const DEFAULT_MAX_RETRY_ATTEMPTS = 3;
10+
// Default retry budget per step: 10 attempts (9 retries). With the
11+
// exponential ramp below the backoff climbs 0.5s, 1s, 2s … up to the 32s
12+
// cap, giving roughly 2–3 minutes of total wait — enough to ride out a
13+
// typical provider overload window (sustained 429s) instead of surfacing
14+
// the error after a couple of quick retries.
15+
export const DEFAULT_MAX_RETRY_ATTEMPTS = 10;
1116

1217
const BASE_DELAY_MS = 500;
13-
// Per-attempt backoff cap (32s). With the default 3 attempts the ramp
14-
// (0.5s, 1s) never reaches the cap, so interactive runs are unaffected; it
15-
// only matters for high-attempt configs (e.g. eval harnesses with
16-
// `max_retries_per_step = 10`), where it lets retries ride out multi-minute
17-
// provider overload instead of giving up after a few seconds of backoff.
18+
// Per-attempt backoff cap (32s). The default 10-attempt ramp reaches the
19+
// cap on the 7th retry, so most of the budget is spent at the cap waiting
20+
// out multi-minute provider overload.
1821
const MAX_DELAY_MS = 32_000;
1922
const RETRY_FACTOR = 2;
2023
// Up to 25% jitter on top of the exponential base to avoid herd retries.

packages/agent-core/test/agent/turn.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2107,7 +2107,7 @@ describe('Agent turn flow', () => {
21072107
const payloads = requestLogs.map((entry) => entry.payload as Record<string, unknown>);
21082108
expect(payloads[0]).toMatchObject({ turnStep: '0.1' });
21092109
expect(payloads[0]).not.toHaveProperty('attempt');
2110-
expect(payloads[1]).toMatchObject({ turnStep: '0.1', attempt: '2/3' });
2110+
expect(payloads[1]).toMatchObject({ turnStep: '0.1', attempt: '2/10' });
21112111
});
21122112

21132113
it('force-refreshes OAuth credentials on video upload 401 and surfaces the provider auth error when replay 401', async () => {

packages/agent-core/test/harness/goal-session.test.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -478,9 +478,19 @@ describe('goal session end-to-end', () => {
478478
it('pauses the goal on provider rate limits', async () => {
479479
const sessionDir = await makeTempDir();
480480
const events: Array<Record<string, unknown>> = [];
481-
const { session, agent } = await setupSession(sessionDir, events, ['GetGoal'], async () => {
482-
throw new APIStatusError(429, 'Rate limited', 'req-429');
483-
});
481+
// Pin the retry budget to 1 attempt: the pause behavior under test does
482+
// not depend on retries, and the default 10-attempt backoff would blow
483+
// the test timeout.
484+
const { session, agent } = await setupSession(
485+
sessionDir,
486+
events,
487+
['GetGoal'],
488+
async () => {
489+
throw new APIStatusError(429, 'Rate limited', 'req-429');
490+
},
491+
undefined,
492+
{ providers: {}, loopControl: { maxRetriesPerStep: 1 } },
493+
);
484494
const api = new SessionAPIImpl(session);
485495
await api.createGoal({ agentId: 'main', objective: 'work' });
486496

@@ -495,9 +505,19 @@ describe('goal session end-to-end', () => {
495505
it('pauses the goal on provider connection errors', async () => {
496506
const sessionDir = await makeTempDir();
497507
const events: Array<Record<string, unknown>> = [];
498-
const { session, agent } = await setupSession(sessionDir, events, ['GetGoal'], async () => {
499-
throw new APIConnectionError('socket hang up');
500-
});
508+
// Pin the retry budget to 1 attempt: the pause behavior under test does
509+
// not depend on retries, and the default 10-attempt backoff would blow
510+
// the test timeout.
511+
const { session, agent } = await setupSession(
512+
sessionDir,
513+
events,
514+
['GetGoal'],
515+
async () => {
516+
throw new APIConnectionError('socket hang up');
517+
},
518+
undefined,
519+
{ providers: {}, loopControl: { maxRetriesPerStep: 1 } },
520+
);
501521
const api = new SessionAPIImpl(session);
502522
await api.createGoal({ agentId: 'main', objective: 'work' });
503523

packages/agent-core/test/loop/error-paths.e2e.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe('runTurn — error paths', () => {
5858
message: 'llm request failed',
5959
payload: {
6060
turnStep: 'turn-1.1',
61-
attempt: '1/3',
61+
attempt: '1/10',
6262
model: 'fake-model',
6363
errorName: 'Error',
6464
errorMessage: 'upstream blew up',

packages/agent-core/test/loop/retry.test.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { describe, expect, it } from 'vitest';
99
import type { KimiConfig } from '#/config';
1010
import { ErrorCodes, KimiError } from '#/errors';
1111
import type { LLM, LLMChatParams, LLMChatResponse } from '#/loop/llm';
12-
import { chatWithRetry, retryBackoffDelays } from '#/loop/retry';
12+
import { chatWithRetry, DEFAULT_MAX_RETRY_ATTEMPTS, retryBackoffDelays } from '#/loop/retry';
1313
import { ProviderManager } from '#/session/provider-manager';
1414

1515
function okResponse(): LLMChatResponse {
@@ -57,7 +57,7 @@ describe('chatWithRetry: terminated stream drops', () => {
5757

5858
expect(seenFields).toEqual([
5959
{ projection: 'strict', turnStep: 't.1' },
60-
{ projection: 'strict', turnStep: 't.1', attempt: '2/3' },
60+
{ projection: 'strict', turnStep: 't.1', attempt: '2/10' },
6161
]);
6262
});
6363

@@ -168,7 +168,7 @@ describe('retryBackoffDelays', () => {
168168
expect(maxSeen).toBeGreaterThan(30_000);
169169
});
170170

171-
it('keeps default-attempt retries quick so interactive runs are not slowed', () => {
171+
it('keeps low-attempt configs quick so latency-sensitive runs are not slowed', () => {
172172
// 3 attempts -> 2 delays at the bottom of the ramp (~0.5s / ~1s before
173173
// jitter); their sum stays small.
174174
const delays = retryBackoffDelays(3);
@@ -177,6 +177,39 @@ describe('retryBackoffDelays', () => {
177177
});
178178
});
179179

180+
describe('chatWithRetry: default retry budget', () => {
181+
it('retries up to DEFAULT_MAX_RETRY_ATTEMPTS before giving up', async () => {
182+
// A sustained 429 carries a 1ms server retry-after so the test exercises
183+
// the full default budget without sleeping through the real backoff.
184+
let calls = 0;
185+
const captured: Array<{ type: string }> = [];
186+
const llm: LLM = {
187+
systemPrompt: '',
188+
modelName: 'mock',
189+
isRetryableError: (e) => isRetryableGenerateError(e),
190+
async chat(): Promise<LLMChatResponse> {
191+
calls += 1;
192+
throw new APIProviderRateLimitError('rate limited', null, 1);
193+
},
194+
};
195+
const input = makeInput(llm, new AbortController().signal);
196+
197+
await expect(
198+
chatWithRetry({
199+
...input,
200+
dispatchEvent: async (event) => {
201+
captured.push(event as { type: string });
202+
},
203+
}),
204+
).rejects.toMatchObject({ name: 'APIProviderRateLimitError' });
205+
206+
expect(calls).toBe(DEFAULT_MAX_RETRY_ATTEMPTS);
207+
expect(captured.filter((e) => e.type === 'step.retrying')).toHaveLength(
208+
DEFAULT_MAX_RETRY_ATTEMPTS - 1,
209+
);
210+
});
211+
});
212+
180213
describe('chatWithRetry: honors server retry-after', () => {
181214
it('uses the error retryAfterMs as the retry delay instead of the backoff', async () => {
182215
let calls = 0;

0 commit comments

Comments
 (0)