Skip to content

Commit 76c8e91

Browse files
committed
fix(providers): name the failing phase of a stalled OpenAI call, and reject a failed generation
An agent block hung ~4.5 minutes with an empty trace and surfaced only the runtime's own `TimeoutError: The operation timed out.` The cause was a runaway generation: the model repeated one tool call until it consumed the whole 128,000-token output budget, which takes minutes, and `/v1/responses` withholds its 200 until generation finishes — so the client waited, bounded only by an undocumented runtime socket deadline, and gave up before the response existed. Nothing in the trace could distinguish that from a request the provider never answered, or from one whose body never arrived. - Name the phase a transport failure died in — `awaiting-response-headers` vs `reading-response-body` — with status, ttfb, content-length and `x-request-id`. undici draws the same line as two error types (UND_ERR_HEADERS_TIMEOUT / UND_ERR_BODY_TIMEOUT); the OpenAI SDK captures `x-request-id` for the same reason. It rides the error message because that reaches the trace span, which survives when a task stops shipping logs. - Carry the cause through `ProviderError` so a transport timeout still classifies after wrapping overwrites `name`. - Reject a 200 that reports a failed or unusable generation instead of returning empty content with billed tokens, and stop truncated tool calls from executing. Matches `streamResponsesTurn`, which already did this, and `@ai-sdk/openai`, which throws on the same condition. - Bound non-JSON error bodies so a gateway error page cannot become the user-facing block error. Deliberately not included: a response-body deadline (the observed failure is in the headers phase, and the body transfers in ~1ms) and status-based retries (worth doing, unrelated to this, and separable).
1 parent 020ec68 commit 76c8e91

6 files changed

Lines changed: 663 additions & 16 deletions

File tree

apps/sim/executor/handlers/agent/agent-handler.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,49 @@ describe('AgentBlockHandler', () => {
979979
)
980980
})
981981

