Skip to content

Commit 354b01b

Browse files
authored
Enable default model retries (#4620)
## Summary Enable provider retries for Electric Agents model calls by default. Transient LLM/provider failures now get two provider-level retry attempts before the run fails, while callers can still opt out with `modelMaxRetries: 0`. ## Root Cause The agent runtime already threaded `modelMaxRetries` through to `pi-ai` provider stream calls, but its default was `0`. That meant normal/default agent runs did not retry retryable provider failures such as transient server errors, rate limits, or network blips unless each agent explicitly configured retries. ## Approach - Change the runtime default from no retries to two provider retries: ```ts const DEFAULT_MODEL_MAX_RETRIES = 2 ``` - Keep the existing `modelMaxRetries` option behavior intact. Because the runtime still uses nullish coalescing, explicit values continue to override the default, including `modelMaxRetries: 0` for no-retry behavior. - Add coverage for the default options passed into provider stream calls. - Update existing option-forwarding tests to use `modelMaxRetries: 5`, so they clearly verify explicit overrides rather than matching the new default by coincidence. ## Key Invariants - Default provider stream calls receive `{ timeoutMs: 30_000, maxRetries: 2 }`. - Explicit `modelMaxRetries` values override the default. - Explicit `modelMaxRetries: 0` remains a valid way to disable retries. - Existing stream options, especially `AbortSignal`, continue to be preserved when timeout/retry options are injected. ## Non-goals - This does not add Electric-runtime-level retry orchestration around failed assistant messages. - This does not change model provider error classification. - This does not alter run/event replay semantics or retry after partially emitted model output. ## Trade-offs This is the smallest safe mitigation: retries happen in the provider/pi-ai layer before the Electric runtime observes a failed stream. That avoids introducing replay or duplicate-event semantics in this PR. The trade-off is that transient failures may now take longer to surface, because the provider call can retry before failing. Users who prefer fail-fast behavior can set `modelMaxRetries: 0`. ## Verification ```bash cd packages/agents-runtime pnpm exec vitest run test/pi-adapter.test.ts test/model-provider-error.test.ts --pool-options.threads.maxThreads=2 ``` Result: ```txt ✓ test/pi-adapter.test.ts (30 tests) ✓ test/model-provider-error.test.ts (11 tests) Test Files 2 passed Tests 41 passed ``` ## Files changed - `.changeset/enable-agent-model-retries.md` — Adds a patch changeset for `@electric-ax/agents-runtime` describing the default retry behavior and opt-out. - `packages/agents-runtime/src/pi-adapter.ts` — Changes the default model retry count from `0` to `2`. - `packages/agents-runtime/test/pi-adapter.test.ts` — Adds default option coverage and strengthens explicit override assertions.
1 parent 9d063e4 commit 354b01b

3 files changed

Lines changed: 64 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@electric-ax/agents-runtime': patch
3+
---
4+
5+
Enable provider retries for agent model calls by default so transient LLM errors are retried. Set `modelMaxRetries: 0` to preserve the previous no-retry behavior.

packages/agents-runtime/src/pi-adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export interface PiAdapterOptions {
8686
}
8787

8888
const DEFAULT_MODEL_TIMEOUT_MS = 30_000
89-
const DEFAULT_MODEL_MAX_RETRIES = 0
89+
const DEFAULT_MODEL_MAX_RETRIES = 2
9090

9191
interface PiAgentAdapterConfig {
9292
entityUrl: string

packages/agents-runtime/test/pi-adapter.test.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,60 @@ describe(`createPiAgentAdapter`, () => {
127127
expect(handle.isRunning()).toBe(false)
128128
})
129129

130+
it(`passes default timeout and retry options to provider stream calls`, async () => {
131+
let seenOptions: { timeoutMs?: number; maxRetries?: number } | undefined
132+
const completedMessage: AssistantMessage = {
133+
role: `assistant`,
134+
content: [{ type: `text`, text: `ok` }],
135+
api: `anthropic-messages`,
136+
provider: `anthropic`,
137+
model: `claude-sonnet-4-5-20250929`,
138+
usage: {
139+
input: 0,
140+
output: 0,
141+
cacheRead: 0,
142+
cacheWrite: 0,
143+
totalTokens: 0,
144+
cost: {
145+
input: 0,
146+
output: 0,
147+
cacheRead: 0,
148+
cacheWrite: 0,
149+
total: 0,
150+
},
151+
},
152+
stopReason: `stop`,
153+
timestamp: Date.now(),
154+
}
155+
156+
const factory = createPiAgentAdapter({
157+
systemPrompt: `Test system prompt`,
158+
model: `claude-sonnet-4-5-20250929`,
159+
tools: [],
160+
streamFn: (_model, _context, options) => {
161+
seenOptions = {
162+
timeoutMs: options?.timeoutMs,
163+
maxRetries: options?.maxRetries,
164+
}
165+
const stream = createAssistantMessageEventStream()
166+
queueMicrotask(() => stream.end(completedMessage))
167+
return stream
168+
},
169+
})
170+
171+
const handle = factory({
172+
entityUrl: `test/entity-1`,
173+
epoch: 1,
174+
messages: [],
175+
outboundIdSeed: { run: 0, step: 0, msg: 0, tc: 0, reasoning: 0 },
176+
writeEvent: (_event: ChangeEvent) => {},
177+
})
178+
179+
await handle.run(`hello`)
180+
181+
expect(seenOptions).toEqual({ timeoutMs: 30_000, maxRetries: 2 })
182+
})
183+
130184
it(`passes timeout and retry options to each provider stream call`, async () => {
131185
const seenOptions: Array<{ timeoutMs?: number; maxRetries?: number }> = []
132186
const completedMessage: AssistantMessage = {
@@ -158,7 +212,7 @@ describe(`createPiAgentAdapter`, () => {
158212
model: `claude-sonnet-4-5-20250929`,
159213
tools: [],
160214
modelTimeoutMs: 1234,
161-
modelMaxRetries: 2,
215+
modelMaxRetries: 5,
162216
streamFn: (_model, _context, options) => {
163217
seenOptions.push({
164218
timeoutMs: options?.timeoutMs,
@@ -180,7 +234,7 @@ describe(`createPiAgentAdapter`, () => {
180234

181235
await handle.run(`hello`)
182236

183-
expect(seenOptions).toEqual([{ timeoutMs: 1234, maxRetries: 2 }])
237+
expect(seenOptions).toEqual([{ timeoutMs: 1234, maxRetries: 5 }])
184238
})
185239

186240
it(`preserves existing stream options while injecting timeout and retry options`, async () => {
@@ -214,11 +268,11 @@ describe(`createPiAgentAdapter`, () => {
214268
model: `claude-sonnet-4-5-20250929`,
215269
tools: [],
216270
modelTimeoutMs: 1234,
217-
modelMaxRetries: 2,
271+
modelMaxRetries: 5,
218272
streamFn: (_model, _context, options) => {
219273
capturedSignal = options?.signal
220274
expect(options?.timeoutMs).toBe(1234)
221-
expect(options?.maxRetries).toBe(2)
275+
expect(options?.maxRetries).toBe(5)
222276
const stream = createAssistantMessageEventStream()
223277
queueMicrotask(() => stream.end(completedMessage))
224278
return stream

0 commit comments

Comments
 (0)