Skip to content

Commit 9ff7eae

Browse files
authored
feat(copilot): attribute tool metrics to invoking agents (#6266)
* feat(copilot): attribute Sim tool calls to agents * fix(review): bound agent metric labels * feat(copilot): attribute tool latency to agents * feat(copilot): attribute tool spans to agents * refactor(copilot): simplify tool attribute naming * fix(copilot): bound tool span agent labels
1 parent 2d90c72 commit 9ff7eae

8 files changed

Lines changed: 260 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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { beforeEach, 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 { normalizeToolAgentId, recordSimToolMetric } from '@/lib/copilot/request/metrics'
25+
26+
describe('recordSimToolMetric', () => {
27+
beforeEach(() => {
28+
vi.clearAllMocks()
29+
})
30+
31+
it.each(['main', 'workflow'])(
32+
'attributes call counts and duration to the registered %s agent',
33+
(agentId) => {
34+
recordSimToolMetric('read', agentId, 'success', 125)
35+
36+
const baseAttributes = {
37+
[TraceAttr.ToolName]: 'read',
38+
[TraceAttr.ToolExecutor]: 'sim',
39+
[TraceAttr.ToolOutcome]: 'success',
40+
}
41+
expect(toolCallsAdd).toHaveBeenCalledWith(1, {
42+
...baseAttributes,
43+
[TraceAttr.GenAiAgentName]: agentId,
44+
})
45+
expect(toolDurationRecord).toHaveBeenCalledWith(125, {
46+
...baseAttributes,
47+
[TraceAttr.GenAiAgentName]: agentId,
48+
})
49+
}
50+
)
51+
52+
it('collapses unknown agent IDs to the bounded fallback', () => {
53+
recordSimToolMetric('read', 'tenant-defined-agent', 'success', 125)
54+
55+
const baseAttributes = {
56+
[TraceAttr.ToolName]: 'read',
57+
[TraceAttr.ToolExecutor]: 'sim',
58+
[TraceAttr.ToolOutcome]: 'success',
59+
}
60+
expect(toolCallsAdd).toHaveBeenCalledWith(1, {
61+
...baseAttributes,
62+
[TraceAttr.GenAiAgentName]: 'other',
63+
})
64+
expect(toolDurationRecord).toHaveBeenCalledWith(125, {
65+
...baseAttributes,
66+
[TraceAttr.GenAiAgentName]: 'other',
67+
})
68+
})
69+
70+
it.each([
71+
{ agentId: 'main', expected: 'main' },
72+
{ agentId: 'workflow', expected: 'workflow' },
73+
{ agentId: 'tenant-defined-agent', expected: 'other' },
74+
{ agentId: '', expected: 'other' },
75+
])('normalizes $agentId to $expected for every telemetry signal', ({ agentId, expected }) => {
76+
expect(normalizeToolAgentId(agentId)).toBe(expected)
77+
})
78+
})

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
// contracts/metrics_v1.go) so the Go∪Sim union is queryable as one series set
66
// — e.g. `copilot.tool.duration` split by `tool.executor` (go|client|sim).
77
//
8-
// Bounded cardinality only: tool.name is capped to the shared tool catalog
9-
// (else "other"); vfs phase / file-read outcome are bounded sets. NEVER a
10-
// user/chat/request id (those explode Prometheus series).
8+
// Bounded cardinality only: tool.name and gen_ai.agent.name are capped to the
9+
// shared catalogs (else "other"); vfs phase / file-read outcome are bounded
10+
// sets. NEVER a user/chat/request id (those explode Prometheus series).
1111
import { type Counter, type Histogram, metrics } from '@opentelemetry/api'
1212
import { Metric } from '@/lib/copilot/generated/metrics-v1'
1313
import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1'
@@ -68,15 +68,30 @@ function cappedToolName(name: string): string {
6868
return TOOL_CATALOG[name] ? name : 'other'
6969
}
7070

71+
const REGISTERED_AGENT_IDS = new Set([
72+
'main',
73+
...Object.values(TOOL_CATALOG).flatMap(({ subagentId }) => (subagentId ? [subagentId] : [])),
74+
])
75+
76+
export function normalizeToolAgentId(agentId: string): string {
77+
return REGISTERED_AGENT_IDS.has(agentId) ? agentId : 'other'
78+
}
79+
7180
// recordSimToolMetric emits copilot.tool.calls (+1) and copilot.tool.duration
7281
// for one server-side Sim tool dispatch (executor=sim). outcome is the bounded
7382
// tool outcome (success/error/…). Pure telemetry.
74-
export function recordSimToolMetric(name: string, outcome: string, durationMs: number): void {
83+
export function recordSimToolMetric(
84+
name: string,
85+
agentId: string,
86+
outcome: string,
87+
durationMs: number
88+
): void {
7589
const { toolDuration, toolCalls } = instruments()
7690
const attrs = {
7791
[TraceAttr.ToolName]: cappedToolName(name),
7892
[TraceAttr.ToolExecutor]: 'sim',
7993
[TraceAttr.ToolOutcome]: outcome,
94+
[TraceAttr.GenAiAgentName]: normalizeToolAgentId(agentId),
8095
}
8196
toolCalls.add(1, attrs)
8297
if (durationMs >= 0) toolDuration.record(durationMs, attrs)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
2323
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
2424
import { contextFromRequestHeaders } from '@/lib/copilot/request/go/propagation'
25+
import { normalizeToolAgentId } from '@/lib/copilot/request/metrics'
2526
import { isExplicitStopReason } from '@/lib/copilot/request/session/abort-reason'
2627

2728
// OTel GenAI content-capture env var (spec:
@@ -284,6 +285,7 @@ export async function withCopilotToolSpan<T>(
284285
input: {
285286
toolName: string
286287
toolCallId: string
288+
agentName: string
287289
runId?: string
288290
chatId?: string
289291
argsBytes?: number
@@ -299,6 +301,7 @@ export async function withCopilotToolSpan<T>(
299301
[TraceAttr.ToolName]: input.toolName,
300302
[TraceAttr.ToolCallId]: input.toolCallId,
301303
[TraceAttr.ToolExecutor]: 'sim',
304+
[TraceAttr.GenAiAgentName]: normalizeToolAgentId(input.agentName),
302305
...(input.runId ? { [TraceAttr.RunId]: input.runId } : {}),
303306
...(input.chatId ? { [TraceAttr.ChatId]: input.chatId } : {}),
304307
...(typeof input.argsBytes === 'number'

0 commit comments

Comments
 (0)