982+
/**
983+
* A stalled model call reaches here as the runtime's own `TimeoutError`, whose bare
984+
* message ("The operation timed out.") names nothing. It must become a Sim-level
985+
* message WITHOUT discarding the phase detail the provider attached — that detail is
986+
* the only thing distinguishing "never answered" from "body never completed".
987+
*/
988+
it('maps a provider TimeoutError to a Sim message while keeping the phase detail', async () => {
989+
const inputs = { model: 'gpt-4o', userPrompt: 'hi', apiKey: 'test-api-key' }
990+
mockGetProviderFromModel.mockReturnValue('openai')
991+
992+
// Faithful to production: providers rewrap the transport failure in a
993+
// ProviderError, which overwrites `name` — so only the cause still classifies it.
994+
const transport = new Error(
995+
'The operation timed out. [phase=reading-response-body elapsedMs=60001 status=200 contentLength=32116]'
996+
)
997+
transport.name = 'TimeoutError'
998+
const wrapped = new Error(transport.message, { cause: transport })
999+
wrapped.name = 'ProviderError'
1000+
mockExecuteProviderRequest.mockRejectedValueOnce(wrapped)
1001+
1002+
const error = await handler.execute(mockContext, mockBlock, inputs).catch((e) => e)
1003+
1004+
expect(error.message).toContain('Provider request timed out')
1005+
expect(error.message).toContain('phase=reading-response-body')
1006+
expect(error.message).toContain('status=200')
1007+
})
1008+
1009+
it('maps a provider AbortError the same way', async () => {
1010+
const inputs = { model: 'gpt-4o', userPrompt: 'hi', apiKey: 'test-api-key' }
1011+
mockGetProviderFromModel.mockReturnValue('openai')
1012+
1013+
const aborted = new Error('aborted [phase=awaiting-response-headers elapsedMs=12]')
1014+
aborted.name = 'AbortError'
1015+
const wrapped = new Error(aborted.message, { cause: aborted })
1016+
wrapped.name = 'ProviderError'
1017+
mockExecuteProviderRequest.mockRejectedValueOnce(wrapped)
1018+
1019+
const error = await handler.execute(mockContext, mockBlock, inputs).catch((e) => e)
1020+
1021+
expect(error.message).toContain('Provider request timed out')
1022+
expect(error.message).toContain('phase=awaiting-response-headers')
1023+
})
1024+
9821025
it('should handle streaming responses with text/event-stream content type', async () => {
9831026
const mockStreamBody = new ReadableStream({
9841027
start(controller) {

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ import { getToolAsync } from '@/tools/utils.server'
7171

7272
const logger = createLogger('AgentBlockHandler')
7373

74+
/**
75+
* True when a failure originated from a transport deadline or abort, at any depth of the
76+
* cause chain.
77+
*
78+
* Providers rewrap transport failures (`ProviderError` overwrites `name`), so a check on
79+
* the top-level `name` alone misses every wrapped case. Bounded to a short walk so a
80+
* self-referential cause cannot loop.
81+
*/
82+
function isTransportTimeout(error: unknown): boolean {
83+
for (let current = error, depth = 0; current instanceof Error && depth < 5; depth++) {
84+
if (current.name === 'AbortError' || current.name === 'TimeoutError') return true
85+
current = current.cause
86+
}
87+
return false
88+
}
89+
7490
/**
7591
* Handler for Agent blocks that process LLM requests with optional tools.
7692
*/
@@ -1299,8 +1315,20 @@ export class AgentBlockHandler implements BlockHandler {
12991315
timestamp: new Date().toISOString(),
13001316
})
13011317

1302-
if (error.name === 'AbortError') {
1303-
throw new Error('Provider request timed out - the API took too long to respond')
1318+
/**
1319+
* `TimeoutError` is what the runtime raises on a fetch deadline; without it a
1320+
* stalled model call reached the trace as the bare runtime string.
1321+
*
1322+
* The cause chain is walked, not just `name`: providers rewrap transport failures in
1323+
* a `ProviderError`, which overwrites `name`, so the classification only survives on
1324+
* `cause`. The original message is kept rather than replaced — providers annotate it
1325+
* with the request phase they died in, and that detail is the only thing separating a
1326+
* request that was never answered from one whose body stalled.
1327+
*/
1328+
if (isTransportTimeout(error)) {
1329+
throw new Error(
1330+
`Provider request timed out - the API took too long to respond (${error.message})`
1331+
)
13041332
}
13051333
if (error.name === 'TypeError' && error.message.includes('fetch')) {
13061334
throw new Error(
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* `/v1/responses` answers HTTP 200 for generations that did not succeed — `status:
5+
* 'failed'` with a populated `error`, or `status: 'incomplete'` with a reason. The
6+
* non-streaming path read only `output`, so those reached the user as a success with
7+
* empty content and billed tokens, while the trace span independently recorded
8+
* `finishReason: 'error'`.
9+
*
10+
* These cover the status/error gate and pin the `incomplete` policy to the one the
11+
* streaming loop already applies, so the two paths cannot silently diverge again.
12+
*/
13+
import { beforeEach, describe, expect, it, vi } from 'vitest'
14+
import { executeResponsesProviderRequest } from '@/providers/openai/core'
15+
import type { ProviderRequest, ProviderResponse } from '@/providers/types'
16+
17+
vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 }))
18+
19+
vi.mock('@/providers/utils', () => ({
20+
isFunctionToolCall: () => false,
21+
calculateCost: () => ({ input: 0, output: 0, total: 0 }),
22+
sumToolCosts: () => 0,
23+
enforceStrictSchema: (schema: unknown) => schema,
24+
prepareToolExecution: () => ({ toolParams: {}, executionParams: {} }),
25+
prepareToolsWithUsageControl: (tools: unknown[]) => ({
26+
tools,
27+
toolChoice: undefined,
28+
forcedTools: [],
29+
hasFilteredTools: false,
30+
}),
31+
trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }),
32+
supportsReasoningEffort: () => false,
33+
}))
34+
35+
const { mockExecuteProviderTool } = vi.hoisted(() => ({
36+
mockExecuteProviderTool: vi.fn(),
37+
}))
38+
39+
vi.mock('@/providers/runtime-context', () => ({
40+
executeProviderTool: mockExecuteProviderTool,
41+
}))
42+
43+
function jsonResponse(body: unknown) {
44+
return {
45+
ok: true,
46+
status: 200,
47+
headers: new Headers(),
48+
json: () => Promise.resolve(body),
49+
}
50+
}
51+
52+
const USAGE = { input_tokens: 1, output_tokens: 1, total_tokens: 2 }
53+
54+
function message(text: string) {
55+
return {
56+
type: 'message',
57+
role: 'assistant',
58+
content: [{ type: 'output_text', text }],
59+
}
60+
}
61+
62+
function functionCall(args: string) {
63+
return { type: 'function_call', call_id: 'call_1', name: 'exa_search', arguments: args }
64+
}
65+
66+
const COMPLETED_RESPONSE = {
67+
id: 'resp_1',
68+
status: 'completed',
69+
error: null,
70+
incomplete_details: null,
71+
output: [message('hello')],
72+
usage: USAGE,
73+
}
74+
75+
describe('OpenAI non-streaming response status handling', () => {
76+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any
77+
78+
beforeEach(() => {
79+
vi.clearAllMocks()
80+
mockExecuteProviderTool.mockResolvedValue({ success: true, output: { results: [] } })
81+
})
82+
83+
function run(fetchMock: unknown, request: Partial<ProviderRequest> = {}) {
84+
return executeResponsesProviderRequest(
85+
{ apiKey: 'k', model: 'gpt-5.5', messages: [{ role: 'user', content: 'hi' }], ...request },
86+
{
87+
providerId: 'openai',
88+
providerLabel: 'OpenAI',
89+
modelName: 'gpt-5.5',
90+
endpoint: 'https://api.openai.com/v1/responses',
91+
headers: { Authorization: 'Bearer k' },
92+
logger,
93+
fetch: fetchMock as typeof fetch,
94+
}
95+
)
96+
}
97+
98+
const TOOL_REQUEST: Partial<ProviderRequest> = {
99+
tools: [{ id: 'exa_search', name: 'exa_search', description: 'search', params: {} }],
100+
}
101+
102+
it('fails the block on a 200 carrying status "failed", surfacing the API error message', async () => {
103+
const fetchMock = vi.fn().mockResolvedValue(
104+
jsonResponse({
105+
id: 'resp_1',
106+
status: 'failed',
107+
error: { code: 'server_error', message: 'The model produced an invalid response.' },
108+
incomplete_details: null,
109+
output: [],
110+
usage: USAGE,
111+
})
112+
)
113+
114+
await expect(run(fetchMock)).rejects.toThrow('The model produced an invalid response.')
115+
})
116+
117+
it('fails the block when error is populated but status is absent', async () => {
118+
const fetchMock = vi.fn().mockResolvedValue(
119+
jsonResponse({
120+
id: 'resp_1',
121+
error: { code: null, message: 'Upstream provider rejected the request.' },
122+
incomplete_details: null,
123+
output: [],
124+
usage: USAGE,
125+
})
126+
)
127+
128+
await expect(run(fetchMock)).rejects.toThrow('Upstream provider rejected the request.')
129+
})
130+
131+
/**
132+
* Decision, matching `streamResponsesTurn`: an `incomplete` response truncated by
133+
* `max_output_tokens` with no tool call is NOT an error — the partial prose is a
134+
* usable answer and is returned as the block content.
135+
*/
136+
it('returns the partial content of a max_output_tokens incomplete response instead of failing', async () => {
137+
const fetchMock = vi.fn().mockResolvedValue(
138+
jsonResponse({
139+
id: 'resp_1',
140+
status: 'incomplete',
141+
error: null,
142+
incomplete_details: { reason: 'max_output_tokens' },
143+
output: [message('a truncated but usable answer')],
144+
usage: USAGE,
145+
})
146+
)
147+
148+
const result = (await run(fetchMock)) as ProviderResponse
149+
expect(result.content).toBe('a truncated but usable answer')
150+
})
151+
152+
/**
153+
* The other half of the same decision: every other incomplete reason is an error,
154+
* because the generation stopped for a reason the caller must be told about.
155+
*/
156+
it('fails the block on an incomplete response whose reason is not max_output_tokens', async () => {
157+
const fetchMock = vi.fn().mockResolvedValue(
158+
jsonResponse({
159+
id: 'resp_1',
160+
status: 'incomplete',
161+
error: null,
162+
incomplete_details: { reason: 'content_filter' },
163+
output: [message('partial')],
164+
usage: USAGE,
165+
})
166+
)
167+
168+
await expect(run(fetchMock)).rejects.toThrow(/content_filter/)
169+
})
170+
171+
/**
172+
* The confusing-failure case: a truncated `function_call` holds half-written JSON.
173+
* Executing it made `parseToolArguments` throw, reporting a tool bug rather than the
174+
* truncation that actually happened.
175+
*/
176+
it('does not execute a tool call from a non-completed response', async () => {
177+
const fetchMock = vi.fn().mockResolvedValue(
178+
jsonResponse({
179+
id: 'resp_1',
180+
status: 'incomplete',
181+
error: null,
182+
incomplete_details: { reason: 'max_output_tokens' },
183+
output: [functionCall('{"query": "half writ')],
184+
usage: USAGE,
185+
})
186+
)
187+
188+
await expect(run(fetchMock, TOOL_REQUEST)).rejects.toThrow(/max_output_tokens/)
189+
expect(mockExecuteProviderTool).not.toHaveBeenCalled()
190+
})
191+
192+
it('leaves a healthy completed response entirely unaffected', async () => {
193+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(COMPLETED_RESPONSE))
194+
195+
const result = (await run(fetchMock)) as ProviderResponse
196+
expect(result.content).toBe('hello')
197+
expect(result.toolCalls).toBeUndefined()
198+
expect(result.tokens?.total).toBe(2)
199+
expect(fetchMock).toHaveBeenCalledTimes(1)
200+
})
201+
202+
it('still runs the multi-turn tool loop end to end', async () => {
203+
const fetchMock = vi
204+
.fn()
205+
.mockResolvedValueOnce(
206+
jsonResponse({
207+
id: 'resp_tool',
208+
status: 'completed',
209+
error: null,
210+
incomplete_details: null,
211+
output: [functionCall('{"query":"sim"}')],
212+
usage: USAGE,
213+
})
214+
)
215+
.mockResolvedValueOnce(jsonResponse(COMPLETED_RESPONSE))
216+
217+
const result = (await run(fetchMock, TOOL_REQUEST)) as ProviderResponse
218+
219+
expect(fetchMock).toHaveBeenCalledTimes(2)
220+
expect(mockExecuteProviderTool).toHaveBeenCalledTimes(1)
221+
expect(result.toolCalls).toHaveLength(1)
222+
expect(result.toolCalls?.[0].success).toBe(true)
223+
expect(result.content).toBe('hello')
224+
expect(result.tokens?.total).toBe(4)
225+
})
226+
227+
/**
228+
* The gate sits in `postResponses`, so it must cover continuation turns too — a loop
229+
* that starts healthy and fails on turn two must still fail the block.
230+
*/
231+
it('fails the block when a later tool-loop turn comes back failed', async () => {
232+
const fetchMock = vi
233+
.fn()
234+
.mockResolvedValueOnce(
235+
jsonResponse({
236+
id: 'resp_tool',
237+
status: 'completed',
238+
error: null,
239+
incomplete_details: null,
240+
output: [functionCall('{"query":"sim"}')],
241+
usage: USAGE,
242+
})
243+
)
244+
.mockResolvedValueOnce(
245+
jsonResponse({
246+
id: 'resp_2',
247+
status: 'failed',
248+
error: { code: 'server_error', message: 'Second turn blew up.' },
249+
incomplete_details: null,
250+
output: [],
251+
usage: USAGE,
252+
})
253+
)
254+
255+
await expect(run(fetchMock, TOOL_REQUEST)).rejects.toThrow('Second turn blew up.')
256+
expect(mockExecuteProviderTool).toHaveBeenCalledTimes(1)
257+
})
258+
})

0 commit comments

Comments
 (0)