Skip to content

Commit a6bff70

Browse files
committed
feat(agent): validate structured outputs after generation with one retry
The agent block never checked structured output after generation: processStructuredResponse was a bare JSON.parse whose failure path logged, attached an unread _responseFormatWarning, and returned success via the standard-format fallback. Downstream blocks read the structured fields as undefined, indistinguishable from a legitimate empty answer. Models with native structured outputs are additionally constrained only by a weakened grammar (the SDK transform strips enum, minLength, numeric bounds, and similar keywords into advisory prose), and prompt-based models had no enforcement at all. Validation now runs in the provider-request seam whenever the response format's existing strict flag is not false (the default the block schema has always documented): truncation at the output token limit (read from the final model trace segment every provider already populates), unparseable JSON (with a Markdown code-fence rescue), and ajv validation against the authored schema are one failure condition. A failed attempt on a tool-free request is resampled once, with the failed attempt's tokens, cost, and trace segments folded into the final result; requests carrying tools are not resampled because their tools would execute again. A response that still fails validation fails the block explicitly. Setting "strict": false keeps the previous lenient fallback, and streaming responses are unchanged. Claude-Session: https://claude.ai/code/session_018L7CosQfKB9SGCF8E9fLfY
1 parent a0f38db commit a6bff70

5 files changed

Lines changed: 810 additions & 51 deletions

File tree

