diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx index 5670cb048e4..de6533fa482 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -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 (
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts index b3dc95416bb..d9c79bb0152 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts @@ -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 } diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 109d6e31e71..1a1788c59eb 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -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 diff --git a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts index 5ca4d8d87a5..d2e657deea3 100644 --- a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts +++ b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts @@ -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, @@ -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 } @@ -24,6 +26,49 @@ function normalizeTraceOutput(value: unknown): Record | 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 + + 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. * @@ -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}`, @@ -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 } @@ -280,6 +330,8 @@ 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'), @@ -287,9 +339,11 @@ function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceS 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 }), } }) } diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts index 77b333bbe75..216a847e9cc 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -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', @@ -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', @@ -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) @@ -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', () => { diff --git a/apps/sim/lib/workspace-events/constants.ts b/apps/sim/lib/workspace-events/constants.ts index 12f855dc7f0..87406df585a 100644 --- a/apps/sim/lib/workspace-events/constants.ts +++ b/apps/sim/lib/workspace-events/constants.ts @@ -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 @@ -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 + properties?: Record< + string, + { type: 'string' | 'number' | 'json' | 'boolean'; description: string } + > } /** Run summary fields shared by top-level plain events and the nested triggeringRun. */ @@ -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, @@ -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', diff --git a/apps/sim/lib/workspace-events/emitter.test.ts b/apps/sim/lib/workspace-events/emitter.test.ts index 3072ece6ac7..9e54202416c 100644 --- a/apps/sim/lib/workspace-events/emitter.test.ts +++ b/apps/sim/lib/workspace-events/emitter.test.ts @@ -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, @@ -112,6 +112,45 @@ function makeLog(overrides: Partial = {}): 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() @@ -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', diff --git a/apps/sim/lib/workspace-events/emitter.ts b/apps/sim/lib/workspace-events/emitter.ts index ce672aef57a..8d580576463 100644 --- a/apps/sim/lib/workspace-events/emitter.ts +++ b/apps/sim/lib/workspace-events/emitter.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' import { generateShortId } from '@sim/utils/id' -import type { WorkflowExecutionLog } from '@/lib/logs/types' +import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' import { isSimRuleEventType, SIM_RULE_COOLDOWN_HOURS, SIM_TRIGGER_PROVIDER, } from '@/lib/workspace-events/constants' import { + buildAgentToolErrorEventPayload, buildDeployEventPayload, buildExecutionEventPayload, buildUndeployEventPayload, @@ -23,6 +24,7 @@ import type { SimEventPayload, SimSubscription, SimSubscriptionConfig, + SimToolError, } from '@/lib/workspace-events/types' const logger = createLogger('WorkspaceEventEmitter') @@ -77,6 +79,33 @@ function matchesWorkflowScope(config: SimSubscriptionConfig, sourceWorkflowId: s return config.workflowIds.includes(sourceWorkflowId) } +/** Collects canonical failed tool spans belonging directly to Agent blocks. */ +function collectAgentToolErrors(spans: TraceSpan[]): SimToolError[] { + const toolErrors: SimToolError[] = [] + + const visit = (span: TraceSpan): void => { + if (span.type === 'agent') { + for (const child of span.children ?? []) { + if (child.type !== 'tool' || child.status !== 'error' || !child.errorMessage) continue + toolErrors.push({ + agentBlockId: span.blockId ?? span.id, + agentBlockName: span.name, + toolName: child.name, + toolCallId: child.toolCallId ?? null, + errorMessage: child.errorMessage, + durationMs: child.duration, + recovered: span.status === 'success', + }) + } + } + + for (const child of span.children ?? []) visit(child) + } + + for (const span of spans) visit(span) + return toolErrors +} + /** * Emits workspace events for a completed workflow execution. * @@ -96,6 +125,7 @@ export async function emitExecutionCompletedEvent(log: WorkflowExecutionLog): Pr if (subscriptions.length === 0) return const executionData = (log.executionData ?? {}) as Record + const toolErrors = collectAgentToolErrors(log.executionData.traceSpans ?? []) const context: ExecutionEventContext = { workflowId: log.workflowId, executionId: log.executionId, @@ -117,6 +147,21 @@ export async function emitExecutionCompletedEvent(log: WorkflowExecutionLog): Pr if (subscription.webhook.workflowId === log.workflowId) continue if (!matchesWorkflowScope(config, log.workflowId)) continue + if (config.eventType === 'agent_tool_error') { + for (const toolError of toolErrors) { + await dispatchSimEvent( + subscription, + buildAgentToolErrorEventPayload({ + workflowId: log.workflowId, + workflowName: workflowContext.workflow.name, + runId: log.executionId, + toolError, + }) + ) + } + continue + } + if (config.eventType === 'execution_success' && context.status !== 'success') continue if (config.eventType === 'execution_error' && context.status !== 'error') continue diff --git a/apps/sim/lib/workspace-events/payload.ts b/apps/sim/lib/workspace-events/payload.ts index b05d237c987..3650f69f4d3 100644 --- a/apps/sim/lib/workspace-events/payload.ts +++ b/apps/sim/lib/workspace-events/payload.ts @@ -3,13 +3,13 @@ import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { SIM_FINAL_OUTPUT_MAX_BYTES, type SimEventType, - type SimPlainEventType, type SimRuleEventType, } from '@/lib/workspace-events/constants' import type { ExecutionEventContext, SimEventPayload, SimRunSummary, + SimToolError, } from '@/lib/workspace-events/types' /** @@ -46,10 +46,29 @@ function basePayload(params: { cost: null, finalOutput: null, triggeringRun: null, + toolError: null, version: null, } } +/** Payload for one failed Agent tool invocation discovered at run completion. */ +export function buildAgentToolErrorEventPayload(params: { + workflowId: string + workflowName: string + runId: string + toolError: SimToolError +}): SimEventPayload { + return { + ...basePayload({ + event: 'agent_tool_error', + workflowId: params.workflowId, + workflowName: params.workflowName, + }), + runId: params.runId, + toolError: params.toolError, + } +} + /** Run summary in user-facing units: cost in credits, finalOutput bounded. */ function summarizeRun(context: ExecutionEventContext): SimRunSummary { return { @@ -67,7 +86,7 @@ function summarizeRun(context: ExecutionEventContext): SimRunSummary { * the condition that fired, so it nests under `triggeringRun`. */ export function buildExecutionEventPayload(params: { - event: Exclude | SimRuleEventType + event: 'execution_success' | 'execution_error' | SimRuleEventType workflowName: string context: ExecutionEventContext }): SimEventPayload { diff --git a/apps/sim/lib/workspace-events/types.ts b/apps/sim/lib/workspace-events/types.ts index 427894b772b..47211b1339f 100644 --- a/apps/sim/lib/workspace-events/types.ts +++ b/apps/sim/lib/workspace-events/types.ts @@ -46,6 +46,17 @@ export interface SimRunSummary { finalOutput: unknown } +/** Safe, bounded metadata describing one failed tool invocation inside an Agent. */ +export interface SimToolError { + agentBlockId: string + agentBlockName: string + toolName: string + toolCallId: string | null + errorMessage: string + durationMs: number + recovered: boolean +} + /** * Wire payload delivered to a Sim trigger workflow. Keys must align with * SIM_EVENT_PAYLOAD_FIELDS — enforced by tests on the payload builders. @@ -60,5 +71,6 @@ export type SimEventPayload = Record & { cost: number | null finalOutput: unknown triggeringRun: SimRunSummary | null + toolError: SimToolError | null version: number | null } diff --git a/apps/sim/triggers/sim/workspace-event.test.ts b/apps/sim/triggers/sim/workspace-event.test.ts index 9ddabb70d6f..fbe76836ac3 100644 --- a/apps/sim/triggers/sim/workspace-event.test.ts +++ b/apps/sim/triggers/sim/workspace-event.test.ts @@ -222,6 +222,12 @@ describe('sim workspace event outputs', () => { } }) + it('agent tool errors expose the source run and safe tool metadata', () => { + expect(visibleOutputsFor('agent_tool_error')).toEqual( + ['event', 'runId', 'timestamp', 'toolError', 'workflowId', 'workflowName'].sort() + ) + }) + it('run-backed rule events expose the base fields plus the nested triggeringRun', () => { for (const eventType of EXECUTION_BACKED.filter( (type) => type !== 'execution_success' && type !== 'execution_error' diff --git a/apps/sim/triggers/sim/workspace-event.ts b/apps/sim/triggers/sim/workspace-event.ts index a2f885b2f02..dc03a0a0e2b 100644 --- a/apps/sim/triggers/sim/workspace-event.ts +++ b/apps/sim/triggers/sim/workspace-event.ts @@ -25,6 +25,7 @@ export const simWorkspaceEventTrigger: TriggerConfig = { options: [ { id: 'execution_error', label: 'Run Error', group: 'Events' }, { id: 'execution_success', label: 'Run Success', group: 'Events' }, + { id: 'agent_tool_error', label: 'Agent Tool Error', group: 'Events' }, { id: 'workflow_deployed', label: 'Workflow Deployed', group: 'Events' }, { id: 'workflow_undeployed', label: 'Workflow Undeployed', group: 'Events' }, { id: 'consecutive_failures', label: 'Consecutive Failures', group: 'Alert Conditions' },