Skip to content

Commit 7576b19

Browse files
committed
feat(agent): surface a normalized finishReason on the agent block
1 parent 2ba2484 commit 7576b19

5 files changed

Lines changed: 209 additions & 0 deletions

File tree

apps/sim/blocks/blocks/agent.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,11 @@ Return ONLY the JSON array.`,
672672
model: { type: 'string', description: 'Model used for generation' },
673673
tokens: { type: 'json', description: 'Token usage statistics' },
674674
toolCalls: { type: 'json', description: 'Tool calls made' },
675+
finishReason: {
676+
type: 'string',
677+
description:
678+
'Why generation stopped: stop, length (truncated by the token limit), tool_calls, content_filter, error, or other',
679+
},
675680
providerTiming: {
676681
type: 'json',
677682
description: 'Provider timing information',

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ import {
6262
canUseProviderLargeFilePath,
6363
getInlineHydrationMaxBytes,
6464
} from '@/providers/file-attachments.server'
65+
import { normalizeFinishReason } from '@/providers/finish-reason'
6566
import { isAutoModel, SIM_AUTO_MODEL_ID } from '@/providers/models'
67+
import type { ProviderResponse } from '@/providers/types'
6668
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
6769
import type { SerializedBlock } from '@/serializer/types'
6870
import { filterSchemaForLLM, type ToolSchema } from '@/tools/params'
@@ -1487,9 +1489,32 @@ export class AgentBlockHandler implements BlockHandler {
14871489
},
14881490
providerTiming: result.timing,
14891491
cost: result.cost,
1492+
/**
1493+
* Read from the last model segment rather than threaded through each provider:
1494+
* every family already records its raw stop reason there for the trace, so this
1495+
* normalizes the value the enrichment layer has already collected. A run whose
1496+
* provider reported nothing simply has no reason, which stays distinct from one
1497+
* the vocabulary could not place.
1498+
*/
1499+
finishReason: normalizeFinishReason(this.lastModelSegmentFinishReasonImpl(result.timing)),
14901500
}
14911501
}
14921502

1503+
/**
1504+
* The raw stop reason from the most recent `model` segment. Later segments win
1505+
* because a tool loop appends one segment per turn and the final turn is the one
1506+
* that ended the generation.
1507+
*/
1508+
private lastModelSegmentFinishReasonImpl(timing: ProviderResponse['timing']): string | undefined {
1509+
const segments = timing?.timeSegments
1510+
if (!segments) return undefined
1511+
for (let i = segments.length - 1; i >= 0; i--) {
1512+
const segment = segments[i]
1513+
if (segment.type === 'model' && segment.finishReason) return segment.finishReason
1514+
}
1515+
return undefined
1516+
}
1517+
14931518
private formatToolCall(tc: any) {
14941519
const toolName = stripCustomToolPrefix(tc.name)
14951520

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* The raw values are taken from the SDK enums this repo compiles against:
5+
* `ChatCompletion.finish_reason`, Anthropic `StopReason`, Gemini `FinishReason`,
6+
* and Bedrock `StopReason`. A provider adding a case must not fail a run, so an
7+
* unknown value normalizes rather than throwing.
8+
*/
9+
import { describe, expect, it } from 'vitest'
10+
import { normalizeFinishReason } from '@/providers/finish-reason'
11+
12+
describe('normalizeFinishReason', () => {
13+
it('reports nothing when the provider reported nothing', () => {
14+
expect(normalizeFinishReason(undefined)).toBeUndefined()
15+
expect(normalizeFinishReason(null)).toBeUndefined()
16+
expect(normalizeFinishReason('')).toBeUndefined()
17+
})
18+
19+
/** The case this exists for: one branch catches truncation on every provider. */
20+
it('maps every provider spelling of truncation to length', () => {
21+
expect(normalizeFinishReason('length')).toBe('length') // OpenAI chat
22+
expect(normalizeFinishReason('max_output_tokens')).toBe('length') // OpenAI Responses
23+
expect(normalizeFinishReason('max_tokens')).toBe('length') // Anthropic, Bedrock
24+
expect(normalizeFinishReason('MAX_TOKENS')).toBe('length') // Gemini
25+
expect(normalizeFinishReason('model_context_window_exceeded')).toBe('length')
26+
})
27+
28+
it('maps natural completion to stop', () => {
29+
expect(normalizeFinishReason('stop')).toBe('stop')
30+
expect(normalizeFinishReason('STOP')).toBe('stop')
31+
expect(normalizeFinishReason('end_turn')).toBe('stop')
32+
expect(normalizeFinishReason('stop_sequence')).toBe('stop')
33+
})
34+
35+
it('maps tool stops to tool_calls', () => {
36+
expect(normalizeFinishReason('tool_calls')).toBe('tool_calls')
37+
expect(normalizeFinishReason('function_call')).toBe('tool_calls')
38+
expect(normalizeFinishReason('tool_use')).toBe('tool_calls')
39+
})
40+
41+
it('maps every safety stop to content_filter', () => {
42+
for (const raw of [
43+
'content_filter',
44+
'content_filtered',
45+
'guardrail_intervened',
46+
'refusal',
47+
'SAFETY',
48+
'BLOCKLIST',
49+
'PROHIBITED_CONTENT',
50+
'SPII',
51+
'RECITATION',
52+
'IMAGE_SAFETY',
53+
]) {
54+
expect(normalizeFinishReason(raw)).toBe('content_filter')
55+
}
56+
})
57+
58+
it('maps malformed generations to error', () => {
59+
expect(normalizeFinishReason('MALFORMED_FUNCTION_CALL')).toBe('error')
60+
expect(normalizeFinishReason('malformed_tool_use')).toBe('error')
61+
expect(normalizeFinishReason('malformed_model_output')).toBe('error')
62+
})
63+
64+
/** A pause is a continuation point, not an outcome the caller should branch on. */
65+
it('does not treat a server-tool pause as a stop', () => {
66+
expect(normalizeFinishReason('pause_turn')).toBe('other')
67+
})
68+
69+
it('degrades an unrecognized value to other rather than throwing', () => {
70+
expect(normalizeFinishReason('some_future_reason')).toBe('other')
71+
expect(normalizeFinishReason('OTHER')).toBe('other')
72+
expect(normalizeFinishReason('FINISH_REASON_UNSPECIFIED')).toBe('other')
73+
})
74+
75+
it('is insensitive to case and surrounding whitespace', () => {
76+
expect(normalizeFinishReason(' Length ')).toBe('length')
77+
})
78+
})
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Why a model stopped generating, normalized across providers.
3+
*
4+
* Providers disagree on vocabulary for the same event — a truncated generation is
5+
* `length` on OpenAI, `max_tokens` on Anthropic and Bedrock, and `MAX_TOKENS` on
6+
* Gemini. Traces keep each provider's raw string because it is the ground truth for
7+
* debugging; this normalized value exists so a workflow can branch on the outcome
8+
* without enumerating every provider's spelling.
9+
*/
10+
export type AgentFinishReason =
11+
/** Generation completed naturally, or hit a caller-supplied stop sequence. */
12+
| 'stop'
13+
/** Truncated by a token limit — the model had more to say. */
14+
| 'length'
15+
/** Stopped in order to call tools. */
16+
| 'tool_calls'
17+
/** Blocked or refused by a safety system. */
18+
| 'content_filter'
19+
/** The provider reported the generation itself as malformed. */
20+
| 'error'
21+
/** Reported, but not a case this vocabulary distinguishes. */
22+
| 'other'
23+
24+
/**
25+
* Raw provider value → normalized reason, keyed on the lowercased string.
26+
*
27+
* A single table rather than a per-provider mapper because the vocabularies do not
28+
* collide: no raw value means one thing to one provider and something else to
29+
* another. Sources are the SDK types this repo compiles against —
30+
* `ChatCompletion.finish_reason`, Anthropic's `StopReason`, Gemini's `FinishReason`,
31+
* and Bedrock's `StopReason`.
32+
*/
33+
const NORMALIZED_BY_RAW = new Map<string, AgentFinishReason>([
34+
// OpenAI Chat Completions, and every OpenAI-compatible provider.
35+
['stop', 'stop'],
36+
['length', 'length'],
37+
['tool_calls', 'tool_calls'],
38+
['function_call', 'tool_calls'],
39+
['content_filter', 'content_filter'],
40+
41+
// OpenAI Responses reports truncation through `incomplete_details.reason`.
42+
['max_output_tokens', 'length'],
43+
44+
// Anthropic Messages, shared by Bedrock's Converse API.
45+
['end_turn', 'stop'],
46+
['stop_sequence', 'stop'],
47+
['max_tokens', 'length'],
48+
['model_context_window_exceeded', 'length'],
49+
['tool_use', 'tool_calls'],
50+
['refusal', 'content_filter'],
51+
/** A server-tool pause is a continuation point, not an outcome. */
52+
['pause_turn', 'other'],
53+
54+
// Bedrock Converse additions.
55+
['content_filtered', 'content_filter'],
56+
['guardrail_intervened', 'content_filter'],
57+
['malformed_model_output', 'error'],
58+
['malformed_tool_use', 'error'],
59+
60+
/**
61+
* Gemini. `STOP` and `MAX_TOKENS` already lowercase onto the entries above, so
62+
* only the values with no counterpart elsewhere are listed here. Recitation is a
63+
* content restriction, so it groups with the filters.
64+
*/
65+
['safety', 'content_filter'],
66+
['blocklist', 'content_filter'],
67+
['prohibited_content', 'content_filter'],
68+
['spii', 'content_filter'],
69+
['recitation', 'content_filter'],
70+
['image_safety', 'content_filter'],
71+
['image_prohibited_content', 'content_filter'],
72+
['image_recitation', 'content_filter'],
73+
['malformed_function_call', 'error'],
74+
['unexpected_tool_call', 'error'],
75+
['language', 'other'],
76+
['other', 'other'],
77+
['no_image', 'other'],
78+
['image_other', 'other'],
79+
['finish_reason_unspecified', 'other'],
80+
])
81+
82+
/**
83+
* Normalizes a provider's raw stop reason.
84+
*
85+
* Returns `undefined` when the provider reported nothing, so an absent reason stays
86+
* distinguishable from one the vocabulary could not place. An unrecognized value maps
87+
* to `'other'` rather than throwing: a provider adding a case must not fail a run
88+
* that otherwise succeeded.
89+
*/
90+
export function normalizeFinishReason(
91+
raw: string | null | undefined
92+
): AgentFinishReason | undefined {
93+
if (!raw) return undefined
94+
return NORMALIZED_BY_RAW.get(raw.trim().toLowerCase()) ?? 'other'
95+
}

apps/sim/providers/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
22
import type { ProviderTimingSegment, StreamingExecution, UserFile } from '@/executor/types'
3+
import type { AgentFinishReason } from '@/providers/finish-reason'
34

45
export type ProviderId =
56
| 'openai'
@@ -95,6 +96,11 @@ export interface ProviderResponse {
9596
}
9697
toolCalls?: FunctionCallResponse[]
9798
toolResults?: Record<string, unknown>[]
99+
/**
100+
* Why generation stopped, normalized across providers. Absent when the provider
101+
* reported nothing; see {@link AgentFinishReason}.
102+
*/
103+
finishReason?: AgentFinishReason
98104
timing?: {
99105
startTime: string
100106
endTime: string

0 commit comments

Comments
 (0)