apps/docs/content/docs/workflows/blocks/agent.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ Give the agent a JSON Schema to force structured output. The response is constra
7272
}
7373
```
7474

75+
The schema is checked after generation.
76+
A response that fails to parse as JSON, violates the schema (including constraints like `enum`, `minLength`, and number bounds), or was cut off at the output token limit counts as a failed generation.
77+
A block without tools retries once automatically; a block with tools is not retried, because its tools would run again.
78+
When no valid response remains, the block fails with the validation error instead of silently returning unstructured text.
79+
Set `"strict": false` in the response format to skip enforcement and accept whatever the model returns.
80+
7581
### Advanced
7682

7783
Some settings live under advanced, or appear only for models that support them:

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

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,21 @@ vi.mock('@/lib/internal/custom-tools/read-available-by-id-or-title', () => ({
148148
mockReadAvailableCustomToolByIdOrTitleAsExecutor(...args),
149149
}))
150150

151+
/**
152+
* Provider response whose content satisfies a strict structured response
153+
* format, for tests that exercise other behavior alongside a responseFormat.
154+
*/
155+
function structuredMockResponse(content: string) {
156+
return {
157+
content,
158+
model: 'mock-model',
159+
tokens: { input: 10, output: 20, total: 30 },
160+
timing: { total: 100 },
161+
toolCalls: [],
162+
cost: undefined,
163+
}
164+
}
165+
151166
const mockGetAllBlocks = getAllBlocks as Mock
152167
const mockExecuteTool = executeTool as Mock
153168
const mockGetProviderFromModel = getProviderFromModel as Mock
@@ -1988,6 +2003,7 @@ describe('AgentBlockHandler', () => {
19882003
mockContext.resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([
19892004
{ name: 'UNUSED', plaintext: 'x', encryptedValue: 'encrypted-unused' },
19902005
])
2006+
mockExecuteProviderRequest.mockResolvedValueOnce(structuredMockResponse('{"answer":"ok"}'))
19912007

19922008
await handler.execute(mockContext, mockBlock, {
19932009
model: 'gpt-4o',
@@ -2014,6 +2030,7 @@ describe('AgentBlockHandler', () => {
20142030
registry.recordResolvedAtInputPath('DESCRIPTION', 'classified', inputPath)
20152031
registry.recordResolvedInputProjection(inputPath, 'classified', '{{DESCRIPTION}}')
20162032
mockContext.resolvedSecretTraceRegistry = registry
2033+
mockExecuteProviderRequest.mockResolvedValueOnce(structuredMockResponse('{"answer":"ok"}'))
20172034

20182035
await handler.execute(mockContext, mockBlock, {
20192036
model: 'gpt-4o',
@@ -2051,6 +2068,7 @@ describe('AgentBlockHandler', () => {
20512068
projectedResponseFormat
20522069
)
20532070
mockContext.resolvedSecretTraceRegistry = registry
2071+
mockExecuteProviderRequest.mockResolvedValueOnce(structuredMockResponse('{"answer":"ok"}'))
20542072

20552073
await handler.execute(mockContext, mockBlock, {
20562074
model: 'gpt-4o',
@@ -2177,6 +2195,7 @@ describe('AgentBlockHandler', () => {
21772195
registry.recordResolvedAtInputPath('FORMAT_NAME', 'private-schema', inputPath)
21782196
registry.recordResolvedInputProjection(inputPath, 'private-schema', '{{FORMAT_NAME}}')
21792197
mockContext.resolvedSecretTraceRegistry = registry
2198+
mockExecuteProviderRequest.mockResolvedValueOnce(structuredMockResponse('{}'))
21802199

21812200
await handler.execute(mockContext, mockBlock, {
21822201
model: 'gpt-4o',
@@ -2298,6 +2317,7 @@ describe('AgentBlockHandler', () => {
22982317
projectedResponseFormat
22992318
)
23002319
mockContext.resolvedSecretTraceRegistry = registry
2320+
mockExecuteProviderRequest.mockResolvedValueOnce(structuredMockResponse('{}'))
23012321

23022322
await handler.execute(mockContext, mockBlock, {
23032323
model: 'gpt-4o',
@@ -2352,6 +2372,7 @@ describe('AgentBlockHandler', () => {
23522372
projectedResponseFormat
23532373
)
23542374
mockContext.resolvedSecretTraceRegistry = registry
2375+
mockExecuteProviderRequest.mockResolvedValueOnce(structuredMockResponse('{"answer":"ok"}'))
23552376
const handlerInputs = {
23562377
model: 'gpt-4o',
23572378
userPrompt: 'Return an answer.',
@@ -2408,6 +2429,140 @@ describe('AgentBlockHandler', () => {
24082429
})
24092430
})
24102431

2432+
it('retries once when a strict structured response fails to parse, then succeeds', async () => {
2433+
mockExecuteProviderRequest
2434+
.mockResolvedValueOnce(structuredMockResponse('{"result": "truncated mid'))
2435+
.mockResolvedValueOnce(structuredMockResponse('{"result": "ok"}'))
2436+
2437+
const result = await handler.execute(mockContext, mockBlock, {
2438+
model: 'gpt-4o',
2439+
userPrompt: 'Test context',
2440+
apiKey: 'test-api-key',
2441+
responseFormat: '{"type":"object","properties":{"result":{"type":"string"}}}',
2442+
})
2443+
2444+
expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2)
2445+
expect(result).toMatchObject({ result: 'ok' })
2446+
expect((result as Record<string, unknown>).tokens).toEqual({
2447+
input: 20,
2448+
output: 40,
2449+
total: 60,
2450+
})
2451+
expect(mockExecuteProviderRequest.mock.calls[0][1]).toEqual(
2452+
mockExecuteProviderRequest.mock.calls[1][1]
2453+
)
2454+
})
2455+
2456+
it('fails the block when the structured response fails validation twice', async () => {
2457+
mockExecuteProviderRequest
2458+
.mockResolvedValueOnce(structuredMockResponse('not json at all'))
2459+
.mockResolvedValueOnce(structuredMockResponse('still not json'))
2460+
2461+
await expect(
2462+
handler.execute(mockContext, mockBlock, {
2463+
model: 'gpt-4o',
2464+
userPrompt: 'Test context',
2465+
apiKey: 'test-api-key',
2466+
responseFormat: '{"type":"object","properties":{"result":{"type":"string"}}}',
2467+
})
2468+
).rejects.toThrow('Agent structured output failed validation after a retry')
2469+
expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2)
2470+
})
2471+
2472+
it('rejects schema-violating output that parses as JSON', async () => {
2473+
mockExecuteProviderRequest
2474+
.mockResolvedValueOnce(structuredMockResponse('{"status":""}'))
2475+
.mockResolvedValueOnce(structuredMockResponse('{"status":"open"}'))
2476+
2477+
const result = await handler.execute(mockContext, mockBlock, {
2478+
model: 'gpt-4o',
2479+
userPrompt: 'Test context',
2480+
apiKey: 'test-api-key',
2481+
responseFormat:
2482+
'{"type":"object","properties":{"status":{"type":"string","enum":["open","closed"]}},"required":["status"]}',
2483+
})
2484+
2485+
expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2)
2486+
expect(result).toMatchObject({ status: 'open' })
2487+
})
2488+
2489+
it('keeps the lenient fallback when the response format sets strict false', async () => {
2490+
mockExecuteProviderRequest.mockResolvedValueOnce(structuredMockResponse('not json at all'))
2491+
2492+
const result = await handler.execute(mockContext, mockBlock, {
2493+
model: 'gpt-4o',
2494+
userPrompt: 'Test context',
2495+
apiKey: 'test-api-key',
2496+
responseFormat:
2497+
'{"name":"response_schema","schema":{"type":"object","properties":{"result":{"type":"string"}}},"strict":false}',
2498+
})
2499+
2500+
expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1)
2501+
expect(result).toMatchObject({
2502+
content: 'not json at all',
2503+
_responseFormatWarning: expect.stringContaining('did not adhere'),
2504+
})
2505+
})
2506+
2507+
it('fails without resampling when the request carries tools', async () => {
2508+
mockTransformBlockTool.mockReturnValue({
2509+
id: 'test_tool',
2510+
name: 'Test Tool',
2511+
description: 'A test tool',
2512+
parameters: { type: 'object', properties: {} },
2513+
})
2514+
mockExecuteProviderRequest.mockResolvedValueOnce(structuredMockResponse('not json at all'))
2515+
2516+
await expect(
2517+
handler.execute(mockContext, mockBlock, {
2518+
model: 'gpt-4o',
2519+
userPrompt: 'Test context',
2520+
apiKey: 'test-api-key',
2521+
responseFormat: '{"type":"object","properties":{"result":{"type":"string"}}}',
2522+
tools: [{ type: 'test_tool', title: 'Test Tool', params: {}, usageControl: 'auto' }],
2523+
})
2524+
).rejects.toThrow('Agent structured output failed validation:')
2525+
expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(1)
2526+
})
2527+
2528+
it('fails a structured response whose final model segment stopped at max_tokens', async () => {
2529+
const truncatedResponse = () => ({
2530+
content: '{"result": "parses but was cut short by the cap"}',
2531+
model: 'mock-model',
2532+
tokens: { input: 10, output: 20, total: 30 },
2533+
timing: {
2534+
startTime: 't0',
2535+
endTime: 't1',
2536+
duration: 10,
2537+
timeSegments: [
2538+
{
2539+
type: 'model',
2540+
name: 'final',
2541+
startTime: 0,
2542+
endTime: 1,
2543+
duration: 1,
2544+
finishReason: 'max_tokens',
2545+
},
2546+
],
2547+
},
2548+
toolCalls: [],
2549+
cost: undefined,
2550+
})
2551+
mockExecuteProviderRequest
2552+
.mockResolvedValueOnce(truncatedResponse())
2553+
.mockResolvedValueOnce(truncatedResponse())
2554+
2555+
await expect(
2556+
handler.execute(mockContext, mockBlock, {
2557+
model: 'gpt-4o',
2558+
userPrompt: 'Test context',
2559+
apiKey: 'test-api-key',
2560+
responseFormat: '{"type":"object","properties":{"result":{"type":"string"}}}',
2561+
})
2562+
).rejects.toThrow('output token limit')
2563+
expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2)
2564+
})
2565+
24112566
it('should handle invalid JSON in responseFormat gracefully', async () => {
24122567
mockExecuteProviderRequest.mockResolvedValueOnce({
24132568
content: 'Regular text response',

0 commit comments

Comments
 (0)