Skip to content

Commit 29fc796

Browse files
committed
feat(copilot): attribute Sim tool calls to agents
1 parent b98dd8b commit 29fc796

7 files changed

Lines changed: 193 additions & 9 deletions

File tree

apps/sim/lib/copilot/request/handlers/handlers.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,7 @@ describe('sse-handlers tool lifecycle', () => {
408408

409409
const updated = context.toolCalls.get('tool-1')
410410
expect(updated?.status).toBe(MothershipStreamV1ToolOutcome.success)
411+
expect(updated?.agentId).toBe('main')
411412
// Display titles are derived client-side from the tool name (+args), not the
412413
// stream; read with no path resolves to the static "Reading file".
413414
expect(updated?.displayTitle).toBe('Reading file')
@@ -889,9 +890,11 @@ describe('sse-handlers tool lifecycle', () => {
889890
expect.any(Object)
890891
)
891892
expect(context.toolCalls.get('sub-tool-1')?.params).toEqual({ name: 'Example Workflow' })
893+
expect(context.toolCalls.get('sub-tool-1')?.agentId).toBe('workflow')
892894
expect(context.subAgentToolCalls['parent-1']?.[0]?.params).toEqual({
893895
name: 'Example Workflow',
894896
})
897+
expect(context.subAgentToolCalls['parent-1']?.[0]?.agentId).toBe('workflow')
895898
})
896899

897900
it('routes subagent text using the event scope parent tool call id', async () => {
@@ -973,6 +976,40 @@ describe('sse-handlers tool lifecycle', () => {
973976
await sleep(0)
974977

975978
expect(context.subAgentToolCalls['parent-1']?.[0]?.id).toBe('sub-tool-scope-1')
979+
expect(context.toolCalls.get('sub-tool-scope-1')?.agentId).toBe('deploy')
980+
})
981+
982+
it('retains the first agent attribution on replayed partial tool calls', async () => {
983+
context.toolCalls.set('replayed-read', {
984+
id: 'replayed-read',
985+
name: 'read',
986+
status: 'executing',
987+
})
988+
989+
const replayPartial = (agentId: string) =>
990+
subAgentHandlers.tool(
991+
{
992+
type: MothershipStreamV1EventType.tool,
993+
scope: { lane: 'subagent', parentToolCallId: 'parent-1', agentId },
994+
payload: {
995+
toolCallId: 'replayed-read',
996+
toolName: 'read',
997+
executor: MothershipStreamV1ToolExecutor.go,
998+
mode: MothershipStreamV1ToolMode.sync,
999+
phase: MothershipStreamV1ToolPhase.call,
1000+
status: 'generating',
1001+
partial: true,
1002+
},
1003+
} satisfies StreamEvent,
1004+
context,
1005+
execContext,
1006+
{ interactive: false, timeout: 1000 }
1007+
)
1008+
1009+
await replayPartial('workflow')
1010+
await replayPartial('deploy')
1011+
1012+
expect(context.toolCalls.get('replayed-read')?.agentId).toBe('workflow')
9761013
})
9771014

9781015
it('pairs compaction lifecycle events within each scoped subagent lane', async () => {

apps/sim/lib/copilot/request/handlers/tool.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ export async function handleToolEvent(
285285
): Promise<void> {
286286
const isSubagent = scope === 'subagent'
287287
const parentToolCallId = isSubagent ? getScopedParentToolCallId(event, context) : undefined
288+
const agentId = event.scope?.agentId ?? 'main'
288289

289290
if (isSubagent && !parentToolCallId) return
290291

@@ -332,6 +333,7 @@ export async function handleToolEvent(
332333
options,
333334
parentToolCallId,
334335
scope,
336+
agentId,
335337
getScopedSpanIdentity(event)
336338
)
337339
}
@@ -409,13 +411,15 @@ async function handleCallPhase(
409411
options: OrchestratorOptions,
410412
parentToolCallId: string | undefined,
411413
scope: ToolScope,
414+
agentId: string,
412415
spanIdentity: { spanId?: string; parentSpanId?: string }
413416
): Promise<void> {
414417
const { toolCallId, toolName } = data
415418
const args = data.arguments
416419
const isGenerating = data.status === TOOL_CALL_STATUS.generating
417420
const isPartial = data.partial === true || isGenerating
418421
const existing = context.toolCalls.get(toolCallId)
422+
if (existing) existing.agentId ??= agentId
419423
const isSubagent = scope === 'subagent'
420424
const ui = getToolCallUI(data)
421425

@@ -459,12 +463,13 @@ async function handleCallPhase(
459463
toolName,
460464
args,
461465
parentToolCallId!,
466+
agentId,
462467
ui,
463468
spanIdentity,
464469
!isPartial
465470
)
466471
} else {
467-
registerMainToolCall(context, toolCallId, toolName, args, existing, ui, !isPartial)
472+
registerMainToolCall(context, toolCallId, toolName, args, existing, agentId, ui, !isPartial)
468473
}
469474

470475
if (isPartial) return
@@ -554,6 +559,7 @@ function registerSubagentToolCall(
554559
toolName: string,
555560
args: Record<string, unknown> | undefined,
556561
parentToolCallId: string,
562+
agentId: string,
557563
ui: { title?: string; phaseLabel?: string; hidden?: boolean },
558564
spanIdentity: { spanId?: string; parentSpanId?: string },
559565
finalized: boolean
@@ -574,6 +580,7 @@ function registerSubagentToolCall(
574580
id: toolCallId,
575581
name: toolName,
576582
status: 'pending',
583+
agentId,
577584
params: args,
578585
startTime: Date.now(),
579586
}
@@ -594,6 +601,7 @@ function registerSubagentToolCall(
594601
const subagentToolCalls = context.subAgentToolCalls[parentToolCallId]
595602
const existingSubagentToolCall = subagentToolCalls.find((tc) => tc.id === toolCallId)
596603
if (existingSubagentToolCall) {
604+
existingSubagentToolCall.agentId ??= agentId
597605
if (!rebindResolvedIntegrationCall(existingSubagentToolCall, toolName, args)) {
598606
updateToolCallFromFrame(existingSubagentToolCall, toolName, args, finalized)
599607
}
@@ -609,6 +617,7 @@ function registerMainToolCall(
609617
toolName: string,
610618
args: Record<string, unknown> | undefined,
611619
existing: ToolCallState | undefined,
620+
agentId: string,
612621
ui: { title?: string; phaseLabel?: string; hidden?: boolean },
613622
finalized: boolean
614623
): void {
@@ -633,6 +642,7 @@ function registerMainToolCall(
633642
id: toolCallId,
634643
name: toolName,
635644
status: 'pending',
645+
agentId,
636646
params: args,
637647
startTime: Date.now(),
638648
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { describe, expect, it, vi } from 'vitest'
6+
7+
const { toolCallsAdd, toolDurationRecord } = vi.hoisted(() => ({
8+
toolCallsAdd: vi.fn(),
9+
toolDurationRecord: vi.fn(),
10+
}))
11+
12+
vi.mock('@opentelemetry/api', () => ({
13+
metrics: {
14+
getMeter: vi.fn(() => ({
15+
createCounter: vi.fn(() => ({ add: toolCallsAdd })),
16+
createHistogram: vi.fn((name: string) => ({
17+
record: name === 'copilot.tool.duration' ? toolDurationRecord : vi.fn(),
18+
})),
19+
})),
20+
},
21+
}))
22+
23+
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
24+
import { recordSimToolMetric } from '@/lib/copilot/request/metrics'
25+
26+
describe('recordSimToolMetric', () => {
27+
it('attributes call counts to the agent without adding it to duration', () => {
28+
recordSimToolMetric('read', 'workflow', 'success', 125)
29+
30+
const baseAttributes = {
31+
[TraceAttr.ToolName]: 'read',
32+
[TraceAttr.ToolExecutor]: 'sim',
33+
[TraceAttr.ToolOutcome]: 'success',
34+
}
35+
expect(toolCallsAdd).toHaveBeenCalledWith(1, {
36+
...baseAttributes,
37+
[TraceAttr.GenAiAgentName]: 'workflow',
38+
})
39+
expect(toolDurationRecord).toHaveBeenCalledWith(125, baseAttributes)
40+
})
41+
})

apps/sim/lib/copilot/request/metrics.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,23 @@ function cappedToolName(name: string): string {
7171
// recordSimToolMetric emits copilot.tool.calls (+1) and copilot.tool.duration
7272
// for one server-side Sim tool dispatch (executor=sim). outcome is the bounded
7373
// tool outcome (success/error/…). Pure telemetry.
74-
export function recordSimToolMetric(name: string, outcome: string, durationMs: number): void {
74+
export function recordSimToolMetric(
75+
name: string,
76+
agentId: string,
77+
outcome: string,
78+
durationMs: number
79+
): void {
7580
const { toolDuration, toolCalls } = instruments()
76-
const attrs = {
81+
const baseAttrs = {
7782
[TraceAttr.ToolName]: cappedToolName(name),
7883
[TraceAttr.ToolExecutor]: 'sim',
7984
[TraceAttr.ToolOutcome]: outcome,
8085
}
81-
toolCalls.add(1, attrs)
82-
if (durationMs >= 0) toolDuration.record(durationMs, attrs)
86+
toolCalls.add(1, {
87+
...baseAttrs,
88+
[TraceAttr.GenAiAgentName]: agentId,
89+
})
90+
if (durationMs >= 0) toolDuration.record(durationMs, baseAttrs)
8391
}
8492

8593
// recordVfsMaterialize records VFS materialization time. Call once per phase

apps/sim/lib/copilot/request/tools/executor.test.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
11
import '@sim/testing/mocks/executor'
22

3-
import { describe, expect, it } from 'vitest'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
const { recordSimToolMetric, setAttribute } = vi.hoisted(() => ({
6+
recordSimToolMetric: vi.fn(),
7+
setAttribute: vi.fn(),
8+
}))
9+
10+
vi.mock('@/lib/copilot/request/metrics', () => ({
11+
recordSimToolMetric,
12+
}))
13+
14+
vi.mock('@/lib/copilot/request/otel', () => ({
15+
withCopilotToolSpan: (
16+
_input: unknown,
17+
fn: (span: { setAttribute: typeof setAttribute }) => Promise<unknown>
18+
) => fn({ setAttribute }),
19+
}))
20+
421
import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants'
22+
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
23+
import { createStreamingContext } from '@/lib/copilot/request/context/request-context'
524
import {
625
buildToolExecutionContext,
26+
executeToolAndReport,
727
pendingToolWaitBudgetMs,
828
toolWatchdogTimeoutMs,
929
} from '@/lib/copilot/request/tools/executor'
10-
import type { ExecutionContext } from '@/lib/copilot/request/types'
30+
import type { ExecutionContext, ToolCallState } from '@/lib/copilot/request/types'
1131

1232
describe('toolWatchdogTimeoutMs', () => {
1333
it('gives request-scoped MCP tools the long-running watchdog', () => {
@@ -58,3 +78,59 @@ describe('buildToolExecutionContext', () => {
5878
})
5979
})
6080
})
81+
82+
describe('executeToolAndReport metrics', () => {
83+
const executionContext: ExecutionContext = {
84+
userId: 'user-1',
85+
workflowId: 'workflow-1',
86+
}
87+
88+
beforeEach(() => {
89+
vi.clearAllMocks()
90+
})
91+
92+
it('forwards the stored agent on normal completion', async () => {
93+
const toolCall: ToolCallState = {
94+
id: 'call-1',
95+
name: 'read',
96+
status: MothershipStreamV1ToolOutcome.success,
97+
result: { success: true, output: 'done' },
98+
agentId: 'workflow',
99+
endTime: Date.now(),
100+
}
101+
const context = createStreamingContext({
102+
toolCalls: new Map([[toolCall.id, toolCall]]),
103+
})
104+
105+
await executeToolAndReport(toolCall.id, context, executionContext)
106+
107+
expect(recordSimToolMetric).toHaveBeenCalledWith(
108+
'read',
109+
'workflow',
110+
MothershipStreamV1ToolOutcome.success,
111+
expect.any(Number)
112+
)
113+
})
114+
115+
it('falls back to main when forwarding an unexpected throw', async () => {
116+
const toolCall: ToolCallState = {
117+
id: 'call-2',
118+
name: 'read',
119+
status: MothershipStreamV1ToolOutcome.error,
120+
endTime: Date.now(),
121+
}
122+
const context = createStreamingContext({
123+
toolCalls: new Map([[toolCall.id, toolCall]]),
124+
})
125+
126+
await expect(executeToolAndReport(toolCall.id, context, executionContext)).rejects.toThrow(
127+
'missing a canonical error'
128+
)
129+
expect(recordSimToolMetric).toHaveBeenCalledWith(
130+
'read',
131+
'main',
132+
MothershipStreamV1ToolOutcome.error,
133+
expect.any(Number)
134+
)
135+
})
136+
})

apps/sim/lib/copilot/request/tools/executor.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,12 @@ export async function executeToolAndReport(
428428
}
429429
// Durable Grafana signal for "which Sim tool is slowest" (executor=sim);
430430
// pairs with the Go executor-boundary metric (U15) as one series set.
431-
recordSimToolMetric(toolCall.name, completion.status, durationMs)
431+
recordSimToolMetric(
432+
toolCall.name,
433+
toolCall.agentId ?? 'main',
434+
completion.status,
435+
durationMs
436+
)
432437
return completion
433438
} catch (err) {
434439
// executeToolAndReportInner threw (infra/unexpected error, not a normal
@@ -437,7 +442,12 @@ export async function executeToolAndReport(
437442
const durationMs = Date.now() - startedAt
438443
otelSpan.setAttribute(TraceAttr.ToolOutcome, 'error')
439444
otelSpan.setAttribute(TraceAttr.ToolDurationMs, durationMs)
440-
recordSimToolMetric(toolCall.name, 'error', durationMs)
445+
recordSimToolMetric(
446+
toolCall.name,
447+
toolCall.agentId ?? 'main',
448+
MothershipStreamV1ToolOutcome.error,
449+
durationMs
450+
)
441451
throw err
442452
}
443453
}

apps/sim/lib/copilot/request/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface ToolCallState {
2525
id: string
2626
name: string
2727
status: ToolCallStatus
28+
/** Bounded registry ID of the agent that invoked this tool. */
29+
agentId?: string
2830
displayTitle?: string
2931
/** Model-authored activity text for a gateway-resolved integration call. */
3032
integrationDescription?: string

0 commit comments

Comments
 (0)