Skip to content

Commit 2b3c495

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(agent): harden structured stream guardrails
1 parent 7db79a1 commit 2b3c495

14 files changed

Lines changed: 156 additions & 16 deletions

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
66
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
77
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
88
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
9+
import { REDACTION_FAILED_MARKER, redactObjectStrings } from '@/lib/logs/execution/pii-redaction'
910
import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection'
1011
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
1112
import { BlockType, EDGE } from '@/executor/constants'
@@ -1565,6 +1566,79 @@ describe('BlockExecutor streaming pump', () => {
15651566
)
15661567
})
15671568

1569+
it('fails empty structured streams when the terminal event reports a token limit', async () => {
1570+
const onFullContent = vi.fn()
1571+
const handler = createAgentEventsStreamingHandler({
1572+
events: [{ type: 'turn_end', turn: 'final', finishReason: 'length' }],
1573+
onFullContent,
1574+
})
1575+
const { executor, block, state } = createExecutor(handler)
1576+
block.config.params = {
1577+
responseFormat: { type: 'object', properties: { answer: { type: 'string' } } },
1578+
}
1579+
const ctx = createContext(state)
1580+
1581+
await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(
1582+
/maximum output-token limit/i
1583+
)
1584+
1585+
expect(onFullContent).not.toHaveBeenCalled()
1586+
expect(state.getBlockOutput(block.id)).toMatchObject({
1587+
content: '',
1588+
error: expect.stringMatching(/maximum output-token limit/i),
1589+
})
1590+
expect(state.getBlockOutput(block.id)?.providerTiming?.timeSegments?.[0]?.finishReason).toBe(
1591+
'length'
1592+
)
1593+
})
1594+
1595+
it('preserves the original failure when provider-diagnostic redaction scrubs', async () => {
1596+
const redactor = vi.mocked(redactObjectStrings)
1597+
redactor
1598+
.mockImplementationOnce(async (value, options) => {
1599+
expect(options.onFailure).toBe('throw')
1600+
return value as never
1601+
})
1602+
.mockImplementationOnce(async (value, options) => {
1603+
expect(options.onFailure).toBe('scrub')
1604+
return {
1605+
...(value as Record<string, unknown>),
1606+
content: REDACTION_FAILED_MARKER,
1607+
model: REDACTION_FAILED_MARKER,
1608+
} as never
1609+
})
1610+
1611+
const onFullContent = vi.fn()
1612+
const handler = createAgentEventsStreamingHandler({
1613+
events: [{ type: 'text_delta', text: '{"answer":"unfinished', turn: 'final' }],
1614+
finishReason: 'max_tokens',
1615+
onFullContent,
1616+
})
1617+
const { executor, block, state } = createExecutor(handler)
1618+
block.config.params = {
1619+
responseFormat: { type: 'object', properties: { answer: { type: 'string' } } },
1620+
}
1621+
const ctx = createContext(state)
1622+
ctx.piiBlockOutputRedaction = {
1623+
enabled: true,
1624+
entityTypes: ['EMAIL_ADDRESS'],
1625+
language: 'en',
1626+
}
1627+
1628+
await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(
1629+
/maximum output-token limit/i
1630+
)
1631+
1632+
expect(redactor).toHaveBeenCalledTimes(2)
1633+
expect(onFullContent).not.toHaveBeenCalled()
1634+
expect(state.getBlockOutput(block.id)).toMatchObject({
1635+
content: REDACTION_FAILED_MARKER,
1636+
model: REDACTION_FAILED_MARKER,
1637+
tokens: { input: 1, output: 2, total: 3 },
1638+
error: expect.stringMatching(/maximum output-token limit/i),
1639+
})
1640+
})
1641+
15681642
it('soft-completes on user abort with drained answer text (no failed block)', async () => {
15691643
const abortController = new AbortController()
15701644
const handler = createAgentEventsStreamingHandler({

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

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
type VariableResolver,
7777
} from '@/executor/variables/resolver'
7878
import { createAgentStreamPump } from '@/providers/stream-pump'
79+
import { enrichLastModelSegment } from '@/providers/trace-enrichment'
7980
import type { SerializedBlock } from '@/serializer/types'
8081
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
8182

@@ -637,7 +638,7 @@ export class BlockExecutor {
637638
let providerDiagnostics: Record<string, unknown> = {}
638639
for (const key of ['content', 'model', 'tokens', 'toolCalls', 'providerTiming', 'cost']) {
639640
const value = failureDiagnosticOutput?.[key]
640-
if (value !== undefined && (key !== 'content' || value !== '')) {
641+
if (value !== undefined) {
641642
providerDiagnostics[key] = value
642643
}
643644
}
@@ -647,7 +648,7 @@ export class BlockExecutor {
647648
entityTypes: ctx.piiBlockOutputRedaction.entityTypes,
648649
language: ctx.piiBlockOutputRedaction.language,
649650
customPatterns: ctx.piiBlockOutputRedaction.customPatterns,
650-
onFailure: 'throw',
651+
onFailure: 'scrub',
651652
})
652653
}
653654
Object.assign(errorOutput, providerDiagnostics)
@@ -1242,11 +1243,8 @@ export class BlockExecutor {
12421243
}
12431244

12441245
let fullContent = pumpResult.answerText
1245-
if (!fullContent) {
1246-
return
1247-
}
12481246

1249-
if (piiEnabled && ctx.piiBlockOutputRedaction) {
1247+
if (fullContent && piiEnabled && ctx.piiBlockOutputRedaction) {
12501248
// Mask before writing to `execution.output` or `onFullContent`.
12511249
fullContent = await redactObjectStrings(fullContent, {
12521250
entityTypes: ctx.piiBlockOutputRedaction.entityTypes,
@@ -1257,15 +1255,28 @@ export class BlockExecutor {
12571255
}
12581256

12591257
const executionOutput = streamingExec.execution?.output
1258+
if (pumpResult.finishReason && executionOutput?.providerTiming?.timeSegments) {
1259+
enrichLastModelSegment(executionOutput.providerTiming.timeSegments, {
1260+
finishReason: pumpResult.finishReason,
1261+
})
1262+
}
1263+
if (executionOutput && typeof executionOutput === 'object' && parsedResponseFormat) {
1264+
// Retain even empty content for failed-block diagnostics, but reject it
1265+
// before parsing so token-limited structured data never reaches downstream.
1266+
executionOutput.content = fullContent
1267+
assertStructuredOutputNotTokenLimited(executionOutput.providerTiming, executionOutput)
1268+
}
1269+
1270+
if (!fullContent) {
1271+
return
1272+
}
1273+
12601274
if (executionOutput && typeof executionOutput === 'object') {
12611275
let parsedForFormat = false
12621276
if (responseFormat) {
12631277
// Retain the drained text for failed-block diagnostics, but reject it
12641278
// before parsing so truncated structured data never reaches downstream.
12651279
executionOutput.content = fullContent
1266-
if (parsedResponseFormat) {
1267-
assertStructuredOutputNotTokenLimited(executionOutput.providerTiming, executionOutput)
1268-
}
12691280
try {
12701281
const parsed = JSON.parse(fullContent.trim())
12711282
streamingExec.execution.output = {

apps/sim/providers/bedrock/utils.stream.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,18 @@ describe('createReadableStreamFromBedrockStream', () => {
3636
yield {
3737
metadata: { usage: { inputTokens: 2, outputTokens: 3 } },
3838
} as any
39+
yield {
40+
messageStop: { stopReason: 'max_tokens' },
41+
} as any
3942
})(),
4043
onComplete
4144
)
4245

4346
const events = await collectEvents(stream)
44-
expect(events).toEqual([{ type: 'text_delta', text: 'Done', turn: 'final' }])
47+
expect(events).toEqual([
48+
{ type: 'text_delta', text: 'Done', turn: 'final' },
49+
{ type: 'turn_end', turn: 'final', finishReason: 'max_tokens' },
50+
])
4551
expect(events.some((e) => e.type === 'thinking_delta')).toBe(false)
4652
expect(events.some((e) => e.type === 'tool_call_start')).toBe(false)
4753
expect(onComplete).toHaveBeenCalledWith('Done', { inputTokens: 2, outputTokens: 3 })

apps/sim/providers/bedrock/utils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export function createReadableStreamFromBedrockStream(
4242
let fullContent = ''
4343
let inputTokens = 0
4444
let outputTokens = 0
45+
let finishReason: string | undefined
4546
let cancelled = false
4647
let streamIterator: AsyncIterator<ConverseStreamOutput> | undefined
4748

@@ -55,6 +56,9 @@ export function createReadableStreamFromBedrockStream(
5556
const event = next.value
5657
const streamError = getBedrockStreamError(event)
5758
if (streamError) throw streamError
59+
if (event.messageStop?.stopReason) {
60+
finishReason = event.messageStop.stopReason
61+
}
5862
if (event.contentBlockDelta?.delta?.text) {
5963
const text = event.contentBlockDelta.delta.text
6064
fullContent += text
@@ -69,6 +73,9 @@ export function createReadableStreamFromBedrockStream(
6973
if (onComplete) {
7074
onComplete(fullContent, { inputTokens, outputTokens })
7175
}
76+
if (finishReason) {
77+
controller.enqueue({ type: 'turn_end', turn: 'final', finishReason })
78+
}
7279

7380
controller.close()
7481
} catch (err) {

apps/sim/providers/google/utils.stream.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe('createReadableStreamFromGeminiStream', () => {
2626
yield {
2727
candidates: [
2828
{
29+
finishReason: 'MAX_TOKENS',
2930
content: {
3031
parts: [
3132
{ text: 'Reasoning step. ', thought: true },
@@ -48,6 +49,7 @@ describe('createReadableStreamFromGeminiStream', () => {
4849
expect(events).toEqual([
4950
{ type: 'thinking_delta', text: 'Reasoning step. ' },
5051
{ type: 'text_delta', text: 'Final answer.', turn: 'final' },
52+
{ type: 'turn_end', turn: 'final', finishReason: 'MAX_TOKENS' },
5153
])
5254
expect(onComplete).toHaveBeenCalledWith(
5355
'Final answer.',

apps/sim/providers/google/utils.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ export function createReadableStreamFromGeminiStream(
252252
cachedContentTokenCount: 0,
253253
totalTokenCount: 0,
254254
}
255+
let finishReason: string | undefined
255256
let cancelled = false
256257
let streamIterator: AsyncIterator<GenerateContentResponse> | undefined
257258

@@ -275,8 +276,12 @@ export function createReadableStreamFromGeminiStream(
275276
if (chunk.usageMetadata) {
276277
usage = convertUsageMetadata(chunk.usageMetadata)
277278
}
279+
const candidate = chunk.candidates?.[0]
280+
if (candidate?.finishReason) {
281+
finishReason = String(candidate.finishReason)
282+
}
278283

279-
const parts = chunk.candidates?.[0]?.content?.parts
284+
const parts = candidate?.content?.parts
280285
if (Array.isArray(parts)) {
281286
for (const part of parts) {
282287
if (!part.text) continue
@@ -301,6 +306,9 @@ export function createReadableStreamFromGeminiStream(
301306

302307
if (cancelled) return
303308
onComplete?.(fullContent, usage, fullThinking || undefined)
309+
if (finishReason) {
310+
controller.enqueue({ type: 'turn_end', turn: 'final', finishReason })
311+
}
304312
controller.close()
305313
} catch (error) {
306314
if (!cancelled) {

apps/sim/providers/openai-compat/stream-events.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ describe('createOpenAICompatibleAgentEventStream', () => {
5757
{ providerName: 'Groq' }
5858
)
5959
const events = await collectEvents(stream)
60-
expect(events.every((e) => e.type === 'text_delta')).toBe(true)
60+
expect(events.filter((e) => e.type !== 'turn_end').every((e) => e.type === 'text_delta')).toBe(
61+
true
62+
)
6163
expect(events.some((e) => e.type === 'thinking_delta')).toBe(false)
6264
})
6365

@@ -141,6 +143,9 @@ describe('createOpenAICompatibleAgentEventStream', () => {
141143
const stream = createOpenAICompatibleAgentEventStream(
142144
(async function* () {
143145
yield* openaiCompatTextOnlyChunks as any
146+
yield {
147+
choices: [{ delta: {}, finish_reason: 'length' }],
148+
} as any
144149
})(),
145150
{ providerName: 'DeepSeek', onComplete }
146151
)
@@ -152,6 +157,7 @@ describe('createOpenAICompatibleAgentEventStream', () => {
152157
.join('')
153158
).toBe('Hello world')
154159
expect(onComplete.mock.calls[0][0].content).toBe('Hello world')
160+
expect(events).toContainEqual({ type: 'turn_end', turn: 'final', finishReason: 'length' })
155161
})
156162

157163
it('surfaces documented in-band provider errors', async () => {

apps/sim/providers/openai-compat/stream-events.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,9 @@ export function createOpenAICompatibleAgentEventStream(
270270
...(finishReason ? { finishReason } : {}),
271271
})
272272
}
273+
if (finishReason) {
274+
controller.enqueue({ type: 'turn_end', turn, finishReason })
275+
}
273276

274277
controller.close()
275278
} catch (error) {

apps/sim/providers/openai/utils.stream.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,10 @@ describe('createReadableStreamFromResponses', () => {
126126

127127
const events = await collectEvents(createReadableStreamFromResponses(response, onComplete))
128128

129-
expect(events).toEqual([{ type: 'text_delta', text: 'Truncated answer', turn: 'final' }])
129+
expect(events).toEqual([
130+
{ type: 'text_delta', text: 'Truncated answer', turn: 'final' },
131+
{ type: 'turn_end', turn: 'final', finishReason: 'max_output_tokens' },
132+
])
130133
expect(onComplete).toHaveBeenCalledWith(
131134
'Truncated answer',
132135
{

apps/sim/providers/openai/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ export function createReadableStreamFromResponses(
438438
let fullThinking = ''
439439
let finalUsage: ResponsesUsageTokens | undefined
440440
let completed = false
441+
let finishReason: string | undefined
441442
let sawFunctionCall = false
442443

443444
try {
@@ -464,6 +465,7 @@ export function createReadableStreamFromResponses(
464465
throw new Error(`OpenAI Responses stream incomplete: ${reason}`)
465466
}
466467
finalUsage = parseResponsesUsage(event.response.usage)
468+
finishReason = reason
467469
completed = true
468470
continue
469471
}
@@ -499,6 +501,9 @@ export function createReadableStreamFromResponses(
499501
}
500502

501503
onComplete?.(fullContent, finalUsage, fullThinking || undefined)
504+
if (finishReason) {
505+
controller.enqueue({ type: 'turn_end', turn: 'final', finishReason })
506+
}
502507
controller.close()
503508
} catch (error) {
504509
if (!streamAbortController.signal.aborted) {

0 commit comments

Comments
 (0)