Skip to content

Commit 73c6f89

Browse files
authored
Handle model provider timeouts and errors (#4521)
Fixes #4520. ## Summary - add default model-provider timeout/retry settings for Pi-backed agent runs - classify provider failures into durable MODEL_PROVIDER_* error codes - attach provider errors to the active run so the UI exits thinking and displays error_code + message inline - preserve classified provider error codes in process-wake error rows - add model provider error classification tests ## Verification - pnpm --filter @electric-ax/agents-mcp build - pnpm --filter @electric-ax/agents-runtime typecheck - pnpm --filter @electric-ax/agents-runtime exec vitest run test/model-provider-error.test.ts - pnpm --filter @electric-ax/agents-runtime build - pnpm --filter @electric-ax/agents-server-ui typecheck
1 parent bee63df commit 73c6f89

12 files changed

Lines changed: 628 additions & 21 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@electric-ax/agents-runtime": patch
3+
"@electric-ax/agents-server-ui": patch
4+
---
5+
6+
Add default model-provider timeout/error handling for agent runs and render durable run errors in the UI.

packages/agents-runtime/src/context-factory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,9 @@ export function createHandlerContext<TState extends StateProxy = StateProxy>(
755755
getApiKey: activeAgentConfig.getApiKey,
756756

757757
onPayload: activeAgentConfig.onPayload,
758+
759+
modelTimeoutMs: activeAgentConfig.modelTimeoutMs,
760+
modelMaxRetries: activeAgentConfig.modelMaxRetries,
758761
})
759762
const handle = adapterFactory({
760763
entityUrl: config.entityUrl,

packages/agents-runtime/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,13 @@ export type {
200200
export { buildSections, buildTimelineEntries } from './use-chat'
201201
export type { EntityTimelineEntry } from './use-chat'
202202
export { appendPathToUrl } from './url'
203+
export {
204+
ModelProviderError,
205+
classifyModelProviderError,
206+
modelProviderErrorMessage,
207+
toModelProviderError,
208+
} from './model-provider-error'
209+
export type { ModelProviderErrorCode } from './model-provider-error'
203210

204211
export {
205212
defaultProjection,
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
export type ModelProviderErrorCode =
2+
| `MODEL_PROVIDER_TIMEOUT`
3+
| `MODEL_PROVIDER_UNREACHABLE`
4+
| `MODEL_PROVIDER_AUTH_FAILED`
5+
| `MODEL_PROVIDER_RATE_LIMITED`
6+
| `MODEL_PROVIDER_UNAVAILABLE`
7+
| `MODEL_PROVIDER_ERROR`
8+
9+
export class ModelProviderError extends Error {
10+
readonly code: ModelProviderErrorCode
11+
readonly provider?: string
12+
readonly model?: string
13+
14+
constructor(opts: {
15+
code: ModelProviderErrorCode
16+
message: string
17+
provider?: string
18+
model?: string
19+
cause?: unknown
20+
}) {
21+
super(
22+
opts.message,
23+
opts.cause === undefined ? undefined : { cause: opts.cause }
24+
)
25+
this.name = `ModelProviderError`
26+
this.code = opts.code
27+
this.provider = opts.provider
28+
this.model = opts.model
29+
}
30+
}
31+
32+
function stringifyError(error: unknown): string {
33+
if (error instanceof Error) {
34+
const cause = (error as { cause?: unknown }).cause
35+
return [
36+
error.name,
37+
error.message,
38+
cause === undefined ? `` : stringifyError(cause),
39+
]
40+
.filter(Boolean)
41+
.join(` `)
42+
}
43+
return String(error)
44+
}
45+
46+
export function classifyModelProviderError(
47+
error: unknown
48+
): ModelProviderErrorCode {
49+
const text = stringifyError(error).toLowerCase()
50+
51+
if (
52+
/\b(aborterror|timeouterror)\b/.test(text) ||
53+
text.includes(`timeout`) ||
54+
text.includes(`timed out`)
55+
) {
56+
return `MODEL_PROVIDER_TIMEOUT`
57+
}
58+
59+
if (
60+
text.includes(`401`) ||
61+
text.includes(`invalid api key`) ||
62+
text.includes(`authentication`) ||
63+
text.includes(`unauthorized`)
64+
) {
65+
return `MODEL_PROVIDER_AUTH_FAILED`
66+
}
67+
68+
if (text.includes(`429`) || text.includes(`rate limit`)) {
69+
return `MODEL_PROVIDER_RATE_LIMITED`
70+
}
71+
72+
if (
73+
text.includes(`502`) ||
74+
text.includes(`503`) ||
75+
text.includes(`504`) ||
76+
text.includes(`overloaded`) ||
77+
text.includes(`unavailable`)
78+
) {
79+
return `MODEL_PROVIDER_UNAVAILABLE`
80+
}
81+
82+
if (
83+
text.includes(`enotfound`) ||
84+
text.includes(`econnrefused`) ||
85+
text.includes(`econnreset`) ||
86+
text.includes(`eai_again`) ||
87+
text.includes(`fetch failed`) ||
88+
text.includes(`failed to fetch`) ||
89+
text.includes(`network`)
90+
) {
91+
return `MODEL_PROVIDER_UNREACHABLE`
92+
}
93+
94+
return `MODEL_PROVIDER_ERROR`
95+
}
96+
97+
export function modelProviderErrorMessage(opts: {
98+
code: ModelProviderErrorCode
99+
provider?: string
100+
}): string {
101+
const provider = opts.provider
102+
? displayProvider(opts.provider)
103+
: `the model provider`
104+
switch (opts.code) {
105+
case `MODEL_PROVIDER_TIMEOUT`:
106+
return `${provider} did not respond before the timeout. Check your Internet connection or provider status.`
107+
case `MODEL_PROVIDER_UNREACHABLE`:
108+
return `Could not reach ${provider}. Check your Internet connection or ${provider} status.`
109+
case `MODEL_PROVIDER_AUTH_FAILED`:
110+
return `${provider} rejected the API key. Check your model provider credentials.`
111+
case `MODEL_PROVIDER_RATE_LIMITED`:
112+
return `${provider} rate limited the request. Please wait and try again.`
113+
case `MODEL_PROVIDER_UNAVAILABLE`:
114+
return `${provider} is currently unavailable. Check provider status and try again.`
115+
case `MODEL_PROVIDER_ERROR`:
116+
return `${provider} returned an error. Check the runtime logs for provider details.`
117+
}
118+
}
119+
120+
export function toModelProviderError(
121+
error: unknown,
122+
opts: { provider?: string; model?: string }
123+
): ModelProviderError {
124+
if (error instanceof ModelProviderError) return error
125+
const code = classifyModelProviderError(error)
126+
const detail = error instanceof Error ? error.message : String(error)
127+
return new ModelProviderError({
128+
code,
129+
provider: opts.provider,
130+
model: opts.model,
131+
message: `${modelProviderErrorMessage({ code, provider: opts.provider })} (${detail})`,
132+
cause: error,
133+
})
134+
}
135+
136+
function displayProvider(provider: string): string {
137+
switch (provider.toLowerCase()) {
138+
case `anthropic`:
139+
return `Anthropic`
140+
case `openai`:
141+
return `OpenAI`
142+
default:
143+
return provider
144+
}
145+
}

packages/agents-runtime/src/outbound-bridge.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export async function loadOutboundIdSeed(
100100
export interface OutboundBridge {
101101
onRunStart: () => void
102102
onRunEnd: (opts?: { finishReason?: string }) => void
103+
onError: (opts: { errorCode: string; message: string }) => void
103104
onStepStart: (opts?: { modelProvider?: string; modelId?: string }) => void
104105
onStepEnd: (opts?: {
105106
finishReason?: string
@@ -193,6 +194,21 @@ export function createOutboundBridge(
193194
currentRunKey = null
194195
},
195196

197+
onError(opts: { errorCode: string; message: string }) {
198+
if (!currentRunKey) return
199+
writeEvent(
200+
entityStateSchema.errors.insert({
201+
key: `${currentRunKey}:error-${crypto.randomUUID()}`,
202+
value: {
203+
error_code: opts.errorCode,
204+
message: opts.message,
205+
run_id: currentRunKey,
206+
...(currentStepKey ? { step_id: currentStepKey } : {}),
207+
} as never,
208+
}) as ChangeEvent
209+
)
210+
},
211+
196212
onStepStart(opts?: { modelProvider?: string; modelId?: string }) {
197213
const runKey = requireActiveRun(`onStepStart`)
198214
currentStepKey = `step-${counters.step++}`

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

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
*/
99

1010
import { Agent } from '@mariozechner/pi-agent-core'
11-
import { getModel } from '@mariozechner/pi-ai'
11+
import { getModel, streamSimple } from '@mariozechner/pi-ai'
1212
import { createOutboundBridge } from './outbound-bridge'
1313
import { MOONSHOT_PROVIDER, getMoonshotModel } from './moonshot-models'
1414
import { runtimeLog } from './log'
15+
import {
16+
ModelProviderError,
17+
toModelProviderError,
18+
} from './model-provider-error'
1519
import type { OutboundIdSeed } from './outbound-bridge'
1620
import type { ChangeEvent } from '@durable-streams/state'
1721
import type {
@@ -42,8 +46,13 @@ export interface PiAdapterOptions {
4246
provider: string
4347
) => Promise<string | undefined> | string | undefined
4448
onPayload?: SimpleStreamOptions[`onPayload`]
49+
modelTimeoutMs?: number
50+
modelMaxRetries?: number
4551
}
4652

53+
const DEFAULT_MODEL_TIMEOUT_MS = 30_000
54+
const DEFAULT_MODEL_MAX_RETRIES = 0
55+
4756
interface PiAgentAdapterConfig {
4857
entityUrl: string
4958
epoch: number
@@ -227,18 +236,32 @@ export function createPiAgentAdapter(
227236
model: opts.model,
228237
...(opts.provider && { provider: opts.provider }),
229238
})
239+
const modelTimeoutMs = opts.modelTimeoutMs ?? DEFAULT_MODEL_TIMEOUT_MS
240+
const modelMaxRetries = opts.modelMaxRetries ?? DEFAULT_MODEL_MAX_RETRIES
241+
242+
const baseStreamFn = opts.streamFn ?? streamSimple
243+
const streamFn: StreamFn = (streamModel, context, streamOptions) =>
244+
baseStreamFn(streamModel, context, {
245+
...streamOptions,
246+
timeoutMs: modelTimeoutMs,
247+
maxRetries: modelMaxRetries,
248+
})
230249

231-
const agent = new Agent({
250+
const agentOptions = {
232251
initialState: {
233252
systemPrompt: opts.systemPrompt,
234253
tools: opts.tools as Array<never>,
235254
messages: history as Array<never>,
236255
model,
237256
},
238-
...(opts.streamFn && { streamFn: opts.streamFn }),
257+
streamFn,
239258
...(opts.getApiKey && { getApiKey: opts.getApiKey }),
240259
...(opts.onPayload && { onPayload: opts.onPayload }),
241-
})
260+
}
261+
262+
const agent = new Agent(
263+
agentOptions as ConstructorParameters<typeof Agent>[0]
264+
)
242265

243266
function processAgentEvents(
244267
resolveWhenDone: () => void,
@@ -409,8 +432,11 @@ export function createPiAgentAdapter(
409432
})
410433

411434
if (isError) {
412-
throw new Error(
413-
`pi-agent message_end error: ${msg.errorMessage ?? `unknown error`} (stopReason=${msg.stopReason ?? `none`})`
435+
throw toModelProviderError(
436+
new Error(
437+
`pi-agent message_end error: ${msg.errorMessage ?? `unknown error`} (stopReason=${msg.stopReason ?? `none`})`
438+
),
439+
{ provider: model.provider, model: model.id }
414440
)
415441
}
416442
break
@@ -436,6 +462,20 @@ export function createPiAgentAdapter(
436462
}
437463

438464
case `agent_end`: {
465+
const messages = (event as Record<string, unknown>).messages as
466+
| Array<{ stopReason?: string; errorMessage?: string }>
467+
| undefined
468+
const errorMessage = messages?.find(
469+
(message) =>
470+
message.stopReason === `error` && !!message.errorMessage
471+
)?.errorMessage
472+
if (errorMessage) {
473+
throw toModelProviderError(
474+
new Error(`pi-agent agent_end error: ${errorMessage}`),
475+
{ provider: model.provider, model: model.id }
476+
)
477+
}
478+
439479
bridge.onRunEnd({
440480
finishReason: abortedRun ? `aborted` : `stop`,
441481
})
@@ -499,6 +539,17 @@ export function createPiAgentAdapter(
499539
unsubscribe()
500540
bridge.onRunEnd({ finishReason })
501541
}
542+
const failWithProviderError = (err: unknown): ModelProviderError => {
543+
const providerError = toModelProviderError(err, {
544+
provider: model.provider,
545+
model: model.id,
546+
})
547+
bridge.onError({
548+
errorCode: providerError.code,
549+
message: providerError.message,
550+
})
551+
return providerError
552+
}
502553
const abortRun = (): void => {
503554
if (settled) return
504555
abortedRun = true
@@ -524,8 +575,9 @@ export function createPiAgentAdapter(
524575
},
525576
(err) => {
526577
if (settled) return
578+
const providerError = failWithProviderError(err)
527579
finish(`error`)
528-
reject(err)
580+
reject(providerError)
529581
}
530582
)
531583

@@ -539,8 +591,9 @@ export function createPiAgentAdapter(
539591
Promise.resolve(runPromise).catch((err: Error) => {
540592
if (settled) return
541593
if (abortedRun) return
594+
const providerError = failWithProviderError(err)
542595
finish(`error`)
543-
reject(err)
596+
reject(providerError)
544597
})
545598
})
546599
},

packages/agents-runtime/src/process-wake.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ensureSandboxMaterialized } from './sandbox/lazy'
1616
import { resolveSandboxIdentity } from './sandbox/identity'
1717
import { appendPathToUrl } from './url'
1818
import { manifestChildKey } from './manifest-helpers'
19+
import { ModelProviderError } from './model-provider-error'
1920
import {
2021
buildHydratedEventSourceWake,
2122
eventSourceWakeInfoFromManifests,
@@ -2200,13 +2201,18 @@ export async function processWake(
22002201
await waitForSignalHandlers()
22012202
activeSignalHandler = null
22022203
wakeSession.rollbackManifestEntries()
2203-
const errMsg = toError(setupErr).message
2204+
const err = toError(setupErr)
2205+
const errMsg = err.message
2206+
const errCode =
2207+
setupErr instanceof ModelProviderError
2208+
? setupErr.code
2209+
: `HANDLER_FAILED`
22042210
log.error(`handler failed for ${entityUrl}:`, errMsg)
22052211
writeEvent(
22062212
entityStateSchema.errors.insert({
22072213
key: `error-${epoch}-${crypto.randomUUID()}`,
22082214
value: {
2209-
error_code: `HANDLER_FAILED`,
2215+
error_code: errCode,
22102216
message: errMsg,
22112217
} as never,
22122218
}) as ChangeEvent

packages/agents-runtime/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,8 @@ export interface AgentConfig {
916916
provider: string
917917
) => Promise<string | undefined> | string | undefined
918918
onPayload?: SimpleStreamOptions[`onPayload`]
919+
modelTimeoutMs?: number
920+
modelMaxRetries?: number
919921
testResponses?: TestResponses
920922
}
921923

0 commit comments

Comments
 (0)