Skip to content

Commit efd0dd5

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(workspace-events): add agent tool error event
1 parent 4ff339e commit efd0dd5

12 files changed

Lines changed: 301 additions & 14 deletions

File tree

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,8 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
701701
if (span.iterationIndex !== undefined)
702702
metaEntries.push({ label: 'Iteration', value: String(span.iterationIndex + 1) })
703703

704-
const statusLabel = hasError ? 'Error' : 'Success'
704+
const statusLabel =
705+
isDirectError && span.errorHandled ? 'Handled error' : hasError ? 'Error' : 'Success'
705706

706707
return (
707708
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3.5 pt-3 pb-4'>

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ export function isIterationType(type: string): boolean {
3838

3939
export function hasErrorInTree(span: TraceSpan): boolean {
4040
if (span.status === 'error') return true
41-
if (span.children?.length) return span.children.some(hasErrorInTree)
41+
if (span.children?.length) {
42+
return span.children.some((child) => hasUnhandledError(child, { includeToolCalls: true }))
43+
}
4244
if (span.toolCalls?.length) return span.toolCalls.some((tc) => tc.error)
4345
return false
4446
}

apps/sim/executor/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ export interface BlockTokens {
183183
/** A single tool invocation recorded by an agent-type block. */
184184
export interface BlockToolCall {
185185
name: string
186+
success?: boolean
187+
status?: string
186188
duration?: number
187189
startTime?: string
188190
endTime?: string

apps/sim/lib/logs/execution/trace-spans/span-factory.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { isRecordLike } from '@sim/utils/object'
3+
import { truncate } from '@sim/utils/string'
34
import type { ProviderTiming, TraceSpan } from '@/lib/logs/types'
45
import {
56
isConditionBlockType,
@@ -14,6 +15,7 @@ import type {
1415
} from '@/executor/types'
1516

1617
const logger = createLogger('SpanFactory')
18+
const TOOL_CALL_ERROR_MAX_LENGTH = 4096
1719

1820
/** A BlockLog that has already passed the id/type validity check. */
1921
type ValidBlockLog = BlockLog & { blockType: string }
@@ -24,6 +26,47 @@ function normalizeTraceOutput(value: unknown): Record<string, unknown> | undefin
2426
return isRecordLike(value) ? value : { value }
2527
}
2628

29+
function nonEmptyString(value: unknown): string | undefined {
30+
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
31+
}
32+
33+
/**
34+
* Returns the canonical error message for a failed agent tool call.
35+
*
36+
* Providers expose failures through several normalized shapes. The nested
37+
* fallback intentionally requires Sim's complete error envelope so ordinary
38+
* successful tool data with an `error` field is not misclassified.
39+
*/
40+
function getToolCallErrorMessage(
41+
toolCall: BlockToolCall | undefined,
42+
segmentErrorMessage?: string
43+
): string | undefined {
44+
const rawResult = toolCall?.result ?? toolCall?.output
45+
const result = isRecordLike(rawResult) ? rawResult : undefined
46+
const topLevelError = nonEmptyString(toolCall?.error)
47+
const segmentError = nonEmptyString(segmentErrorMessage)
48+
const hasExplicitFailure =
49+
toolCall?.success === false ||
50+
toolCall?.status === 'error' ||
51+
topLevelError !== undefined ||
52+
segmentError !== undefined
53+
const hasStandardSimError =
54+
result?.error === true &&
55+
nonEmptyString(result.message) !== undefined &&
56+
nonEmptyString(result.tool) !== undefined
57+
58+
if (!hasExplicitFailure && !hasStandardSimError) return undefined
59+
60+
const nestedMessage = nonEmptyString(result?.message) ?? nonEmptyString(result?.error)
61+
const message =
62+
topLevelError ??
63+
segmentError ??
64+
nestedMessage ??
65+
`Tool ${toolCall?.name || 'call'} execution failed`
66+
67+
return truncate(message, TOOL_CALL_ERROR_MAX_LENGTH)
68+
}
69+
2770
/**
2871
* Creates a TraceSpan from a BlockLog. Returns null for invalid logs.
2972
*
@@ -202,6 +245,10 @@ function buildChildrenFromTimeSegments(
202245
const match = callsForName[currentIndex]
203246
toolCallIndices.set(normalizedName, currentIndex + 1)
204247
const output = normalizeTraceOutput(match?.result ?? match?.output)
248+
const errorMessage = getToolCallErrorMessage(match, segment.errorMessage)
249+
const errorHandled = Boolean(
250+
errorMessage && span.type === 'agent' && span.status === 'success'
251+
)
205252

206253
const toolChild: TraceSpan = {
207254
id: `${span.id}-segment-${index}`,
@@ -210,13 +257,14 @@ function buildChildrenFromTimeSegments(
210257
duration: segment.duration,
211258
startTime: segmentStartTime,
212259
endTime: segmentEndTime,
213-
status: match?.error || segment.errorMessage ? 'error' : 'success',
260+
status: errorMessage ? 'error' : 'success',
214261
input: match?.arguments ?? match?.input,
215262
output: match?.error ? { error: match.error, ...output } : output,
263+
...(errorHandled && { errorHandled: true }),
216264
}
217265
if (segment.toolCallId) toolChild.toolCallId = segment.toolCallId
218266
if (segment.errorType) toolChild.errorType = segment.errorType
219-
if (segment.errorMessage) toolChild.errorMessage = segment.errorMessage
267+
if (errorMessage) toolChild.errorMessage = errorMessage
220268
return toolChild
221269
}
222270

@@ -280,16 +328,20 @@ function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceS
280328
const startTime = tc.startTime ?? log.startedAt
281329
const endTime = tc.endTime ?? log.endedAt
282330
const output = normalizeTraceOutput(tc.result ?? tc.output)
331+
const errorMessage = getToolCallErrorMessage(tc)
332+
const errorHandled = Boolean(errorMessage && span.type === 'agent' && span.status === 'success')
283333
return {
284334
id: `${span.id}-tool-${index}`,
285335
name: stripCustomToolPrefix(tc.name ?? 'unnamed-tool'),
286336
type: 'tool',
287337
duration: tc.duration ?? 0,
288338
startTime,
289339
endTime,
290-
status: tc.error ? 'error' : 'success',
340+
status: errorMessage ? 'error' : 'success',
291341
input: tc.arguments ?? tc.input,
292342
output: tc.error ? { error: tc.error, ...output } : output,
343+
...(errorMessage && { errorMessage }),
344+
...(errorHandled && { errorHandled: true }),
293345
}
294346
})
295347
}

apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ describe('buildTraceSpans', () => {
345345
const mockExecutionResult: ExecutionResult = {
346346
success: true,
347347
output: { content: 'Final output' },
348+
metadata: { duration: 3000, startTime: '2024-01-01T10:00:00.000Z' },
348349
logs: [
349350
{
350351
blockId: 'agent-4',
@@ -391,7 +392,11 @@ describe('buildTraceSpans', () => {
391392
{
392393
name: 'failing_tool',
393394
arguments: { input: 'test' },
394-
error: 'Tool execution failed',
395+
result: {
396+
error: true,
397+
message: 'Tool execution failed',
398+
tool: 'custom_failing_tool',
399+
},
395400
duration: 1000,
396401
startTime: '2024-01-01T10:00:01.000Z',
397402
endTime: '2024-01-01T10:00:02.000Z',
@@ -407,7 +412,10 @@ describe('buildTraceSpans', () => {
407412
const { traceSpans } = buildTraceSpans(mockExecutionResult)
408413

409414
expect(traceSpans).toHaveLength(1)
410-
const agentSpan = traceSpans[0]
415+
const workflowSpan = traceSpans[0]
416+
expect(workflowSpan.status).toBe('success')
417+
const agentSpan = workflowSpan.children![0]
418+
expect(agentSpan.status).toBe('success')
411419
expect(agentSpan.children).toBeDefined()
412420
expect(agentSpan.children).toHaveLength(3)
413421

@@ -416,8 +424,14 @@ describe('buildTraceSpans', () => {
416424
expect(toolSegment.name).toBe('failing_tool')
417425
expect(toolSegment.type).toBe('tool')
418426
expect(toolSegment.status).toBe('error')
427+
expect(toolSegment.errorHandled).toBe(true)
428+
expect(toolSegment.errorMessage).toBe('Tool execution failed')
419429
expect(toolSegment.input).toEqual({ input: 'test' })
420-
expect(toolSegment.output).toEqual({ error: 'Tool execution failed' })
430+
expect(toolSegment.output).toEqual({
431+
error: true,
432+
message: 'Tool execution failed',
433+
tool: 'custom_failing_tool',
434+
})
421435
})
422436

423437
it.concurrent('handles blocks without tool calls', () => {

apps/sim/lib/workspace-events/constants.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const SIM_WORKSPACE_EVENT_TRIGGER_ID = 'sim_workspace_event'
1616
export const SIM_PLAIN_EVENT_TYPES = [
1717
'execution_success',
1818
'execution_error',
19+
'agent_tool_error',
1920
'workflow_deployed',
2021
'workflow_undeployed',
2122
] as const
@@ -89,7 +90,10 @@ interface SimEventPayloadField {
8990
/** Restricts which event types surface this field in the tag dropdown. */
9091
condition?: SimEventPayloadFieldCondition
9192
/** Nested fields for json outputs, surfaced as dotted paths in the tag dropdown. */
92-
properties?: Record<string, { type: 'string' | 'number' | 'json'; description: string }>
93+
properties?: Record<
94+
string,
95+
{ type: 'string' | 'number' | 'json' | 'boolean'; description: string }
96+
>
9397
}
9498

9599
/** Run summary fields shared by top-level plain events and the nested triggeringRun. */
@@ -140,7 +144,10 @@ export const SIM_EVENT_PAYLOAD_FIELDS = {
140144
},
141145
runId: {
142146
...RUN_SUMMARY_FIELDS.runId,
143-
condition: { field: 'eventType', value: [...SIM_PLAIN_RUN_EVENT_TYPES] },
147+
condition: {
148+
field: 'eventType',
149+
value: [...SIM_PLAIN_RUN_EVENT_TYPES, 'agent_tool_error'],
150+
},
144151
},
145152
durationMs: {
146153
...RUN_SUMMARY_FIELDS.durationMs,
@@ -160,6 +167,41 @@ export const SIM_EVENT_PAYLOAD_FIELDS = {
160167
condition: { field: 'eventType', value: [...SIM_RUN_BACKED_RULE_EVENT_TYPES] },
161168
properties: RUN_SUMMARY_FIELDS,
162169
},
170+
toolError: {
171+
type: 'json',
172+
description: 'The failed Agent tool invocation',
173+
condition: { field: 'eventType', value: 'agent_tool_error' },
174+
properties: {
175+
agentBlockId: {
176+
type: 'string',
177+
description: 'The Agent block ID',
178+
},
179+
agentBlockName: {
180+
type: 'string',
181+
description: 'The Agent block name',
182+
},
183+
toolName: {
184+
type: 'string',
185+
description: 'The failed tool name',
186+
},
187+
toolCallId: {
188+
type: 'string',
189+
description: 'The provider tool call ID, when available',
190+
},
191+
errorMessage: {
192+
type: 'string',
193+
description: 'The bounded tool failure message',
194+
},
195+
durationMs: {
196+
type: 'number',
197+
description: 'Tool invocation duration in milliseconds',
198+
},
199+
recovered: {
200+
type: 'boolean',
201+
description: 'Whether the Agent completed successfully after the failure',
202+
},
203+
},
204+
},
163205
version: {
164206
type: 'number',
165207
description: 'The deployment version number that was activated',

apps/sim/lib/workspace-events/emitter.test.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ vi.mock('@/lib/webhooks/processor', () => ({
4646
processPolledWebhookEvent: mockProcessPolledWebhookEvent,
4747
}))
4848

49-
import type { WorkflowExecutionLog } from '@/lib/logs/types'
49+
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
5050
import {
5151
emitExecutionCompletedEvent,
5252
emitWorkflowDeployedEvent,
@@ -112,6 +112,45 @@ function makeLog(overrides: Partial<WorkflowExecutionLog> = {}): WorkflowExecuti
112112
}
113113
}
114114

115+
function makeAgentTrace(toolStatus: 'success' | 'error'): TraceSpan[] {
116+
const toolSpan: TraceSpan = {
117+
id: 'agent-1-segment-1',
118+
name: 'always_fail',
119+
type: 'tool',
120+
duration: 12361,
121+
startTime: '2026-08-14T00:08:49.018Z',
122+
endTime: '2026-08-14T00:09:01.379Z',
123+
status: toolStatus,
124+
toolCallId: 'tool-call-1',
125+
...(toolStatus === 'error'
126+
? { errorHandled: true, errorMessage: 'Intentional test tool failure' }
127+
: {}),
128+
}
129+
const agentSpan: TraceSpan = {
130+
id: 'agent-1-span',
131+
blockId: 'agent-1',
132+
name: 'Agent',
133+
type: 'agent',
134+
duration: 15984,
135+
startTime: '2026-08-14T00:08:46.273Z',
136+
endTime: '2026-08-14T00:09:02.257Z',
137+
status: 'success',
138+
children: [toolSpan],
139+
}
140+
return [
141+
{
142+
id: 'workflow-execution',
143+
name: 'Workflow Execution',
144+
type: 'workflow',
145+
duration: 15992,
146+
startTime: '2026-08-14T00:08:46.265Z',
147+
endTime: '2026-08-14T00:09:02.257Z',
148+
status: 'success',
149+
children: [agentSpan],
150+
},
151+
]
152+
}
153+
115154
describe('emitExecutionCompletedEvent', () => {
116155
beforeEach(() => {
117156
vi.clearAllMocks()
@@ -189,6 +228,58 @@ describe('emitExecutionCompletedEvent', () => {
189228
})
190229
})
191230

231+
it('fires one agent_tool_error event alongside execution_success for a recovered failure', async () => {
232+
const toolErrorSub = makeSubscription(makeConfig({ eventType: 'agent_tool_error' }), {
233+
subscriberWorkflowId: 'wf-tool-error-sub',
234+
})
235+
const successSub = makeSubscription(makeConfig({ eventType: 'execution_success' }), {
236+
subscriberWorkflowId: 'wf-success-sub',
237+
})
238+
mockFetchSubscriptions.mockResolvedValueOnce([toolErrorSub, successSub])
239+
240+
await emitExecutionCompletedEvent(
241+
makeLog({
242+
level: 'info',
243+
executionData: {
244+
finalOutput: { content: 'I recovered from the tool failure.' },
245+
traceSpans: makeAgentTrace('error'),
246+
},
247+
})
248+
)
249+
250+
expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(2)
251+
expect(mockProcessPolledWebhookEvent.mock.calls[0][2]).toMatchObject({
252+
event: 'agent_tool_error',
253+
workflowId: 'wf-source',
254+
workflowName: 'Source Workflow',
255+
runId: 'exec-1',
256+
toolError: {
257+
agentBlockId: 'agent-1',
258+
agentBlockName: 'Agent',
259+
toolName: 'always_fail',
260+
toolCallId: 'tool-call-1',
261+
errorMessage: 'Intentional test tool failure',
262+
durationMs: 12361,
263+
recovered: true,
264+
},
265+
})
266+
expect(mockProcessPolledWebhookEvent.mock.calls[1][2]).toMatchObject({
267+
event: 'execution_success',
268+
runId: 'exec-1',
269+
})
270+
})
271+
272+
it('does not fire agent_tool_error when every Agent tool call succeeded', async () => {
273+
const toolErrorSub = makeSubscription(makeConfig({ eventType: 'agent_tool_error' }))
274+
mockFetchSubscriptions.mockResolvedValueOnce([toolErrorSub])
275+
276+
await emitExecutionCompletedEvent(
277+
makeLog({ executionData: { traceSpans: makeAgentTrace('success') } })
278+
)
279+
280+
expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled()
281+
})
282+
192283
it('respects the workflow scope filter, ignoring stale workflow ids', async () => {
193284
const matching = makeSubscription(makeConfig({ workflowIds: ['wf-source', 'wf-deleted'] }), {
194285
subscriberWorkflowId: 'wf-a',

0 commit comments

Comments
 (0)