Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,8 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
if (span.iterationIndex !== undefined)
metaEntries.push({ label: 'Iteration', value: String(span.iterationIndex + 1) })

const statusLabel = hasError ? 'Error' : 'Success'
const statusLabel =
isDirectError && span.errorHandled ? 'Handled error' : hasError ? 'Error' : 'Success'

return (
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3.5 pt-3 pb-4'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ export function isIterationType(type: string): boolean {

export function hasErrorInTree(span: TraceSpan): boolean {
if (span.status === 'error') return true
if (span.children?.length) return span.children.some(hasErrorInTree)
if (span.children?.length) {
return span.children.some((child) => hasUnhandledError(child, { includeToolCalls: true }))
}
if (span.toolCalls?.length) return span.toolCalls.some((tc) => tc.error)
return false
}
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/executor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ export interface BlockTokens {
/** A single tool invocation recorded by an agent-type block. */
export interface BlockToolCall {
name: string
success?: boolean
status?: string
duration?: number
startTime?: string
endTime?: string
Expand Down
60 changes: 57 additions & 3 deletions apps/sim/lib/logs/execution/trace-spans/span-factory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { isRecordLike } from '@sim/utils/object'
import { truncate } from '@sim/utils/string'
import type { ProviderTiming, TraceSpan } from '@/lib/logs/types'
import {
isConditionBlockType,
Expand All @@ -14,6 +15,7 @@ import type {
} from '@/executor/types'

const logger = createLogger('SpanFactory')
const TOOL_CALL_ERROR_MAX_LENGTH = 4096

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

function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
}

/**
* Returns the canonical error message for a failed agent tool call.
*
* Providers expose failures through several normalized shapes. A nested
* `success: false` is explicit; the generic nested fallback requires Sim's
* complete error envelope so successful tool data with an `error` field is
* not misclassified.
*/
function getToolCallErrorMessage(
toolCall: BlockToolCall | undefined,
segmentErrorMessage?: string
): string | undefined {
const rawResult = toolCall?.result ?? toolCall?.output
const result = isRecordLike(rawResult) ? rawResult : undefined
const topLevelError = nonEmptyString(toolCall?.error)
const segmentError = nonEmptyString(segmentErrorMessage)
const hasExplicitFailure =
toolCall?.success === false ||
toolCall?.status === 'error' ||
result?.success === false ||
topLevelError !== undefined ||
segmentError !== undefined
const hasStandardSimError =
result?.error === true &&
nonEmptyString(result.message) !== undefined &&
nonEmptyString(result.tool) !== undefined
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.

if (!hasExplicitFailure && !hasStandardSimError) return undefined

const nestedMessage = nonEmptyString(result?.message) ?? nonEmptyString(result?.error)
const message =
topLevelError ??
segmentError ??
nestedMessage ??
`Tool ${toolCall?.name || 'call'} execution failed`

return truncate(message, TOOL_CALL_ERROR_MAX_LENGTH)
}

/**
* Creates a TraceSpan from a BlockLog. Returns null for invalid logs.
*
Expand Down Expand Up @@ -202,6 +247,10 @@ function buildChildrenFromTimeSegments(
const match = callsForName[currentIndex]
toolCallIndices.set(normalizedName, currentIndex + 1)
const output = normalizeTraceOutput(match?.result ?? match?.output)
const errorMessage = getToolCallErrorMessage(match, segment.errorMessage)
const errorHandled = Boolean(
errorMessage && span.type === 'agent' && span.status === 'success'
)

const toolChild: TraceSpan = {
id: `${span.id}-segment-${index}`,
Expand All @@ -210,13 +259,14 @@ function buildChildrenFromTimeSegments(
duration: segment.duration,
startTime: segmentStartTime,
endTime: segmentEndTime,
status: match?.error || segment.errorMessage ? 'error' : 'success',
status: errorMessage ? 'error' : 'success',
input: match?.arguments ?? match?.input,
output: match?.error ? { error: match.error, ...output } : output,
...(errorHandled && { errorHandled: true }),
}
if (segment.toolCallId) toolChild.toolCallId = segment.toolCallId
if (segment.errorType) toolChild.errorType = segment.errorType
if (segment.errorMessage) toolChild.errorMessage = segment.errorMessage
if (errorMessage) toolChild.errorMessage = errorMessage
return toolChild
}

Expand Down Expand Up @@ -280,16 +330,20 @@ function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceS
const startTime = tc.startTime ?? log.startedAt
const endTime = tc.endTime ?? log.endedAt
const output = normalizeTraceOutput(tc.result ?? tc.output)
const errorMessage = getToolCallErrorMessage(tc)
const errorHandled = Boolean(errorMessage && span.type === 'agent' && span.status === 'success')
return {
id: `${span.id}-tool-${index}`,
name: stripCustomToolPrefix(tc.name ?? 'unnamed-tool'),
type: 'tool',
duration: tc.duration ?? 0,
startTime,
endTime,
status: tc.error ? 'error' : 'success',
status: errorMessage ? 'error' : 'success',
input: tc.arguments ?? tc.input,
output: tc.error ? { error: tc.error, ...output } : output,
...(errorMessage && { errorMessage }),
...(errorHandled && { errorHandled: true }),
}
})
}
Expand Down
30 changes: 26 additions & 4 deletions apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,10 +341,27 @@ describe('buildTraceSpans', () => {
expect(toolCall.output).toEqual({ analysis: 'completed' })
})

it.concurrent('handles tool calls with errors in timeSegments', () => {
it.concurrent.each([
{
toolResult: {
error: true,
message: 'Tool execution failed',
tool: 'custom_failing_tool',
},
expectedMessage: 'Tool execution failed',
},
{
toolResult: {
success: false,
error: 'MCP server connection failed',
},
expectedMessage: 'MCP server connection failed',
},
])('handles tool calls with errors in timeSegments', ({ toolResult, expectedMessage }) => {
const mockExecutionResult: ExecutionResult = {
success: true,
output: { content: 'Final output' },
metadata: { duration: 3000, startTime: '2024-01-01T10:00:00.000Z' },
logs: [
{
blockId: 'agent-4',
Expand Down Expand Up @@ -391,7 +408,7 @@ describe('buildTraceSpans', () => {
{
name: 'failing_tool',
arguments: { input: 'test' },
error: 'Tool execution failed',
result: toolResult,
duration: 1000,
startTime: '2024-01-01T10:00:01.000Z',
endTime: '2024-01-01T10:00:02.000Z',
Expand All @@ -407,7 +424,10 @@ describe('buildTraceSpans', () => {
const { traceSpans } = buildTraceSpans(mockExecutionResult)

expect(traceSpans).toHaveLength(1)
const agentSpan = traceSpans[0]
const workflowSpan = traceSpans[0]
expect(workflowSpan.status).toBe('success')
const agentSpan = workflowSpan.children![0]
expect(agentSpan.status).toBe('success')
expect(agentSpan.children).toBeDefined()
expect(agentSpan.children).toHaveLength(3)

Expand All @@ -416,8 +436,10 @@ describe('buildTraceSpans', () => {
expect(toolSegment.name).toBe('failing_tool')
expect(toolSegment.type).toBe('tool')
expect(toolSegment.status).toBe('error')
expect(toolSegment.errorHandled).toBe(true)
expect(toolSegment.errorMessage).toBe(expectedMessage)
expect(toolSegment.input).toEqual({ input: 'test' })
expect(toolSegment.output).toEqual({ error: 'Tool execution failed' })
expect(toolSegment.output).toEqual(toolResult)
})

it.concurrent('handles blocks without tool calls', () => {
Expand Down
46 changes: 44 additions & 2 deletions apps/sim/lib/workspace-events/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const SIM_WORKSPACE_EVENT_TRIGGER_ID = 'sim_workspace_event'
export const SIM_PLAIN_EVENT_TYPES = [
'execution_success',
'execution_error',
'agent_tool_error',
'workflow_deployed',
'workflow_undeployed',
] as const
Expand Down Expand Up @@ -89,7 +90,10 @@ interface SimEventPayloadField {
/** Restricts which event types surface this field in the tag dropdown. */
condition?: SimEventPayloadFieldCondition
/** Nested fields for json outputs, surfaced as dotted paths in the tag dropdown. */
properties?: Record<string, { type: 'string' | 'number' | 'json'; description: string }>
properties?: Record<
string,
{ type: 'string' | 'number' | 'json' | 'boolean'; description: string }
>
}

/** Run summary fields shared by top-level plain events and the nested triggeringRun. */
Expand Down Expand Up @@ -140,7 +144,10 @@ export const SIM_EVENT_PAYLOAD_FIELDS = {
},
runId: {
...RUN_SUMMARY_FIELDS.runId,
condition: { field: 'eventType', value: [...SIM_PLAIN_RUN_EVENT_TYPES] },
condition: {
field: 'eventType',
value: [...SIM_PLAIN_RUN_EVENT_TYPES, 'agent_tool_error'],
},
},
durationMs: {
...RUN_SUMMARY_FIELDS.durationMs,
Expand All @@ -160,6 +167,41 @@ export const SIM_EVENT_PAYLOAD_FIELDS = {
condition: { field: 'eventType', value: [...SIM_RUN_BACKED_RULE_EVENT_TYPES] },
properties: RUN_SUMMARY_FIELDS,
},
toolError: {
type: 'json',
description: 'The failed Agent tool invocation',
condition: { field: 'eventType', value: 'agent_tool_error' },
properties: {
agentBlockId: {
type: 'string',
description: 'The Agent block ID',
},
agentBlockName: {
type: 'string',
description: 'The Agent block name',
},
toolName: {
type: 'string',
description: 'The failed tool name',
},
toolCallId: {
type: 'string',
description: 'The provider tool call ID, when available',
},
errorMessage: {
type: 'string',
description: 'The bounded tool failure message',
},
durationMs: {
type: 'number',
description: 'Tool invocation duration in milliseconds',
},
recovered: {
type: 'boolean',
description: 'Whether the Agent completed successfully after the failure',
},
},
},
version: {
type: 'number',
description: 'The deployment version number that was activated',
Expand Down
93 changes: 92 additions & 1 deletion apps/sim/lib/workspace-events/emitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ vi.mock('@/lib/webhooks/processor', () => ({
processPolledWebhookEvent: mockProcessPolledWebhookEvent,
}))

import type { WorkflowExecutionLog } from '@/lib/logs/types'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import {
emitExecutionCompletedEvent,
emitWorkflowDeployedEvent,
Expand Down Expand Up @@ -112,6 +112,45 @@ function makeLog(overrides: Partial<WorkflowExecutionLog> = {}): WorkflowExecuti
}
}

function makeAgentTrace(toolStatus: 'success' | 'error'): TraceSpan[] {
const toolSpan: TraceSpan = {
id: 'agent-1-segment-1',
name: 'always_fail',
type: 'tool',
duration: 12361,
startTime: '2026-08-14T00:08:49.018Z',
endTime: '2026-08-14T00:09:01.379Z',
status: toolStatus,
toolCallId: 'tool-call-1',
...(toolStatus === 'error'
? { errorHandled: true, errorMessage: 'Intentional test tool failure' }
: {}),
}
const agentSpan: TraceSpan = {
id: 'agent-1-span',
blockId: 'agent-1',
name: 'Agent',
type: 'agent',
duration: 15984,
startTime: '2026-08-14T00:08:46.273Z',
endTime: '2026-08-14T00:09:02.257Z',
status: 'success',
children: [toolSpan],
}
return [
{
id: 'workflow-execution',
name: 'Workflow Execution',
type: 'workflow',
duration: 15992,
startTime: '2026-08-14T00:08:46.265Z',
endTime: '2026-08-14T00:09:02.257Z',
status: 'success',
children: [agentSpan],
},
]
}

describe('emitExecutionCompletedEvent', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -189,6 +228,58 @@ describe('emitExecutionCompletedEvent', () => {
})
})

it('fires one agent_tool_error event alongside execution_success for a recovered failure', async () => {
const toolErrorSub = makeSubscription(makeConfig({ eventType: 'agent_tool_error' }), {
subscriberWorkflowId: 'wf-tool-error-sub',
})
const successSub = makeSubscription(makeConfig({ eventType: 'execution_success' }), {
subscriberWorkflowId: 'wf-success-sub',
})
mockFetchSubscriptions.mockResolvedValueOnce([toolErrorSub, successSub])

await emitExecutionCompletedEvent(
makeLog({
level: 'info',
executionData: {
finalOutput: { content: 'I recovered from the tool failure.' },
traceSpans: makeAgentTrace('error'),
},
})
)

expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(2)
expect(mockProcessPolledWebhookEvent.mock.calls[0][2]).toMatchObject({
event: 'agent_tool_error',
workflowId: 'wf-source',
workflowName: 'Source Workflow',
runId: 'exec-1',
toolError: {
agentBlockId: 'agent-1',
agentBlockName: 'Agent',
toolName: 'always_fail',
toolCallId: 'tool-call-1',
errorMessage: 'Intentional test tool failure',
durationMs: 12361,
recovered: true,
},
})
expect(mockProcessPolledWebhookEvent.mock.calls[1][2]).toMatchObject({
event: 'execution_success',
runId: 'exec-1',
})
})

it('does not fire agent_tool_error when every Agent tool call succeeded', async () => {
const toolErrorSub = makeSubscription(makeConfig({ eventType: 'agent_tool_error' }))
mockFetchSubscriptions.mockResolvedValueOnce([toolErrorSub])

await emitExecutionCompletedEvent(
makeLog({ executionData: { traceSpans: makeAgentTrace('success') } })
)

expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled()
})

it('respects the workflow scope filter, ignoring stale workflow ids', async () => {
const matching = makeSubscription(makeConfig({ workflowIds: ['wf-source', 'wf-deleted'] }), {
subscriberWorkflowId: 'wf-a',
Expand Down
Loading
Loading