Skip to content

Commit 7db79a1

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(agent): bound structured output generation
1 parent bc07d2d commit 7db79a1

10 files changed

Lines changed: 212 additions & 12 deletions

File tree

apps/sim/blocks/blocks/agent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,8 @@ Return ONLY the JSON array.`,
399399
title: 'Max Output Tokens',
400400
type: 'short-input',
401401
placeholder: 'Enter max tokens (e.g., 4096)...',
402+
description:
403+
'Maximum response length. When blank, structured responses use 4,096 tokens; other responses use the model default.',
402404
mode: 'advanced',
403405
condition: {
404406
field: 'model',

apps/sim/executor/execution/block-executor.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,7 @@ describe('BlockExecutor streaming pump', () => {
12241224
failAfterText?: string
12251225
streamError?: Error
12261226
onFullContent?: (content: string) => void | Promise<void>
1227+
finishReason?: string
12271228
resolvedSecret?: { name: string; value: string }
12281229
separateResultRegistry?: boolean
12291230
}): BlockHandler {
@@ -1279,6 +1280,9 @@ describe('BlockExecutor streaming pump', () => {
12791280
if (options.attachThinkingOnDrain) {
12801281
timeSegment.thinkingContent = options.attachThinkingOnDrain
12811282
}
1283+
if (options.finishReason) {
1284+
timeSegment.finishReason = options.finishReason
1285+
}
12821286
controller.close()
12831287
},
12841288
})
@@ -1534,6 +1538,33 @@ describe('BlockExecutor streaming pump', () => {
15341538
expect(callbackError.message).toContain(secret)
15351539
})
15361540

1541+
it('fails token-limited structured streams before downstream completion', async () => {
1542+
const onFullContent = vi.fn()
1543+
const handler = createAgentEventsStreamingHandler({
1544+
events: [{ type: 'text_delta', text: '{"answer":"unfinished', turn: 'final' }],
1545+
finishReason: 'max_tokens',
1546+
onFullContent,
1547+
})
1548+
const { executor, block, state } = createExecutor(handler)
1549+
block.config.params = {
1550+
responseFormat: { type: 'object', properties: { answer: { type: 'string' } } },
1551+
}
1552+
const ctx = createContext(state)
1553+
1554+
await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(
1555+
/maximum output-token limit/i
1556+
)
1557+
1558+
expect(onFullContent).not.toHaveBeenCalled()
1559+
expect(state.getBlockOutput(block.id)).toMatchObject({
1560+
content: '{"answer":"unfinished',
1561+
error: expect.stringMatching(/maximum output-token limit/i),
1562+
})
1563+
expect(state.getBlockOutput(block.id)?.providerTiming?.timeSegments?.[0]?.finishReason).toBe(
1564+
'max_tokens'
1565+
)
1566+
})
1567+
15371568
it('soft-completes on user abort with drained answer text (no failed block)', async () => {
15381569
const abortController = new AbortController()
15391570
const handler = createAgentEventsStreamingHandler({

apps/sim/executor/execution/block-executor.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ import type {
3434
ContextExtensions,
3535
WorkflowNodeMetadata,
3636
} from '@/executor/execution/types'
37+
import {
38+
assertStructuredOutputNotTokenLimited,
39+
parseResponseFormat,
40+
StructuredOutputTokenLimitError,
41+
} from '@/executor/handlers/shared/response-format'
3742
import {
3843
generatePauseContextId,
3944
mapNodeMetadataToPauseScopes,
@@ -228,7 +233,7 @@ export class BlockExecutor {
228233
}
229234
cleanupSelfReference?.()
230235

231-
let streamingPartialOutput: Record<string, any> | undefined
236+
let failureDiagnosticOutput: Record<string, any> | undefined
232237
try {
233238
/**
234239
* Only the handler call is retried. A streaming handler returns before any
@@ -274,7 +279,7 @@ export class BlockExecutor {
274279
blockCtx.resolvedSecretTraceRegistry = resultRegistry?.forkForPropagatedEntries()
275280
// Timeout / drain failures may still have projected answer text — keep it
276281
// for the failed block output so logs match what the client already saw.
277-
streamingPartialOutput = streamingExec.execution?.output
282+
failureDiagnosticOutput = streamingExec.execution?.output
278283
throw streamError
279284
}
280285

@@ -403,6 +408,9 @@ export class BlockExecutor {
403408
commitBlockRegistry()
404409
return stateOutput
405410
} catch (error) {
411+
if (!failureDiagnosticOutput && error instanceof StructuredOutputTokenLimitError) {
412+
failureDiagnosticOutput = error.diagnosticOutput
413+
}
406414
try {
407415
return await this.handleBlockError(
408416
error,
@@ -416,7 +424,7 @@ export class BlockExecutor {
416424
inputDisplayRegistry,
417425
isSentinel,
418426
'execution',
419-
streamingPartialOutput
427+
failureDiagnosticOutput
420428
)
421429
} finally {
422430
commitBlockRegistry()
@@ -548,7 +556,7 @@ export class BlockExecutor {
548556
inputDisplayRegistry: ResolvedSecretTraceRegistry | undefined,
549557
isSentinel: boolean,
550558
phase: 'input_resolution' | 'execution',
551-
streamingPartialOutput?: Record<string, any>
559+
failureDiagnosticOutput?: Record<string, any>
552560
): Promise<NormalizedBlockOutput> {
553561
const endedAt = new Date().toISOString()
554562
const duration = performance.now() - startTime
@@ -624,12 +632,25 @@ export class BlockExecutor {
624632
error: errorMessage,
625633
}
626634

627-
// Keep any answer text already drained before timeout/failure so logs match
628-
// what was projected to the client.
629-
const partialContent = streamingPartialOutput?.content
630-
if (typeof partialContent === 'string' && partialContent) {
631-
errorOutput.content = partialContent
635+
// Retain completed provider diagnostics for observability and billing while
636+
// keeping the block failed so normal downstream execution cannot consume it.
637+
let providerDiagnostics: Record<string, unknown> = {}
638+
for (const key of ['content', 'model', 'tokens', 'toolCalls', 'providerTiming', 'cost']) {
639+
const value = failureDiagnosticOutput?.[key]
640+
if (value !== undefined && (key !== 'content' || value !== '')) {
641+
providerDiagnostics[key] = value
642+
}
632643
}
644+
if (ctx.piiBlockOutputRedaction?.enabled && Object.keys(providerDiagnostics).length > 0) {
645+
stripThinkingContentFromOutput(providerDiagnostics)
646+
providerDiagnostics = await redactObjectStrings(providerDiagnostics, {
647+
entityTypes: ctx.piiBlockOutputRedaction.entityTypes,
648+
language: ctx.piiBlockOutputRedaction.language,
649+
customPatterns: ctx.piiBlockOutputRedaction.customPatterns,
650+
onFailure: 'throw',
651+
})
652+
}
653+
Object.assign(errorOutput, providerDiagnostics)
633654

634655
// Only real workflow blocks surface a child workflow name. A custom block's
635656
// source workflow is never named to its consumer — and before the handler
@@ -1106,6 +1127,7 @@ export class BlockExecutor {
11061127
resolvedInputs?.responseFormat ??
11071128
(block.config?.params as Record<string, any> | undefined)?.responseFormat ??
11081129
(block.config as Record<string, any> | undefined)?.responseFormat
1130+
const parsedResponseFormat = parseResponseFormat(responseFormat)
11091131

11101132
const streamFormat = streamingExec.streamFormat ?? 'text'
11111133
const pump = createAgentStreamPump({
@@ -1238,6 +1260,12 @@ export class BlockExecutor {
12381260
if (executionOutput && typeof executionOutput === 'object') {
12391261
let parsedForFormat = false
12401262
if (responseFormat) {
1263+
// Retain the drained text for failed-block diagnostics, but reject it
1264+
// before parsing so truncated structured data never reaches downstream.
1265+
executionOutput.content = fullContent
1266+
if (parsedResponseFormat) {
1267+
assertStructuredOutputNotTokenLimited(executionOutput.providerTiming, executionOutput)
1268+
}
12411269
try {
12421270
const parsed = JSON.parse(fullContent.trim())
12431271
streamingExec.execution.output = {

apps/sim/executor/execution/block-retry.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest'
1313
import { BlockType } from '@/executor/constants'
1414
import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
1515
import { isRetryableBlockError, resolveBlockRetryPolicy } from '@/executor/execution/block-retry'
16+
import { StructuredOutputTokenLimitError } from '@/executor/handlers/shared/response-format'
1617
import type { SerializedBlock } from '@/serializer/types'
1718

1819
function block(
@@ -162,6 +163,10 @@ describe('isRetryableBlockError', () => {
162163
).toBe(false)
163164
})
164165

166+
it('never replays token-limited structured output', () => {
167+
expect(isRetryableBlockError(new StructuredOutputTokenLimitError())).toBe(false)
168+
})
169+
165170
it('finds a deliberate stop that a provider rewrapped, since name is overwritten', () => {
166171
const abort = new Error('aborted')
167172
abort.name = 'AbortError'

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1967,6 +1967,67 @@ describe('AgentBlockHandler', () => {
19671967
})
19681968
})
19691969

1970+
it.each([
1971+
{
1972+
name: 'defaults structured output to 4,096 tokens',
1973+
responseFormat: { type: 'object', properties: { answer: { type: 'string' } } },
1974+
maxTokens: undefined,
1975+
expectedMaxTokens: 4096,
1976+
},
1977+
{
1978+
name: 'keeps an explicit structured-output limit',
1979+
responseFormat: { type: 'object', properties: { answer: { type: 'string' } } },
1980+
maxTokens: '512',
1981+
expectedMaxTokens: 512,
1982+
},
1983+
{
1984+
name: 'leaves an unstructured output limit unset',
1985+
responseFormat: undefined,
1986+
maxTokens: undefined,
1987+
expectedMaxTokens: undefined,
1988+
},
1989+
])('$name', async ({ responseFormat, maxTokens, expectedMaxTokens }) => {
1990+
await handler.execute(mockContext, mockBlock, {
1991+
model: 'gpt-4o',
1992+
userPrompt: 'Return an answer.',
1993+
responseFormat,
1994+
maxTokens,
1995+
})
1996+
1997+
expect(mockExecuteProviderRequest.mock.calls[0][1].maxTokens).toBe(expectedMaxTokens)
1998+
})
1999+
2000+
it('fails non-streaming structured output that reaches its token limit', async () => {
2001+
mockExecuteProviderRequest.mockResolvedValueOnce({
2002+
content: '{"answer":"unfinished',
2003+
model: 'mock-model',
2004+
tokens: { input: 10, output: 4096, total: 4106 },
2005+
timing: {
2006+
timeSegments: [
2007+
{
2008+
type: 'model',
2009+
startTime: 1,
2010+
endTime: 2,
2011+
duration: 1,
2012+
finishReason: 'max_tokens',
2013+
},
2014+
],
2015+
},
2016+
toolCalls: [],
2017+
})
2018+
2019+
await expect(
2020+
handler.execute(mockContext, mockBlock, {
2021+
model: 'claude-sonnet-5',
2022+
userPrompt: 'Return an answer.',
2023+
responseFormat: { type: 'object', properties: { answer: { type: 'string' } } },
2024+
})
2025+
).rejects.toMatchObject({
2026+
code: 'structured_output_token_limit',
2027+
retryable: false,
2028+
})
2029+
})
2030+
19702031
it('keeps an ordinary response format unchanged without resolver-recorded lineage', async () => {
19712032
const responseFormat = {
19722033
name: 'response_schema',

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ import type {
6060
StreamingConfig,
6161
ToolInput,
6262
} from '@/executor/handlers/agent/types'
63-
import { parseResponseFormat } from '@/executor/handlers/shared/response-format'
63+
import {
64+
assertStructuredOutputNotTokenLimited,
65+
DEFAULT_STRUCTURED_OUTPUT_MAX_TOKENS,
66+
parseResponseFormat,
67+
} from '@/executor/handlers/shared/response-format'
6468
import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/executor/types'
6569
import { collectBlockData } from '@/executor/utils/block-data'
6670
import { stringifyJSON } from '@/executor/utils/json'
@@ -2426,7 +2430,11 @@ export class AgentBlockHandler implements BlockHandler {
24262430
? Number(inputs.temperature)
24272431
: undefined,
24282432
maxTokens:
2429-
inputs.maxTokens != null && inputs.maxTokens !== '' ? Number(inputs.maxTokens) : undefined,
2433+
inputs.maxTokens != null && inputs.maxTokens !== ''
2434+
? Number(inputs.maxTokens)
2435+
: responseFormat
2436+
? DEFAULT_STRUCTURED_OUTPUT_MAX_TOKENS
2437+
: undefined,
24302438
apiKey: inputs.apiKey,
24312439
azureEndpoint: inputs.azureEndpoint,
24322440
azureApiVersion: inputs.azureApiVersion,
@@ -2759,6 +2767,11 @@ export class AgentBlockHandler implements BlockHandler {
27592767
): BlockOutput {
27602768
const content = result.content
27612769

2770+
assertStructuredOutputNotTokenLimited(result.timing, {
2771+
content,
2772+
...this.createResponseMetadata(result),
2773+
})
2774+
27622775
try {
27632776
const extractedJson = JSON.parse(content.trim())
27642777
return {

apps/sim/executor/handlers/shared/response-format.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,58 @@
11
import { createLogger } from '@sim/logger'
2+
import { NonRetryableExecutionError } from '@/lib/execution/non-retryable-error'
23
import type { BlockOutput } from '@/blocks/types'
34
import { REFERENCE } from '@/executor/constants'
45

56
const logger = createLogger('SharedResponseFormat')
67

8+
export const DEFAULT_STRUCTURED_OUTPUT_MAX_TOKENS = 4096
9+
10+
const TOKEN_LIMIT_FINISH_REASONS = new Set(['max_tokens', 'max_output_tokens', 'length'])
11+
12+
interface ProviderTimingLike {
13+
timeSegments?: Array<{
14+
type?: string
15+
finishReason?: string
16+
}>
17+
}
18+
19+
export class StructuredOutputTokenLimitError extends NonRetryableExecutionError {
20+
readonly code = 'structured_output_token_limit' as const
21+
readonly diagnosticOutput?: Record<string, unknown>
22+
23+
constructor(diagnosticOutput?: Record<string, unknown>) {
24+
super(
25+
'Structured output reached the maximum output-token limit before completion. Increase Max Output Tokens or reduce the requested response size.'
26+
)
27+
this.name = 'StructuredOutputTokenLimitError'
28+
this.diagnosticOutput = diagnosticOutput
29+
Object.defineProperty(this, 'diagnosticOutput', { enumerable: false })
30+
}
31+
}
32+
33+
/**
34+
* Rejects explicitly token-limited structured generations before their partial
35+
* content can be parsed or exposed as a successful block output.
36+
*/
37+
export function assertStructuredOutputNotTokenLimited(
38+
timing?: ProviderTimingLike,
39+
diagnosticOutput?: Record<string, unknown>
40+
): void {
41+
const segments = timing?.timeSegments
42+
if (!Array.isArray(segments)) return
43+
44+
for (let index = segments.length - 1; index >= 0; index--) {
45+
const segment = segments[index]
46+
if (segment?.type !== 'model') continue
47+
48+
const finishReason = segment.finishReason?.trim().toLowerCase()
49+
if (finishReason && TOKEN_LIMIT_FINISH_REASONS.has(finishReason)) {
50+
throw new StructuredOutputTokenLimitError(diagnosticOutput)
51+
}
52+
return
53+
}
54+
}
55+
756
/**
857
* Parse a raw responseFormat value (string or object) into a usable schema.
958
*

apps/sim/providers/anthropic/core.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ export async function executeAnthropicProviderRequest(
512512
createStream: ({ output, finalizeTiming }) =>
513513
createReadableStreamFromAnthropicStream(
514514
streamResponse as AsyncIterable<RawMessageStreamEvent>,
515-
({ content, usage, thinking }) => {
515+
({ content, usage, thinking, finishReason }) => {
516516
const tokens = buildAnthropicUsageTokens(usage)
517517
const cost = buildAnthropicUsageCost(request.model, usage)
518518
output.content = content
@@ -531,6 +531,9 @@ export async function executeAnthropicProviderRequest(
531531
if (thinking) {
532532
segment.thinkingContent = thinking
533533
}
534+
if (finishReason) {
535+
segment.finishReason = finishReason
536+
}
534537
}
535538

536539
finalizeTiming()

apps/sim/providers/anthropic/utils.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ describe('createReadableStreamFromAnthropicStream', () => {
8585
}
8686
yield {
8787
type: 'message_delta',
88+
delta: { stop_reason: 'max_tokens', stop_sequence: null },
8889
usage: {
8990
input_tokens: 10,
9091
output_tokens: 40,
@@ -105,6 +106,7 @@ describe('createReadableStreamFromAnthropicStream', () => {
105106
cacheWriteFiveMinute: 10,
106107
cacheWriteOneHour: 20,
107108
})
109+
expect(onComplete.mock.calls[0][0].finishReason).toBe('max_tokens')
108110
})
109111

110112
it('records [redacted] for redacted_thinking blocks and streams text', async () => {

0 commit comments

Comments
 (0)