From a3517d56f718cfcc6b642fb4109e10b22e7bbfee Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 10 Jul 2026 13:40:11 +0800 Subject: [PATCH 01/13] fix: emit turn_id on turn_started/ended/interrupted telemetry The turn lifecycle telemetry events (turn_started, turn_ended, turn_interrupted) never carried the turn id, while tool_call and tool_call_dedup_detected did. Any analysis correlating a turn's start, end, or interruption back to its tool calls had nothing to join on. Add turn_id (already in scope) to all three track() calls, matching the existing key/value convention used by tool_call_dedup_detected. Update the strict turn_started/turn_interrupted assertion to cover it. --- packages/agent-core/src/agent/turn/index.ts | 4 +++- packages/agent-core/test/agent/turn.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 0caaa7475c..a2ca954a24 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -515,7 +515,7 @@ export class TurnFlow { const telemetryMode = this.telemetryMode(); this.telemetryModeByTurn.set(turnId, telemetryMode); this.currentStepByTurn.set(turnId, 0); - this.agent.telemetry.track('turn_started', { mode: telemetryMode, ...this.requestProtocolProps() }); + this.agent.telemetry.track('turn_started', { turn_id: turnId, mode: telemetryMode, ...this.requestProtocolProps() }); this.agent.fullCompaction.resetForTurn(); this.agent.usage.beginTurn(); this.agent.emitEvent({ type: 'turn.started', turnId, origin }); @@ -613,6 +613,7 @@ export class TurnFlow { }); } this.agent.telemetry.track('turn_ended', { + turn_id: turnId, reason: ended.reason, duration_ms: ended.durationMs, mode: this.telemetryModeByTurn.get(turnId) ?? this.telemetryMode(), @@ -1104,6 +1105,7 @@ export class TurnFlow { if (this.interruptedTelemetryTurnIds.has(turnId)) return; this.interruptedTelemetryTurnIds.add(turnId); this.agent.telemetry.track('turn_interrupted', { + turn_id: turnId, mode: this.telemetryModeByTurn.get(turnId) ?? this.telemetryMode(), at_step: atStep, interrupt_reason: interruptReason, diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 358b52d7eb..bb6a005e4a 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -409,11 +409,11 @@ describe('Agent turn flow', () => { expect(records).toContainEqual({ event: 'turn_started', - properties: { mode: 'agent' }, + properties: { turn_id: 0, mode: 'agent' }, }); expect(records).toContainEqual({ event: 'turn_interrupted', - properties: { mode: 'agent', at_step: 0, interrupt_reason: 'error' }, + properties: { turn_id: 0, mode: 'agent', at_step: 0, interrupt_reason: 'error' }, }); }); From b4c4ba83085823f7b4d227a9263eaaa81daeec6b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 14 Jul 2026 12:27:43 +0800 Subject: [PATCH 02/13] fix(agent-core-v2): emit turn_id on turn_started/ended/interrupted telemetry Port the v1 fix to agent-core-v2: turn lifecycle telemetry events (turn_started, turn_ended, turn_interrupted) carried no turn id while tool_call did, leaving nothing to correlate a turn's start, end, or interruption back to its tool calls. Add turn_id to the three event interfaces, the telemetry registry property docs, and the three track2() calls in AgentLoopService, matching the existing ToolCallEvent key convention. Extend the turn telemetry assertions in loop.test.ts to cover it. --- packages/agent-core-v2/src/agent/loop/loopService.ts | 4 +++- packages/agent-core-v2/src/app/telemetry/events.ts | 6 ++++++ packages/agent-core-v2/test/agent/loop/loop.test.ts | 6 ++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index d1c1fbab2c..3b9155533c 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -369,7 +369,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { const { mode, provider_type, protocol } = telemetryContext; let result: TurnResult | undefined; try { - const started: TurnStartedTelemetryEvent = { mode, provider_type, protocol }; + const started: TurnStartedTelemetryEvent = { turn_id: turn.id, mode, provider_type, protocol }; turnTelemetry.track2('turn_started', started); result = await this.run({ turnId: turn.id, @@ -397,6 +397,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (error !== undefined) this.eventBus.publish({ type: 'error', ...error }); if (result.type !== 'completed') { const interrupted: TurnInterruptedEvent = { + turn_id: turn.id, at_step: result.steps, mode, interrupt_reason: interruptReasonFor(result), @@ -407,6 +408,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } } const ended: TurnEndedTelemetryEvent = { + turn_id: turn.id, reason: result?.type ?? 'failed', duration_ms: Date.now() - startedAt, mode, diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index bb1a982f9a..061e3bcb6c 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -43,12 +43,14 @@ export type StrictPropertyCheck = string extends keyof T : never; export interface TurnStartedEvent { + turn_id: number; mode: 'agent' | 'plan'; provider_type?: string; protocol?: string; } export interface TurnInterruptedEvent { + turn_id: number; at_step: number; mode: 'agent' | 'plan'; interrupt_reason: 'user_cancelled' | 'aborted' | 'max_steps' | 'error' | 'filtered' | 'blocked'; @@ -57,6 +59,7 @@ export interface TurnInterruptedEvent { } export interface TurnEndedEvent { + turn_id: number; reason: 'completed' | 'cancelled' | 'failed'; duration_ms: number; mode: 'agent' | 'plan'; @@ -388,6 +391,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A turn starts running.', properties: { + turn_id: 'Turn index within the session', mode: 'Agent mode the turn runs in', provider_type: 'Provider protocol type', protocol: 'Request protocol', @@ -397,6 +401,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A running turn is interrupted.', properties: { + turn_id: 'Turn index within the session', at_step: 'Step index the turn reached before interruption', mode: 'Agent mode the turn ran in', interrupt_reason: 'Why the turn was interrupted', @@ -408,6 +413,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A turn ends, unconditionally.', properties: { + turn_id: 'Turn index within the session', reason: 'How the turn ended', duration_ms: 'Turn wall-clock time in milliseconds', mode: 'Agent mode the turn ran in', diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 08a427c76e..77ee6a4f12 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -598,11 +598,12 @@ describe('turn telemetry', () => { expect(records).toContainEqual({ event: 'turn_started', - properties: { mode: 'agent', provider_type: 'kimi', protocol: 'kimi' }, + properties: { turn_id: 0, mode: 'agent', provider_type: 'kimi', protocol: 'kimi' }, }); expect(records).toContainEqual({ event: 'turn_ended', properties: expect.objectContaining({ + turn_id: 0, reason: 'completed', duration_ms: expect.any(Number), mode: 'agent', @@ -630,6 +631,7 @@ describe('turn telemetry', () => { expect(records).toContainEqual({ event: 'turn_interrupted', properties: expect.objectContaining({ + turn_id: 0, at_step: 1, mode: 'agent', interrupt_reason: 'filtered', @@ -677,7 +679,7 @@ describe('turn telemetry', () => { expect(records).toContainEqual({ event: 'turn_interrupted', - properties: expect.objectContaining({ interrupt_reason: expected, mode: 'agent' }), + properties: expect.objectContaining({ turn_id: 0, interrupt_reason: expected, mode: 'agent' }), }); expect(records).toContainEqual({ event: 'turn_ended', From 7c6c1e7c1c8263e3e01aa0546abedf34b999881e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 14 Jul 2026 12:57:38 +0800 Subject: [PATCH 03/13] fix: emit turn_id on tool_call telemetry --- packages/agent-core/src/agent/turn/index.ts | 1 + packages/agent-core/test/agent/turn.test.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index a2ca954a24..9797e0a206 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -1046,6 +1046,7 @@ export class TurnFlow { this.toolCallDupType.delete(event.toolCallId); const outcome = telemetryToolOutcome(event.result); const properties: Record = { + turn_id: turnId, tool_name: started.name, outcome, duration_ms: Date.now() - started.startedAt, diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index bb6a005e4a..cdea0bee71 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -546,6 +546,7 @@ describe('Agent turn flow', () => { expect(ended).toEqual({ event: 'turn_ended', properties: expect.objectContaining({ + turn_id: 0, mode: 'agent', reason: 'completed', provider_type: 'kimi', @@ -625,6 +626,7 @@ describe('Agent turn flow', () => { expect(records).toContainEqual({ event: 'tool_call', properties: expect.objectContaining({ + turn_id: 0, tool_name: 'Bash', outcome: 'success', dup_type: 'cross_step', @@ -702,6 +704,7 @@ describe('Agent turn flow', () => { expect(records).toContainEqual({ event: 'tool_call', properties: expect.objectContaining({ + turn_id: 0, tool_name: 'MissingTool', outcome: 'error', dup_type: 'normal', @@ -2096,6 +2099,7 @@ describe('Agent turn flow', () => { expect(records).toContainEqual({ event: 'tool_call', properties: expect.objectContaining({ + turn_id: 0, tool_name: 'Bash', outcome: 'cancelled', dup_type: 'normal', From 88beb5dad6a76bedd7a8f17b2369ebee993befee Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 14 Jul 2026 12:57:38 +0800 Subject: [PATCH 04/13] docs(agent-core-v2): clarify turn_id is a per-agent index in the telemetry registry --- packages/agent-core-v2/src/app/telemetry/events.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 061e3bcb6c..4952eb51c8 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -391,7 +391,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A turn starts running.', properties: { - turn_id: 'Turn index within the session', + turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', mode: 'Agent mode the turn runs in', provider_type: 'Provider protocol type', protocol: 'Request protocol', @@ -401,7 +401,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A running turn is interrupted.', properties: { - turn_id: 'Turn index within the session', + turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', at_step: 'Step index the turn reached before interruption', mode: 'Agent mode the turn ran in', interrupt_reason: 'Why the turn was interrupted', @@ -413,7 +413,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A turn ends, unconditionally.', properties: { - turn_id: 'Turn index within the session', + turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', reason: 'How the turn ended', duration_ms: 'Turn wall-clock time in milliseconds', mode: 'Agent mode the turn ran in', @@ -425,7 +425,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A tool call finishes execution.', properties: { - turn_id: 'Turn index within the session', + turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', tool_call_id: 'Provider-assigned tool call id', tool_name: 'Registered tool name', outcome: 'Execution outcome', @@ -663,7 +663,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A duplicate tool call is detected.', properties: { - turn_id: 'Turn index within the session', + turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', step_no: 'Step index within the turn', tool_call_id: 'Provider-assigned tool call id', tool_name: 'Registered tool name', From f57d5066c675e8ec4dcf133043dc8fab8bc818cf Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 14 Jul 2026 14:10:31 +0800 Subject: [PATCH 05/13] feat(telemetry): add agent_id to turn and tool events turn_id is a per-agent counter, so it collides across the main agent and subagents within a session. Emit each agent's scope id as agent_id on turn_*, tool_call, tool_call_dedup_detected, api_error and subagent_created (plus parent_agent_id) so events become attributable via (session_id, agent_id, turn_id). --- .changeset/telemetry-agent-id.md | 7 +++++ .../agent/llmRequester/llmRequesterService.ts | 3 +++ .../src/agent/loop/loopService.ts | 6 +++-- .../src/agent/toolDedupe/toolDedupeService.ts | 3 +++ .../agent/toolExecutor/toolExecutorService.ts | 3 +++ .../app/telemetry/agentTelemetryContext.ts | 1 + .../telemetry/agentTelemetryContextService.ts | 7 ++++- .../agent-core-v2/src/app/telemetry/events.ts | 26 +++++++++++++++---- .../session/agentLifecycle/mirrorAgentRun.ts | 2 ++ .../agent/llmRequester/llmRequester.test.ts | 1 + .../llmRequester/llmRequesterService.test.ts | 2 ++ .../test/agent/loop/loop.test.ts | 8 +++++- .../test/agent/profile/profileOps.test.ts | 6 ++++- .../test/agent/toolDedupe/toolDedupe.test.ts | 2 ++ .../agent/toolExecutor/toolExecutor.test.ts | 3 +++ .../toolSelect/toolSelectService.test.ts | 2 ++ .../telemetry/agentTelemetryContext.test.ts | 18 +++++++++---- packages/agent-core/src/session/index.ts | 4 +-- .../agent-core/src/session/subagent-host.ts | 2 ++ .../test/harness/model-alias-session.test.ts | 9 ++++--- .../test/harness/skill-session.test.ts | 3 +++ .../test/session/subagent-host.test.ts | 2 ++ 22 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 .changeset/telemetry-agent-id.md diff --git a/.changeset/telemetry-agent-id.md b/.changeset/telemetry-agent-id.md new file mode 100644 index 0000000000..dcae982579 --- /dev/null +++ b/.changeset/telemetry-agent-id.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Add an `agent_id` field to turn and tool telemetry events so activity from the main agent and subagents can be told apart within a session. diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index c71f218eac..cc7f08614b 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -27,6 +27,7 @@ import { type FaultKind, } from '#/agent/faultInjection/faultInjection'; import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentUsageService } from '#/agent/usage/usage'; @@ -140,6 +141,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentWireService private readonly wire: IWireService, @IFaultInjectionService private readonly faultInjection: IFaultInjectionService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) {} async request( @@ -182,6 +184,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { const model = this.tryGetProvider(); const properties: ApiErrorEvent = { error_type: apiErrorType(error), + agent_id: this.scopeContext.agentId, model: model?.id ?? modelAlias ?? 'unknown', alias: modelAlias, provider_type: model?.protocol, diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 3b9155533c..c1ef1dc6c2 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -366,10 +366,10 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { const startedAt = Date.now(); const telemetryContext = this.telemetryContext.get(); const turnTelemetry = this.telemetry.withContext(telemetryContext); - const { mode, provider_type, protocol } = telemetryContext; + const { agent_id, mode, provider_type, protocol } = telemetryContext; let result: TurnResult | undefined; try { - const started: TurnStartedTelemetryEvent = { turn_id: turn.id, mode, provider_type, protocol }; + const started: TurnStartedTelemetryEvent = { turn_id: turn.id, agent_id, mode, provider_type, protocol }; turnTelemetry.track2('turn_started', started); result = await this.run({ turnId: turn.id, @@ -398,6 +398,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (result.type !== 'completed') { const interrupted: TurnInterruptedEvent = { turn_id: turn.id, + agent_id, at_step: result.steps, mode, interrupt_reason: interruptReasonFor(result), @@ -409,6 +410,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } const ended: TurnEndedTelemetryEvent = { turn_id: turn.id, + agent_id, reason: result?.type ?? 'failed', duration_ms: Date.now() - startedAt, mode, diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index b5a4ff1303..08fed3cfb4 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -16,6 +16,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { canonicalTelemetryArgs } from '#/_base/utils/canonical-args'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolExecutorService, type ToolCallDupType } from '#/agent/toolExecutor/toolExecutor'; import type { ContentPart } from '#/app/llmProtocol/message'; import { IAgentToolDedupeService, type ToolDedupeResult } from './toolDedupe'; @@ -119,6 +120,7 @@ export class AgentToolDedupeService extends Disposable implements IAgentToolDedu @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentLoopService loop: IAgentLoopService, @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { super(); loop.hooks.onWillBeginStep.register('toolDedupe', async (ctx, next) => { @@ -215,6 +217,7 @@ export class AgentToolDedupeService extends Disposable implements IAgentToolDedu this.toolExecutor.recordDupType(toolCallId, dupType); this.telemetry.track2('tool_call_dedup_detected', { turn_id: this.activeTurnId ?? 0, + agent_id: this.scopeContext.agentId, step_no: this.activeStep, tool_call_id: toolCallId, tool_name: toolName, diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 246509c3cc..75fb5be634 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -39,6 +39,7 @@ import { } from '#/tool/toolContract'; import type { ToolDidExecuteContext, ToolBeforeExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { ToolCall } from '#/app/llmProtocol/message'; import { ILogService } from '#/_base/log/log'; import type { ToolCallEvent } from '#/app/telemetry/events'; @@ -135,6 +136,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentToolResultTruncationService private readonly resultTruncation: IAgentToolResultTruncationService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ILogService private readonly log?: ILogService, ) {} @@ -275,6 +277,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { this.toolCallDupTypes.delete(toolCallId); const properties: ToolCallEvent = { turn_id: turnId, + agent_id: this.scopeContext.agentId, tool_call_id: toolCallId, tool_name: call.toolName, outcome, diff --git a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts index 0b5b16ebaf..5fc23dd8eb 100644 --- a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts +++ b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts @@ -13,6 +13,7 @@ import { createDecorator } from '#/_base/di/instantiation'; export type AgentTelemetryContext = { mode: 'agent' | 'plan'; + agent_id: string; provider_type?: string; protocol?: string; }; diff --git a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts index 5b84fff18e..3d62017b79 100644 --- a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts +++ b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts @@ -8,6 +8,7 @@ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentTelemetryContextService, type AgentTelemetryContext, @@ -15,7 +16,11 @@ import { export class AgentTelemetryContextService implements IAgentTelemetryContextService { declare readonly _serviceBrand: undefined; - private context: AgentTelemetryContext = { mode: 'agent' }; + private context: AgentTelemetryContext; + + constructor(@IAgentScopeContext scopeContext: IAgentScopeContext) { + this.context = { mode: 'agent', agent_id: scopeContext.agentId }; + } get(): AgentTelemetryContext { return this.context; diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 4952eb51c8..d9de328e00 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -44,6 +44,7 @@ export type StrictPropertyCheck = string extends keyof T export interface TurnStartedEvent { turn_id: number; + agent_id: string; mode: 'agent' | 'plan'; provider_type?: string; protocol?: string; @@ -51,6 +52,7 @@ export interface TurnStartedEvent { export interface TurnInterruptedEvent { turn_id: number; + agent_id: string; at_step: number; mode: 'agent' | 'plan'; interrupt_reason: 'user_cancelled' | 'aborted' | 'max_steps' | 'error' | 'filtered' | 'blocked'; @@ -60,6 +62,7 @@ export interface TurnInterruptedEvent { export interface TurnEndedEvent { turn_id: number; + agent_id: string; reason: 'completed' | 'cancelled' | 'failed'; duration_ms: number; mode: 'agent' | 'plan'; @@ -71,6 +74,7 @@ export type ToolCallOutcome = 'success' | 'error' | 'cancelled'; export interface ToolCallEvent { turn_id: number; + agent_id: string; tool_call_id: string; tool_name: string; outcome: ToolCallOutcome; @@ -81,6 +85,7 @@ export interface ToolCallEvent { export interface ApiErrorEvent { error_type: string; + agent_id: string; model: string; alias?: string; retryable: boolean; @@ -260,6 +265,7 @@ export interface GoalStatusChangedEvent extends GoalBudgetProperties { export interface ToolCallDedupDetectedEvent { turn_id: number; + agent_id: string; step_no: number; tool_call_id: string; tool_name: string; @@ -290,6 +296,8 @@ export interface FsGrepNodeFallbackEvent { export interface SubagentCreatedEvent { subagent_name: string; run_in_background: boolean; + agent_id: string; + parent_agent_id: string; } export interface McpConnectedEvent { @@ -391,7 +399,8 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A turn starts running.', properties: { - turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', + turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', + agent_id: 'Agent id (main or subagent scope id)', mode: 'Agent mode the turn runs in', provider_type: 'Provider protocol type', protocol: 'Request protocol', @@ -401,7 +410,8 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A running turn is interrupted.', properties: { - turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', + turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', + agent_id: 'Agent id (main or subagent scope id)', at_step: 'Step index the turn reached before interruption', mode: 'Agent mode the turn ran in', interrupt_reason: 'Why the turn was interrupted', @@ -413,7 +423,8 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A turn ends, unconditionally.', properties: { - turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', + turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', + agent_id: 'Agent id (main or subagent scope id)', reason: 'How the turn ended', duration_ms: 'Turn wall-clock time in milliseconds', mode: 'Agent mode the turn ran in', @@ -425,7 +436,8 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A tool call finishes execution.', properties: { - turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', + turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', + agent_id: 'Agent id (main or subagent scope id)', tool_call_id: 'Provider-assigned tool call id', tool_name: 'Registered tool name', outcome: 'Execution outcome', @@ -439,6 +451,7 @@ export const telemetryEventDefinitions = { comment: 'An LLM API request fails.', properties: { error_type: 'Classified error category', + agent_id: 'Agent id (main or subagent scope id)', model: 'Model id the request targeted', alias: 'Model alias the request targeted', retryable: 'Whether the error is retryable', @@ -663,7 +676,8 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A duplicate tool call is detected.', properties: { - turn_id: 'Per-agent turn index (main or subagent); not unique across agents in the same session', + turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', + agent_id: 'Agent id (main or subagent scope id)', step_no: 'Step index within the turn', tool_call_id: 'Provider-assigned tool call id', tool_name: 'Registered tool name', @@ -707,6 +721,8 @@ export const telemetryEventDefinitions = { properties: { subagent_name: 'Profile name of the subagent', run_in_background: 'Whether the subagent runs in the background', + agent_id: 'Child agent id', + parent_agent_id: 'Parent (caller) agent id', }, }), mcp_connected: defineTelemetryEvent({ diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts index 678eea9f43..fee419bfbb 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts @@ -84,6 +84,8 @@ export function emitAgentRunSpawned( requester.accessor.get(ITelemetryService)?.track2('subagent_created', { subagent_name: meta.profileName, run_in_background: meta.runInBackground ?? false, + agent_id: targetAgentId, + parent_agent_id: requester.id, }); } diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts index 348e57ca98..f7080eb406 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts @@ -363,6 +363,7 @@ describe('LLMRequester service migration coverage', () => { event: 'api_error', properties: expect.objectContaining({ error_type: 'rate_limit', + agent_id: 'main', model: 'mock-model', alias: 'mock-model', provider_type: 'kimi', diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index 8a21a7f4f7..c647a76223 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -23,6 +23,7 @@ import { AgentLLMRequesterService } from '#/agent/llmRequester/llmRequesterServi import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; import { IAgentContextSizeService } from '#/agent/contextSize/contextSize'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentUsageService } from '#/agent/usage/usage'; @@ -154,6 +155,7 @@ function createService( ix.stub(IConfigService, config); ix.stub(ILogService, log); ix.stub(ITelemetryService, telemetry); + ix.stub(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); ix.set( IAgentWireService, new SyncDescriptor(WireService, [{ logScope: 'wire', logKey: 'strict-resend' }]), diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 77ee6a4f12..2c7cca3c77 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -598,7 +598,13 @@ describe('turn telemetry', () => { expect(records).toContainEqual({ event: 'turn_started', - properties: { turn_id: 0, mode: 'agent', provider_type: 'kimi', protocol: 'kimi' }, + properties: { + turn_id: 0, + agent_id: 'main', + mode: 'agent', + provider_type: 'kimi', + protocol: 'kimi', + }, }); expect(records).toContainEqual({ event: 'turn_ended', diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index d35f14eb01..1b42fa84f5 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -16,6 +16,7 @@ import { IModelResolver } from '#/app/model/modelResolver'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { AgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContextService'; +import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; @@ -94,7 +95,10 @@ function buildHost(key: string): { host.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); host.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }])); host.stub(ITelemetryService, createTelemetryStub()); - host.stub(IAgentTelemetryContextService, new AgentTelemetryContextService()); + host.stub( + IAgentTelemetryContextService, + new AgentTelemetryContextService(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + ); host.stub(IConfigService, createConfigStub()); host.stub(IModelResolver, modelResolver); host.stub(IHostEnvironment, stubUnused()); diff --git a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts index ed7c394b19..10548659c0 100644 --- a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts +++ b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts @@ -641,6 +641,7 @@ describe('AgentToolDedupeService', () => { event: 'tool_call_dedup_detected', properties: { turn_id: 7, + agent_id: 'main', step_no: 1, tool_call_id: 'c2', tool_name: 'Read', @@ -672,6 +673,7 @@ describe('AgentToolDedupeService', () => { event: 'tool_call_dedup_detected', properties: { turn_id: 7, + agent_id: 'main', step_no: 2, tool_call_id: 'c2', tool_name: 'Read', diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index 8e5e56a658..e057e93183 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -17,6 +17,7 @@ import { import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { AgentToolExecutorService, parseToolCallArguments } from '#/agent/toolExecutor/toolExecutorService'; import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation'; +import { makeAgentScopeContext, IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; @@ -50,6 +51,7 @@ beforeEach(() => { additionalServices: (reg) => { reg.define(IAgentToolRegistryService, AgentToolRegistryService); reg.define(IAgentToolExecutorService, AgentToolExecutorService); + reg.defineInstance(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); reg.defineInstance(IAgentWireRecordService, stubWireRecord()); reg.defineInstance( IAgentWireService, @@ -106,6 +108,7 @@ describe('AgentToolExecutorService', () => { event: 'tool_call', properties: expect.objectContaining({ turn_id: 0, + agent_id: 'main', tool_call_id: 'call_echo', tool_name: 'echo', outcome: 'success', diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index 068eed0f30..d12bb4b669 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -33,6 +33,7 @@ import { } from '#/agent/loop/loop'; import type { StepRequest } from '#/agent/loop/stepRequest'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import type { ExecutableTool, ToolExecution } from '#/tool/toolContract'; @@ -343,6 +344,7 @@ function createExecutorHarness(): ExecutorHarness { additionalServices: (reg) => { registerSharedServices(reg, contextMemory, loop, eventBus); reg.defineInstance(ITelemetryService, recordingTelemetry([])); + reg.defineInstance(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); reg.define(IAgentToolExecutorService, AgentToolExecutorService); registerToolResultTruncationServices(reg); }, diff --git a/packages/agent-core-v2/test/app/telemetry/agentTelemetryContext.test.ts b/packages/agent-core-v2/test/app/telemetry/agentTelemetryContext.test.ts index 8a4ad4c4e0..94a3faeb5d 100644 --- a/packages/agent-core-v2/test/app/telemetry/agentTelemetryContext.test.ts +++ b/packages/agent-core-v2/test/app/telemetry/agentTelemetryContext.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; +import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { AgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContextService'; import { recordingTelemetry, type TelemetryRecord } from './stubs'; @@ -11,23 +12,30 @@ describe('AgentTelemetryContextService', () => { it('defaults to agent mode and merges into telemetry through withContext', () => { const records: TelemetryRecord[] = []; const telemetry = recordingTelemetry(records); - const ctx = new AgentTelemetryContextService(); + const ctx = new AgentTelemetryContextService( + makeAgentScopeContext({ agentId: 'main', agentScope: '' }), + ); telemetry.withContext(ctx.get()).track('turn_started'); - expect(records).toContainEqual({ event: 'turn_started', properties: { mode: 'agent' } }); + expect(records).toContainEqual({ + event: 'turn_started', + properties: { mode: 'agent', agent_id: 'main' }, + }); ctx.set({ mode: 'plan' }); telemetry.withContext(ctx.get()).track('turn_interrupted', { at_step: 2 }); expect(records).toContainEqual({ event: 'turn_interrupted', - properties: { mode: 'plan', at_step: 2 }, + properties: { mode: 'plan', agent_id: 'main', at_step: 2 }, }); }); it('snapshots the context at withContext time', () => { const records: TelemetryRecord[] = []; const telemetry = recordingTelemetry(records); - const ctx = new AgentTelemetryContextService(); + const ctx = new AgentTelemetryContextService( + makeAgentScopeContext({ agentId: 'main', agentScope: '' }), + ); ctx.set({ mode: 'plan' }); const fork = telemetry.withContext(ctx.get()); @@ -36,7 +44,7 @@ describe('AgentTelemetryContextService', () => { fork.track('turn_interrupted', { at_step: 1 }); expect(records).toContainEqual({ event: 'turn_interrupted', - properties: { mode: 'plan', at_step: 1 }, + properties: { mode: 'plan', agent_id: 'main', at_step: 1 }, }); }); }); diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index f8f40de5f2..6bc2f1456e 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -47,7 +47,7 @@ import { type SkillRoot, type SkillSummary, } from '../skill'; -import { noopTelemetryClient, type TelemetryClient } from '../telemetry'; +import { noopTelemetryClient, type TelemetryClient, withTelemetryProperties } from '../telemetry'; import { SessionSubagentHost } from './subagent-host'; import { sessionMediaOriginalsDir } from '../tools/support/image-originals'; import type { ToolServices } from '../tools/support/services'; @@ -904,7 +904,7 @@ export class Session { subagentHost: config.subagentHost ?? new SessionSubagentHost(this, id), mcp: this.mcp, permission: this.permissionOptions(parentAgentId, config.permission), - telemetry: this.telemetry, + telemetry: withTelemetryProperties(this.telemetry, { agent_id: id }), log: this.log.createChild({ agentId: id }), pluginSessionStarts: type === 'main' ? this.options.pluginSessionStarts : undefined, pluginCommands: type === 'main' ? this.options.pluginCommands : undefined, diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index d6c4b6369d..2889859f0e 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -473,6 +473,8 @@ export class SessionSubagentHost { runInBackground: options.runInBackground, }); parent.telemetry.track('subagent_created', { + agent_id: childId, + parent_agent_id: this.ownerAgentId, subagent_name: profileName, run_in_background: options.runInBackground, }); diff --git a/packages/agent-core/test/harness/model-alias-session.test.ts b/packages/agent-core/test/harness/model-alias-session.test.ts index 6d9b66388a..38e233146e 100644 --- a/packages/agent-core/test/harness/model-alias-session.test.ts +++ b/packages/agent-core/test/harness/model-alias-session.test.ts @@ -343,7 +343,7 @@ max_context_size = 1000000 expect(createRecords).toContainEqual({ event: 'yolo_toggle', sessionId: created.id, - properties: { enabled: true }, + properties: { enabled: true, agent_id: 'main' }, }); await createRpc.setPermission({ sessionId: created.id, agentId: 'main', mode: 'auto' }); @@ -351,7 +351,7 @@ max_context_size = 1000000 expect(createRecords).toContainEqual({ event: 'afk_toggle', sessionId: created.id, - properties: { enabled: true }, + properties: { enabled: true, agent_id: 'main' }, }); await createRpc.setKimiConfig({ @@ -380,7 +380,7 @@ max_context_size = 1000000 expect(createRecords).toContainEqual({ event: 'model_switch', sessionId: created.id, - properties: { model: 'gpt-alias' }, + properties: { model: 'gpt-alias', agent_id: 'main' }, }); const resumeRecords: TelemetryContextRecord[] = []; @@ -391,7 +391,7 @@ max_context_size = 1000000 expect(resumeRecords).toContainEqual({ event: 'thinking_toggle', sessionId: created.id, - properties: { enabled: false, effort: 'off', from: 'high' }, + properties: { enabled: false, effort: 'off', from: 'high', agent_id: 'main' }, }); }); @@ -447,6 +447,7 @@ max_context_size = 1000000 client_version: '0.1.1', ui_mode: 'web', enabled: true, + agent_id: 'main', }, }); }); diff --git a/packages/agent-core/test/harness/skill-session.test.ts b/packages/agent-core/test/harness/skill-session.test.ts index 223fe64dcc..987380961c 100644 --- a/packages/agent-core/test/harness/skill-session.test.ts +++ b/packages/agent-core/test/harness/skill-session.test.ts @@ -186,6 +186,7 @@ describe('HarnessAPI session skills', () => { properties: { skill_name: 'phase-one-review', trigger: 'user-slash', + agent_id: 'main', }, }); expect(telemetryRecords.some((record) => record.event === 'flow_invoked')).toBe(false); @@ -404,6 +405,7 @@ describe('HarnessAPI session skills', () => { properties: { skill_name: 'review-flow', trigger: 'user-slash', + agent_id: 'main', }, }); expect(telemetryRecords).toContainEqual({ @@ -411,6 +413,7 @@ describe('HarnessAPI session skills', () => { sessionId: created.id, properties: { flow_name: 'review-flow', + agent_id: 'main', }, }); }); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index e611e1b821..da7bb33075 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -327,6 +327,8 @@ describe('SessionSubagentHost', () => { expect(telemetryTrack).toHaveBeenCalledWith('subagent_created', { subagent_name: 'explore', run_in_background: false, + agent_id: 'agent-0', + parent_agent_id: 'main', }); expect(parent.allEvents).toContainEqual( expect.objectContaining({ From 3ac82d97b689b8691786f6864b478486bcc994c4 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 14 Jul 2026 14:11:20 +0800 Subject: [PATCH 06/13] docs(changeset): cover turn_id emission alongside agent_id --- .changeset/telemetry-agent-id.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/telemetry-agent-id.md b/.changeset/telemetry-agent-id.md index dcae982579..04ef4b54c8 100644 --- a/.changeset/telemetry-agent-id.md +++ b/.changeset/telemetry-agent-id.md @@ -4,4 +4,4 @@ "@moonshot-ai/kimi-code": patch --- -Add an `agent_id` field to turn and tool telemetry events so activity from the main agent and subagents can be told apart within a session. +Emit `turn_id` and `agent_id` on turn and tool telemetry events so activity can be attributed to the main agent or a specific subagent within a session. From 8dedbbe245b30290935bb9ba069e28b6f84673dd Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 14 Jul 2026 15:27:49 +0800 Subject: [PATCH 07/13] feat(telemetry): add agent_id to agent-level settings events --- .changeset/telemetry-agent-id.md | 2 +- .../src/agent/profile/profileService.ts | 7 +++++-- .../agent-core-v2/src/agent/rpc/rpcService.ts | 6 ++++-- .../src/agent/skill/skillService.ts | 4 ++++ .../agent-core-v2/src/app/telemetry/events.ts | 16 ++++++++++++---- .../test/agent/profile/config-state.test.ts | 2 +- .../test/agent/profile/profileOps.test.ts | 3 ++- .../agent-core-v2/test/agent/skill/skill.test.ts | 3 +++ 8 files changed, 32 insertions(+), 11 deletions(-) diff --git a/.changeset/telemetry-agent-id.md b/.changeset/telemetry-agent-id.md index 04ef4b54c8..86e9793d09 100644 --- a/.changeset/telemetry-agent-id.md +++ b/.changeset/telemetry-agent-id.md @@ -4,4 +4,4 @@ "@moonshot-ai/kimi-code": patch --- -Emit `turn_id` and `agent_id` on turn and tool telemetry events so activity can be attributed to the main agent or a specific subagent within a session. +Emit `turn_id` and `agent_id` on turn, tool, and agent-level settings telemetry events (model/thinking/skill/permission toggles) so activity can be attributed to the main agent or a specific subagent within a session. diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 2573f0b70e..4c3f77c1d6 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -46,6 +46,7 @@ import { isMcpToolName, type ToolSource } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { WarningEvent } from '@moonshot-ai/protocol'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -114,6 +115,7 @@ export class AgentProfileService implements IAgentProfileService { @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IAgentProfileCatalogService private readonly catalog: IAgentProfileCatalogService, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { this.configure({}); } @@ -177,10 +179,10 @@ export class AgentProfileService implements IAgentProfileService { const model = this.modelFactory.resolve(alias); if (this.profileName === undefined) { await this.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: alias }); - this.telemetry.track2('model_switch', { model: alias }); + this.telemetry.track2('model_switch', { agent_id: this.scopeContext.agentId, model: alias }); } else if (this.modelAlias !== alias) { this.update({ modelAlias: alias }); - this.telemetry.track2('model_switch', { model: alias }); + this.telemetry.track2('model_switch', { agent_id: this.scopeContext.agentId, model: alias }); } return { model: alias, @@ -194,6 +196,7 @@ export class AgentProfileService implements IAgentProfileService { const effort = this.thinkingLevel; if (effort !== previousEffort) { this.telemetry.track2('thinking_toggle', { + agent_id: this.scopeContext.agentId, enabled: effort !== 'off', effort, from: previousEffort, diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index 92266fd50c..a95f907f1d 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -24,6 +24,7 @@ import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionBtwService } from '#/session/btw/btw'; import { IAgentSkillService } from '#/agent/skill/skill'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; @@ -100,6 +101,7 @@ export class AgentRPCService implements IAgentRPCService { @ISessionMetadata private readonly metadata: ISessionMetadata, @ISessionContext private readonly sessionContext: ISessionContext, @ISessionBtwService private readonly btw: ISessionBtwService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { } async prompt(payload: PromptPayload): Promise { @@ -158,11 +160,11 @@ export class AgentRPCService implements IAgentRPCService { this.permissionMode.setMode(payload.mode); const enabled = this.permissionMode.mode === 'yolo'; if (enabled !== wasYolo) { - this.telemetry.track2('yolo_toggle', { enabled }); + this.telemetry.track2('yolo_toggle', { agent_id: this.scopeContext.agentId, enabled }); } const afkEnabled = this.permissionMode.mode === 'auto'; if (afkEnabled !== wasAuto) { - this.telemetry.track2('afk_toggle', { enabled: afkEnabled }); + this.telemetry.track2('afk_toggle', { agent_id: this.scopeContext.agentId, enabled: afkEnabled }); } } diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index 22fecbf690..0cc564e4df 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -24,6 +24,7 @@ import { Disposable } from '#/_base/di/lifecycle'; import { ErrorCodes, Error2 } from '#/errors'; import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCatalog/types'; import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { Turn } from '#/agent/loop/loop'; import { IAgentWireService } from '#/wire/tokens'; @@ -41,6 +42,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService @IAgentWireService private readonly wire: IWireService, @ITelemetryService private readonly telemetry: ITelemetryService, @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { super(); } @@ -124,11 +126,13 @@ export class AgentSkillService extends Disposable implements IAgentSkillService private publishActivation(origin: SkillActivationOrigin): void { this.telemetry.track2('skill_invoked', { + agent_id: this.scopeContext.agentId, skill_name: origin.skillName, trigger: origin.trigger, }); if (origin.skillType === 'flow') { this.telemetry.track2('flow_invoked', { + agent_id: this.scopeContext.agentId, flow_name: origin.skillName, }); } diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index d9de328e00..2ecf3d246c 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -97,11 +97,13 @@ export interface ApiErrorEvent { } export interface SkillInvokedEvent { + agent_id: string; skill_name: string; trigger: 'user-slash' | 'model-tool' | 'nested-skill'; } export interface FlowInvokedEvent { + agent_id: string; flow_name: string; } @@ -118,10 +120,12 @@ export interface ConversationUndoEvent { } export interface YoloToggleEvent { + agent_id: string; enabled: boolean; } export interface AfkToggleEvent { + agent_id: string; enabled: boolean; } @@ -214,10 +218,12 @@ export interface BackgroundTaskCompletedEvent { } export interface ModelSwitchEvent { + agent_id: string; model: string; } export interface ThinkingToggleEvent { + agent_id: string; enabled: boolean; effort: string; from: string; @@ -466,6 +472,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A skill is invoked.', properties: { + agent_id: 'Agent id (main or subagent scope id)', skill_name: 'Skill name', trigger: 'How the skill was triggered', }, @@ -473,7 +480,7 @@ export const telemetryEventDefinitions = { flow_invoked: defineTelemetryEvent({ owner: 'kimi-code', comment: 'A flow-type skill is invoked.', - properties: { flow_name: 'Flow name' }, + properties: { agent_id: 'Agent id (main or subagent scope id)', flow_name: 'Flow name' }, }), input_steer: defineTelemetryEvent({ owner: 'kimi-code', @@ -493,12 +500,12 @@ export const telemetryEventDefinitions = { yolo_toggle: defineTelemetryEvent({ owner: 'kimi-code', comment: 'Yolo permission mode is toggled.', - properties: { enabled: 'Whether yolo mode is now enabled' }, + properties: { agent_id: 'Agent id (main or subagent scope id)', enabled: 'Whether yolo mode is now enabled' }, }), afk_toggle: defineTelemetryEvent({ owner: 'kimi-code', comment: 'AFK (auto) permission mode is toggled.', - properties: { enabled: 'Whether auto mode is now enabled' }, + properties: { agent_id: 'Agent id (main or subagent scope id)', enabled: 'Whether auto mode is now enabled' }, }), permission_policy_decision: defineTelemetryEvent({ owner: 'kimi-code', @@ -606,12 +613,13 @@ export const telemetryEventDefinitions = { model_switch: defineTelemetryEvent({ owner: 'kimi-code', comment: 'The active model is bound or switched.', - properties: { model: 'Model alias' }, + properties: { agent_id: 'Agent id (main or subagent scope id)', model: 'Model alias' }, }), thinking_toggle: defineTelemetryEvent({ owner: 'kimi-code', comment: 'Thinking effort is toggled.', properties: { + agent_id: 'Agent id (main or subagent scope id)', enabled: 'Whether thinking is now enabled', effort: 'New thinking effort level', from: 'Previous thinking effort level', diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 08b79944fe..b7ddaae600 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -109,7 +109,7 @@ describe('ConfigState model capabilities', () => { expect(records).toContainEqual({ event: 'thinking_toggle', - properties: { enabled: true, effort: 'low', from: 'off' }, + properties: { agent_id: 'main', enabled: true, effort: 'low', from: 'off' }, }); }); diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index 1b42fa84f5..142e54e8ca 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -16,7 +16,7 @@ import { IModelResolver } from '#/app/model/modelResolver'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { AgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContextService'; -import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; @@ -95,6 +95,7 @@ function buildHost(key: string): { host.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); host.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }])); host.stub(ITelemetryService, createTelemetryStub()); + host.stub(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); host.stub( IAgentTelemetryContextService, new AgentTelemetryContextService(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), diff --git a/packages/agent-core-v2/test/agent/skill/skill.test.ts b/packages/agent-core-v2/test/agent/skill/skill.test.ts index 18742ecd97..7d333d2625 100644 --- a/packages/agent-core-v2/test/agent/skill/skill.test.ts +++ b/packages/agent-core-v2/test/agent/skill/skill.test.ts @@ -6,6 +6,7 @@ import { createServices, type TestInstantiationService } from '#/_base/di/test'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { IAgentSkillService } from '#/agent/skill/skill'; +import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -81,6 +82,7 @@ describe('AgentSkillService', () => { register: () => ({ dispose: () => {} }), }); reg.defineInstance(ISessionContext, stubSessionContext()); + reg.defineInstance(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); }, }); skills = new InMemorySkillCatalog(); @@ -176,6 +178,7 @@ describe('SkillTool', () => { register: () => ({ dispose: () => {} }), }); reg.defineInstance(ISessionContext, stubSessionContext()); + reg.defineInstance(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); }, }); skills = new InMemorySkillCatalog(); From 90fb055e77c293c882af20d50fa4c6ef80081075 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 14 Jul 2026 16:32:02 +0800 Subject: [PATCH 08/13] feat(telemetry): link v2 events across agents, turns, and tool calls - subagent_created: parent_tool_call_id, so a child run joins to the tool call that launched it - permission_policy_decision / permission_approval_result: agent_id, turn_id, tool_call_id - plan_submitted / plan_resolved / plan_enter_resolved, context_projection_repaired: agent_id - compaction_finished / compaction_failed: agent_id + optional turn_id - cron_scheduled / cron_deleted: optional agent_id of the scheduling agent - api_error: turn_id + request_kind, so compaction request failures are distinguishable from turn requests - tool_call_repeat: agent_id + optional turn_id; tool_call_dedup_detected stops fabricating turn_id: 0 outside a turn - background_task_created/completed: task_id on both, unified kind vocabulary ('process' replaces the legacy 'bash' alias on created) - agent lifecycle: auto-assigned agent-N ids now skip ids persisted by previous runs, so a resumed session cannot reissue agent-0 and collide with earlier telemetry --- .changeset/telemetry-agent-id.md | 2 + .../contextProjectorService.ts | 3 + .../fullCompaction/fullCompactionService.ts | 20 +++++- .../agent/llmRequester/llmRequesterService.ts | 11 ++- .../permissionGate/permissionGateService.ts | 9 +++ .../policies/exit-plan-mode-review-ask.ts | 13 ++-- .../src/agent/plan/tools/enter-plan-mode.ts | 7 +- .../src/agent/plan/tools/exit-plan-mode.ts | 13 +++- .../src/agent/task/taskService.ts | 4 +- .../src/agent/toolDedupe/toolDedupeService.ts | 15 ++-- .../agent-core-v2/src/app/telemetry/events.ts | 72 ++++++++++++++++--- .../agentLifecycle/agentLifecycleService.ts | 16 ++++- .../session/agentLifecycle/mirrorAgentRun.ts | 1 + .../src/session/cron/sessionCronService.ts | 4 +- .../session/cron/sessionCronServiceImpl.ts | 14 ++-- .../src/session/cron/tools/cron-create.ts | 8 ++- .../src/session/cron/tools/cron-delete.ts | 8 ++- .../projector-tool-exchanges.test.ts | 6 ++ .../fullCompaction/fullCompaction.test.ts | 4 ++ .../agent/llmRequester/llmRequester.test.ts | 38 ++++++++++ .../permissionGate/permissionGate.test.ts | 9 +++ .../permissionPolicyService.test.ts | 13 ++++ .../exit-plan-mode-review-ask.test.ts | 22 ++++-- .../agent/plan/tools/exit-plan-mode.test.ts | 15 ++-- .../plan/tools/plan-tools-telemetry.test.ts | 49 ++++++++----- .../test/agent/task/rpc-events.test.ts | 5 +- .../test/agent/toolDedupe/toolDedupe.test.ts | 3 + .../agentLifecycle/agentLifecycle.test.ts | 30 ++++++++ .../test/session/cron/cron-tools.test.ts | 45 +++++++----- packages/agent-core-v2/test/tool/tool.test.ts | 20 +++++- 30 files changed, 396 insertions(+), 83 deletions(-) diff --git a/.changeset/telemetry-agent-id.md b/.changeset/telemetry-agent-id.md index 86e9793d09..24f2e45670 100644 --- a/.changeset/telemetry-agent-id.md +++ b/.changeset/telemetry-agent-id.md @@ -5,3 +5,5 @@ --- Emit `turn_id` and `agent_id` on turn, tool, and agent-level settings telemetry events (model/thinking/skill/permission toggles) so activity can be attributed to the main agent or a specific subagent within a session. + +Extend the same linkage across the rest of the v2 event surface: `parent_tool_call_id` on `subagent_created`; `agent_id`/`turn_id`/`tool_call_id` on permission decisions and approvals; `agent_id` on plan, compaction, context-projection repair, and cron schedule/delete events; `turn_id`/`request_kind` on `api_error` so compaction request failures are distinguishable from turn request failures; `agent_id`/`turn_id` on `tool_call_repeat` while `tool_call_dedup_detected` stops fabricating `turn_id: 0` outside a turn; and `task_id` on background task created/completed events with a unified `kind` vocabulary (`process` replaces the legacy `bash` alias on `background_task_created`). Auto-assigned subagent ids (`agent-N`) now skip ids persisted from previous runs, so a resumed session cannot reissue `agent-0` and collide with earlier telemetry. diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index afc9f6b1f2..c00c2756fc 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -25,6 +25,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ErrorCodes, Error2 } from '#/errors'; import type { ContentPart, Message } from '#/app/llmProtocol/message'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -37,6 +38,7 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi constructor( @ILogService private readonly log: ILogService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITelemetryService private readonly telemetry: ITelemetryService, ) {} @@ -119,6 +121,7 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi toolCallIds, }); this.telemetry.track2('context_projection_repaired', { + agent_id: this.scopeContext.agentId, reordered, synthesized, dropped_orphan: droppedOrphan, diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index aa7073aa46..80c08d18dd 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -20,6 +20,7 @@ import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry'; import { IAgentLoopService, type LoopErrorContext } from '#/agent/loop/loop'; import { isAbortError } from '#/_base/utils/abort'; import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { stripDynamicToolContext } from '#/agent/toolSelect/dynamicTools'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; @@ -36,7 +37,7 @@ import { createUserMessage, type Message } from '#/app/llmProtocol/message'; import type { Tool } from '#/app/llmProtocol/tool'; import { inputTotal, type TokenUsage } from '#/app/llmProtocol/usage'; import { IEventBus } from '#/app/event/eventBus'; -import type { CompactionFinishedEvent } from '#/app/telemetry/events'; +import type { CompactionFailedEvent, CompactionFinishedEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2, isCodedError, isError2, toKimiErrorPayload, unwrapErrorCause } from "#/errors"; import { IAgentWireService } from '#/wire/tokens'; @@ -111,6 +112,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull private readonly observedMaxContextTokensByModel = new Map(); private lastCompactedTokenCount: number | null = null; private consecutiveOverflowCompactions = 0; + private activeTurnId: number | undefined; private contextInjectorService: IAgentContextInjectorService | undefined; constructor( @@ -118,6 +120,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull @IAgentContextSizeService private readonly contextSize: IAgentContextSizeService, @IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService, @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService, @IInstantiationService private readonly instantiation: IInstantiationService, @@ -135,6 +138,11 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull this._register( this.eventBus.subscribe('turn.started', () => this.resetForTurn()), ); + this._register( + this.eventBus.subscribe('turn.ended', () => { + this.activeTurnId = undefined; + }), + ); this._register( this.loopService.hooks.onWillBeginStep.register('full-compaction', async (ctx, next) => { await this.beforeStep(ctx.signal, ctx.turnId); @@ -366,6 +374,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } private async beforeStep(signal: AbortSignal, turnId?: number): Promise { + this.activeTurnId = turnId; this.checkAutoCompaction(); if (this.strategy.shouldBlock(this.tokenCountWithPending())) { await this.block(signal, turnId); @@ -607,6 +616,8 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull }); const properties: CompactionFinishedEvent = { + agent_id: this.scopeContext.agentId, + turn_id: this.activeTurnId, source: data.source, tokens_before: result.tokensBefore, tokens_after: result.tokensAfter, @@ -622,7 +633,9 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull return result; } catch (error) { if (isAbortError(error)) throw error; - this.telemetry.track2('compaction_failed', { + const properties: CompactionFailedEvent = { + agent_id: this.scopeContext.agentId, + turn_id: this.activeTurnId, source: data.source, tokens_before: tokensBefore, duration_ms: Date.now() - startedAt, @@ -630,7 +643,8 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull retry_count: retryCount, thinking_effort: this.profile.data().thinkingLevel, error_type: error instanceof Error ? error.name : 'Unknown', - }); + }; + this.telemetry.track2('compaction_failed', properties); if ( isError2(error) && (error.code === ErrorCodes.AUTH_LOGIN_REQUIRED || diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index cc7f08614b..efafd0c1d2 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -155,7 +155,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { return await this.runRequest(this.resolveRequest(overrides), onPart, signal); } catch (error) { this.logRequestFailure(error, overrides, signal); - this.trackApiError(error, startedAt, signal); + this.trackApiError(error, startedAt, signal, overrides.source); throw error; } } @@ -178,6 +178,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { error: unknown, startedAt: number, signal: AbortSignal | undefined, + source: LLMRequestSource | undefined, ): void { if (isAbortError(error) || signal?.aborted === true) return; const modelAlias = this.profile.data().modelAlias; @@ -191,6 +192,8 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { protocol: model?.protocol, retryable: isRetryableGenerateError(error), duration_ms: Math.max(0, Date.now() - startedAt), + turn_id: source?.type === 'turn' ? source.turnId : undefined, + request_kind: requestKindForTelemetry(source), }; const statusCode = apiStatusCode(error); if (statusCode !== undefined) properties['status_code'] = statusCode; @@ -532,6 +535,12 @@ function logFieldsForSource(source: LLMRequestSource | undefined): LLMRequestLog } } +function requestKindForTelemetry(source: LLMRequestSource | undefined): string | undefined { + if (source?.type === 'turn') return 'turn'; + if (source?.type === 'operation') return source.requestKind ?? 'operation'; + return undefined; +} + function providerVisibleTools(tools: readonly Tool[]): readonly Tool[] { if (!tools.some((tool) => tool.deferred === true)) return tools; return tools.filter((tool) => tool.deferred !== true); diff --git a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts index 1d79437a56..4afd5ce24d 100644 --- a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts +++ b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts @@ -90,6 +90,9 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG const evaluation = await this.policyService.evaluate(context); if (evaluation === undefined) return undefined; this.telemetry.track2('permission_policy_decision', { + agent_id: this.scopeContext.agentId, + turn_id: context.turnId, + tool_call_id: context.toolCall.id, policy_name: evaluation.policyName, tool_name: context.toolCall.name, permission_mode: this.modeService.mode, @@ -173,6 +176,9 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG } catch (error) { if (isUserCancellation(error)) throw error; this.telemetry.track2('permission_approval_result', { + agent_id: this.scopeContext.agentId, + turn_id: context.turnId, + tool_call_id: context.toolCall.id, policy_name: policyName ?? null, tool_name: name, permission_mode: this.modeService.mode, @@ -216,6 +222,9 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG result: response, }); this.telemetry.track2('permission_approval_result', { + agent_id: this.scopeContext.agentId, + turn_id: context.turnId, + tool_call_id: context.toolCall.id, policy_name: policyName ?? null, tool_name: name, permission_mode: this.modeService.mode, diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts index 29c0b98e15..509998f047 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts @@ -1,6 +1,7 @@ import { IAgentPlanService, type IAgentPlanService as AgentPlanService } from '#/agent/plan/plan'; import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { PlanResolvedEvent, PlanSubmittedEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { @@ -27,6 +28,7 @@ export class ExitPlanModeReviewAskPermissionPolicyService implements PermissionP constructor( @IAgentPlanService private readonly plan: AgentPlanService, @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITelemetryService private readonly telemetry: ITelemetryService, ) {} @@ -165,16 +167,17 @@ export class ExitPlanModeReviewAskPermissionPolicyService implements PermissionP this.trackPlanTelemetry('plan_resolved', { outcome: 'rejected' }); } - private trackPlanTelemetry(event: 'plan_submitted', properties: PlanSubmittedEvent): void; - private trackPlanTelemetry(event: 'plan_resolved', properties: PlanResolvedEvent): void; + private trackPlanTelemetry(event: 'plan_submitted', properties: Omit): void; + private trackPlanTelemetry(event: 'plan_resolved', properties: Omit): void; private trackPlanTelemetry( event: 'plan_submitted' | 'plan_resolved', - properties: PlanSubmittedEvent | PlanResolvedEvent, + properties: Omit | Omit, ): void { + const withAgent = { ...properties, agent_id: this.scopeContext.agentId }; if (event === 'plan_submitted') { - this.telemetry.track2('plan_submitted', properties as PlanSubmittedEvent); + this.telemetry.track2('plan_submitted', withAgent as PlanSubmittedEvent); } else { - this.telemetry.track2('plan_resolved', properties as PlanResolvedEvent); + this.telemetry.track2('plan_resolved', withAgent as PlanResolvedEvent); } } } diff --git a/packages/agent-core-v2/src/agent/plan/tools/enter-plan-mode.ts b/packages/agent-core-v2/src/agent/plan/tools/enter-plan-mode.ts index 72f3a94472..b71a53c573 100644 --- a/packages/agent-core-v2/src/agent/plan/tools/enter-plan-mode.ts +++ b/packages/agent-core-v2/src/agent/plan/tools/enter-plan-mode.ts @@ -12,6 +12,7 @@ import { registerTool } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentPlanService } from '#/agent/plan/plan'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import DESCRIPTION from './enter-plan-mode.md?raw'; @@ -25,6 +26,7 @@ export class EnterPlanModeTool implements BuiltinTool { constructor( @IAgentPlanService private readonly planMode: IAgentPlanService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITelemetryService private readonly telemetry: ITelemetryService, ) {} @@ -48,7 +50,10 @@ export class EnterPlanModeTool implements BuiltinTool { return { isError: true, output: `Failed to enter plan mode: ${message}` }; } - this.telemetry.track2('plan_enter_resolved', { outcome: 'auto_approved' }); + this.telemetry.track2('plan_enter_resolved', { + agent_id: this.scopeContext.agentId, + outcome: 'auto_approved', + }); const after = await this.planMode.status(); return { output: enteredPlanModeMessage(after?.path ?? null) }; }, diff --git a/packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts b/packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts index a4484132b2..cfa241daac 100644 --- a/packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts +++ b/packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts @@ -25,6 +25,7 @@ import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentPlanService } from '#/agent/plan/plan'; import type { PlanData } from '#/agent/plan/plan'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import DESCRIPTION from './exit-plan-mode.md?raw'; @@ -90,6 +91,7 @@ export class ExitPlanModeTool implements BuiltinTool { constructor( @IAgentPlanService private readonly planMode: IAgentPlanService, @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITelemetryService private readonly telemetry: ITelemetryService, ) {} @@ -137,6 +139,7 @@ export class ExitPlanModeTool implements BuiltinTool { if (!resolvedPlan.ok) return resolvedPlan.error; this.telemetry.track2('plan_submitted', { + agent_id: this.scopeContext.agentId, has_options: args.options !== undefined && args.options.length >= 2, }); @@ -144,14 +147,20 @@ export class ExitPlanModeTool implements BuiltinTool { if (failed !== undefined) return failed; if (this.permissionMode.mode === 'auto') { - this.telemetry.track2('plan_resolved', { outcome: 'auto_approved' }); + this.telemetry.track2('plan_resolved', { + agent_id: this.scopeContext.agentId, + outcome: 'auto_approved', + }); return { isError: false, output: `Exited plan mode. ${formatAutoApprovedPlanForOutput(resolvedPlan.plan, resolvedPlan.path)}`, }; } - this.telemetry.track2('plan_resolved', { outcome: 'approved' }); + this.telemetry.track2('plan_resolved', { + agent_id: this.scopeContext.agentId, + outcome: 'approved', + }); return { isError: false, output: `Exited plan mode. ${formatPlanForOutput(resolvedPlan.plan, resolvedPlan.path)}`, diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 47e6a7eec9..862eb92b66 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -965,13 +965,15 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { private recordTaskStarted(info: AgentTaskInfo): void { this.wire.dispatch(taskStarted({ info })); this.telemetry.track2('background_task_created', { - kind: info.kind === 'process' ? 'bash' : info.kind, + task_id: info.taskId, + kind: info.kind, }); } private recordTaskTerminated(info: AgentTaskInfo): void { this.wire.dispatch(taskTerminated({ info })); this.telemetry.track2('background_task_completed', { + task_id: info.taskId, kind: info.kind, duration_ms: info.endedAt !== null ? info.endedAt - info.startedAt : null, status: info.status, diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 08fed3cfb4..d97d287b79 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -14,6 +14,7 @@ import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { canonicalTelemetryArgs } from '#/_base/utils/canonical-args'; +import type { ToolCallDedupDetectedEvent, ToolCallRepeatEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; @@ -215,15 +216,16 @@ export class AgentToolDedupeService extends Disposable implements IAgentToolDedu dupType: ToolCallDupType, ): void { this.toolExecutor.recordDupType(toolCallId, dupType); - this.telemetry.track2('tool_call_dedup_detected', { - turn_id: this.activeTurnId ?? 0, + const properties: ToolCallDedupDetectedEvent = { + turn_id: this.activeTurnId, agent_id: this.scopeContext.agentId, step_no: this.activeStep, tool_call_id: toolCallId, tool_name: toolName, dup_type: dupType, args_hash: argsHash(args), - }); + }; + this.telemetry.track2('tool_call_dedup_detected', properties); } private async finalizeResult( @@ -274,11 +276,14 @@ export class AgentToolDedupeService extends Disposable implements IAgentToolDedu } if (streak >= 2) { - this.telemetry.track2('tool_call_repeat', { + const properties: ToolCallRepeatEvent = { + agent_id: this.scopeContext.agentId, + turn_id: this.activeTurnId, tool_name: toolName, repeat_count: streak, action, - }); + }; + this.telemetry.track2('tool_call_repeat', properties); } this.stepDeferreds.get(key)?.resolve(finalResult); diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 2ecf3d246c..642e957c70 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -94,6 +94,8 @@ export interface ApiErrorEvent { provider_type?: string; protocol?: string; input_tokens?: number; + turn_id?: number; + request_kind?: string; } export interface SkillInvokedEvent { @@ -132,6 +134,9 @@ export interface AfkToggleEvent { export type TelemetryPermissionMode = 'manual' | 'yolo' | 'auto'; export interface PermissionPolicyDecisionEvent { + agent_id: string; + turn_id: number; + tool_call_id: string; policy_name: string; tool_name: string; permission_mode: TelemetryPermissionMode; @@ -140,6 +145,9 @@ export interface PermissionPolicyDecisionEvent { } export interface PermissionApprovalResultEvent { + agent_id: string; + turn_id: number; + tool_call_id: string; policy_name: string | null; tool_name: string; permission_mode: TelemetryPermissionMode; @@ -151,10 +159,12 @@ export interface PermissionApprovalResultEvent { } export interface PlanSubmittedEvent { + agent_id: string; has_options: boolean; } export interface PlanResolvedEvent { + agent_id: string; outcome: | 'approved' | 'dismissed' @@ -167,10 +177,13 @@ export interface PlanResolvedEvent { } export interface PlanEnterResolvedEvent { + agent_id: string; outcome: 'auto_approved'; } export interface CompactionFinishedEvent { + agent_id: string; + turn_id?: number; source: 'manual' | 'auto'; tokens_before: number; tokens_after: number; @@ -187,6 +200,8 @@ export interface CompactionFinishedEvent { } export interface CompactionFailedEvent { + agent_id: string; + turn_id?: number; source: 'manual' | 'auto'; tokens_before: number; duration_ms: number; @@ -197,6 +212,7 @@ export interface CompactionFailedEvent { } export interface ContextProjectionRepairedEvent { + agent_id: string; reordered: number; synthesized: number; dropped_orphan: number; @@ -208,10 +224,12 @@ export interface ContextProjectionRepairedEvent { } export interface BackgroundTaskCreatedEvent { - kind: 'bash' | 'agent' | 'question'; + task_id: string; + kind: 'agent' | 'process' | 'question'; } export interface BackgroundTaskCompletedEvent { + task_id: string; kind: 'agent' | 'process' | 'question'; duration_ms: number | null; status: 'running' | 'completed' | 'failed' | 'timed_out' | 'killed' | 'lost'; @@ -270,7 +288,7 @@ export interface GoalStatusChangedEvent extends GoalBudgetProperties { } export interface ToolCallDedupDetectedEvent { - turn_id: number; + turn_id?: number; agent_id: string; step_no: number; tool_call_id: string; @@ -280,6 +298,8 @@ export interface ToolCallDedupDetectedEvent { } export interface ToolCallRepeatEvent { + agent_id: string; + turn_id?: number; tool_name: string; repeat_count: number; action: 'none' | 'r1' | 'r2' | 'r3' | 'stop'; @@ -304,6 +324,7 @@ export interface SubagentCreatedEvent { run_in_background: boolean; agent_id: string; parent_agent_id: string; + parent_tool_call_id: string; } export interface McpConnectedEvent { @@ -322,10 +343,12 @@ export interface CronMissedEvent { export interface CronScheduledEvent { recurring: boolean; + agent_id?: string; } export interface CronDeletedEvent { task_id: string; + agent_id?: string; } export interface CronFiredEvent { @@ -466,6 +489,8 @@ export const telemetryEventDefinitions = { provider_type: 'Provider protocol type', protocol: 'Request protocol', input_tokens: "Current turn's accumulated total input tokens", + turn_id: 'Per-agent turn index when the request belongs to a turn; omitted for out-of-turn operations', + request_kind: "Request source vocabulary: 'turn' for turn requests, the operation's requestKind (e.g. 'full_compaction') otherwise", }, }), skill_invoked: defineTelemetryEvent({ @@ -511,6 +536,9 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A permission policy evaluates a tool call.', properties: { + agent_id: 'Agent id (main or subagent scope id)', + turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', + tool_call_id: 'Provider-assigned tool call id', policy_name: 'Name of the deciding policy', tool_name: 'Tool being gated', permission_mode: 'Active permission mode', @@ -521,6 +549,9 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A permission approval prompt resolves.', properties: { + agent_id: 'Agent id (main or subagent scope id)', + turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', + tool_call_id: 'Provider-assigned tool call id', policy_name: 'Name of the asking policy, null when unknown', tool_name: 'Tool being approved', permission_mode: 'Active permission mode', @@ -534,12 +565,16 @@ export const telemetryEventDefinitions = { plan_submitted: defineTelemetryEvent({ owner: 'kimi-code', comment: 'A plan is submitted for review.', - properties: { has_options: 'Whether the plan offered selectable options' }, + properties: { + agent_id: 'Agent id (main or subagent scope id)', + has_options: 'Whether the plan offered selectable options', + }, }), plan_resolved: defineTelemetryEvent({ owner: 'kimi-code', comment: 'A submitted plan is resolved.', properties: { + agent_id: 'Agent id (main or subagent scope id)', outcome: 'How the plan was resolved', chosen_option: 'Label of the option the user chose', has_feedback: 'Whether the user attached revision feedback', @@ -548,12 +583,17 @@ export const telemetryEventDefinitions = { plan_enter_resolved: defineTelemetryEvent({ owner: 'kimi-code', comment: 'A request to enter plan mode is resolved.', - properties: { outcome: 'How the request was resolved' }, + properties: { + agent_id: 'Agent id (main or subagent scope id)', + outcome: 'How the request was resolved', + }, }), compaction_finished: defineTelemetryEvent({ owner: 'kimi-code', comment: 'Context compaction completes.', properties: { + agent_id: 'Agent id (main or subagent scope id)', + turn_id: 'Per-agent turn index when compaction ran inside a turn; omitted for manual compaction between turns', source: 'Whether compaction was triggered manually or automatically', tokens_before: 'Token count before compaction', tokens_after: 'Token count after compaction', @@ -573,6 +613,8 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'Context compaction fails.', properties: { + agent_id: 'Agent id (main or subagent scope id)', + turn_id: 'Per-agent turn index when compaction ran inside a turn; omitted for manual compaction between turns', source: 'Whether compaction was triggered manually or automatically', tokens_before: 'Token count before compaction', duration_ms: 'Wall-clock time until failure in milliseconds', @@ -586,6 +628,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'The context projector repairs the outgoing request to keep it wire-valid.', properties: { + agent_id: 'Agent id (main or subagent scope id)', reordered: 'Tool results moved back next to their call', synthesized: 'Placeholder results invented for lost ones', dropped_orphan: 'Results with no matching call dropped', @@ -599,12 +642,16 @@ export const telemetryEventDefinitions = { background_task_created: defineTelemetryEvent({ owner: 'kimi-code', comment: 'A background task is created.', - properties: { kind: 'Task kind, process tasks reported as bash' }, + properties: { + task_id: 'Background task id; joins background_task_created with background_task_completed', + kind: 'Task kind', + }, }), background_task_completed: defineTelemetryEvent({ owner: 'kimi-code', comment: 'A background task reaches a terminal state.', properties: { + task_id: 'Background task id; joins background_task_created with background_task_completed', kind: 'Task kind', duration_ms: 'Task wall-clock time in milliseconds, null when unknown', status: 'Terminal task status', @@ -684,7 +731,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A duplicate tool call is detected.', properties: { - turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', + turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session; omitted when no turn is active', agent_id: 'Agent id (main or subagent scope id)', step_no: 'Step index within the turn', tool_call_id: 'Provider-assigned tool call id', @@ -697,6 +744,8 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A repeated tool call streak is detected.', properties: { + agent_id: 'Agent id (main or subagent scope id)', + turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session; omitted when no turn is active', tool_name: 'Registered tool name', repeat_count: 'Length of the repeat streak', action: 'Intervention action taken', @@ -731,6 +780,7 @@ export const telemetryEventDefinitions = { run_in_background: 'Whether the subagent runs in the background', agent_id: 'Child agent id', parent_agent_id: 'Parent (caller) agent id', + parent_tool_call_id: "Tool call id of the launching call in the parent agent; '' when not launched from a tool call", }, }), mcp_connected: defineTelemetryEvent({ @@ -757,12 +807,18 @@ export const telemetryEventDefinitions = { cron_scheduled: defineTelemetryEvent({ owner: 'kimi-code', comment: 'A cron task is scheduled.', - properties: { recurring: 'Whether the task repeats' }, + properties: { + recurring: 'Whether the task repeats', + agent_id: 'Agent that scheduled the task; omitted for session-level scheduling', + }, }), cron_deleted: defineTelemetryEvent({ owner: 'kimi-code', comment: 'A cron task is deleted.', - properties: { task_id: 'Cron task id' }, + properties: { + task_id: 'Cron task id', + agent_id: 'Agent that deleted the task; omitted for session-level deletion (e.g. stale auto-removal)', + }, }), cron_fired: defineTelemetryEvent({ owner: 'kimi-code', diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index febd1c63bb..0e35200fd1 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -175,7 +175,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle } async create(opts: CreateAgentOptions = {}): Promise { - const agentId = opts.agentId ?? `agent-${nextAgentId++}`; + const agentId = opts.agentId ?? (await this.nextAvailableAgentId()); const creating = this.creating.get(agentId); if (creating !== undefined) return creating; this.assertCanCreate(); @@ -221,6 +221,20 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle } } + private async nextAvailableAgentId(): Promise { + let maxSuffix = -1; + const consider = (id: string): void => { + const match = /^agent-(\d+)$/.exec(id); + if (match !== null) maxSuffix = Math.max(maxSuffix, Number(match[1])); + }; + for (const id of this.handles.keys()) consider(id); + const persisted = (await this.sessionMetadata.read()).agents ?? {}; + for (const id of Object.keys(persisted)) consider(id); + const candidate = Math.max(maxSuffix + 1, nextAgentId); + nextAgentId = candidate + 1; + return `agent-${String(candidate)}`; + } + private async bootstrapAgent( handle: IAgentScopeHandle, agentId: string, diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts index fee419bfbb..653889cf43 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts @@ -86,6 +86,7 @@ export function emitAgentRunSpawned( run_in_background: meta.runInBackground ?? false, agent_id: targetAgentId, parent_agent_id: requester.id, + parent_tool_call_id: meta.parentToolCallId ?? '', }); } diff --git a/packages/agent-core-v2/src/session/cron/sessionCronService.ts b/packages/agent-core-v2/src/session/cron/sessionCronService.ts index e2d4ddabdd..2cbcea5de5 100644 --- a/packages/agent-core-v2/src/session/cron/sessionCronService.ts +++ b/packages/agent-core-v2/src/session/cron/sessionCronService.ts @@ -46,8 +46,8 @@ export interface ISessionCronService { tasks: readonly CronTask[], renderMissedNotification: (tasks: readonly CronTask[]) => readonly ContentPart[], ): Turn | undefined; - emitScheduled(task: CronTask): void; - emitDeleted(taskId: string): void; + emitScheduled(task: CronTask, agentId?: string): void; + emitDeleted(taskId: string, agentId?: string): void; } export const ISessionCronService = createDecorator('sessionCronService'); diff --git a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts index 0a42d4d868..e88e745b9c 100644 --- a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts +++ b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts @@ -26,6 +26,7 @@ import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '# import { IntervalTimer } from '#/_base/utils/timer'; import { IConfigService } from '#/app/config/config'; +import type { CronDeletedEvent, CronScheduledEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { type ClockSources, resolveClockSources, SYSTEM_CLOCKS } from '#/app/cron/clock'; import { type CronConfig, CRON_SECTION } from '#/app/cron/configSection'; @@ -384,14 +385,17 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe return undefined; } - emitScheduled(task: CronTask): void { - this.telemetry.track2(CRON_SCHEDULED, { + emitScheduled(task: CronTask, agentId?: string): void { + const properties: CronScheduledEvent = { recurring: task.recurring !== false, - }); + agent_id: agentId, + }; + this.telemetry.track2(CRON_SCHEDULED, properties); } - emitDeleted(taskId: string): void { - this.telemetry.track2(CRON_DELETED, { task_id: taskId }); + emitDeleted(taskId: string, agentId?: string): void { + const properties: CronDeletedEvent = { task_id: taskId, agent_id: agentId }; + this.telemetry.track2(CRON_DELETED, properties); } diff --git a/packages/agent-core-v2/src/session/cron/tools/cron-create.ts b/packages/agent-core-v2/src/session/cron/tools/cron-create.ts index b4fb6b9985..d8595c7bae 100644 --- a/packages/agent-core-v2/src/session/cron/tools/cron-create.ts +++ b/packages/agent-core-v2/src/session/cron/tools/cron-create.ts @@ -28,6 +28,7 @@ import { z } from 'zod'; import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/tool/toolContract'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern } from '#/tool/rule-match'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ISessionCronService } from '#/session/cron/sessionCronService'; import { computeNextCronRun, cronToHuman, hasFireWithinYears, parseCronExpression, type ParsedCronExpression } from '#/app/cron/cron-expr'; import { formatLocalIsoWithOffset } from '#/app/cron/format'; @@ -80,7 +81,10 @@ export class CronCreateTool implements BuiltinTool { CronCreateInputSchema, ); - constructor(@ISessionCronService private readonly cron: ISessionCronService) {} + constructor( + @ISessionCronService private readonly cron: ISessionCronService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + ) {} resolveExecution(args: CronCreateInput): ToolExecution { if (this.cron.isDisabled()) { @@ -188,7 +192,7 @@ export class CronCreateTool implements BuiltinTool { const humanSchedule = cronToHuman(parsed); - this.cron.emitScheduled(task); + this.cron.emitScheduled(task, this.scopeContext.agentId); const output: CronCreateOutput = { id: task.id, diff --git a/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts b/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts index 7bc2e81271..e1d80e7657 100644 --- a/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts +++ b/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts @@ -39,6 +39,7 @@ import { z } from 'zod'; import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/tool/toolContract'; import { toInputJsonSchema } from '#/tool/input-schema'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ISessionCronService } from '#/session/cron/sessionCronService'; import CRON_DELETE_DESCRIPTION from './cron-delete.md?raw'; @@ -61,7 +62,10 @@ export class CronDeleteTool implements BuiltinTool { CronDeleteInputSchema, ); - constructor(@ISessionCronService private readonly cron: ISessionCronService) {} + constructor( + @ISessionCronService private readonly cron: ISessionCronService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + ) {} resolveExecution(args: CronDeleteInput): ToolExecution { if (!ID_PATTERN.test(args.id)) { @@ -85,7 +89,7 @@ export class CronDeleteTool implements BuiltinTool { }; } - this.cron.emitDeleted(args.id); + this.cron.emitDeleted(args.id, this.scopeContext.agentId); return { output: `Deleted cron job ${args.id}.`, diff --git a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index d09744167e..c14389e733 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts @@ -8,6 +8,7 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector'; import { AgentContextProjectorService } from '#/agent/contextProjector/contextProjectorService'; import { toProtocolMessage } from '#/agent/contextMemory/messageProjection'; +import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { Message } from '#/app/llmProtocol/message'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; @@ -104,6 +105,10 @@ describe('projector tool-exchange normalization', () => { const ix = disposables.add(new TestInstantiationService()); ix.set(ILogService, createCapturingLog(warnings)); ix.set(ITelemetryService, recordingTelemetry(telemetryRecords)); + ix.set( + IAgentScopeContext, + makeAgentScopeContext({ agentId: 'main', agentScope: '' }), + ); ix.set(IAgentContextProjectorService, new SyncDescriptor(AgentContextProjectorService)); projector = ix.get(IAgentContextProjectorService); }); @@ -495,6 +500,7 @@ describe('projector tool-exchange normalization', () => { { event: 'context_projection_repaired', properties: { + agent_id: 'main', reordered: 2, synthesized: 0, dropped_orphan: 0, diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index d8806041e4..54d1b52e67 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -298,6 +298,7 @@ describe('FullCompaction', () => { expect(records).toContainEqual({ event: 'compaction_finished', properties: expect.objectContaining({ + agent_id: 'main', source: 'manual', tokens_before: 39, tokens_after: expect.any(Number), @@ -992,6 +993,7 @@ describe('FullCompaction', () => { expect(records).toContainEqual({ event: 'compaction_failed', properties: expect.objectContaining({ + agent_id: 'main', source: 'manual', tokens_before: 25, duration_ms: expect.any(Number), @@ -2223,6 +2225,8 @@ describe('FullCompaction', () => { expect(records).toContainEqual({ event: 'compaction_finished', properties: expect.objectContaining({ + agent_id: 'main', + turn_id: expect.any(Number), source: 'auto', thinking_effort: 'high', }), diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts index f7080eb406..01fe5b9ee7 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts @@ -374,6 +374,44 @@ describe('LLMRequester service migration coverage', () => { }), }); }); + + it('tags api_error with turn_id and request_kind from the request source', async () => { + const records: TelemetryRecord[] = []; + ctx = createTestAgent( + llmGenerateServices(async () => { + throw new APIConnectionError('terminated'); + }), + telemetryServices(recordingTelemetry(records)), + ); + const llmRequester = ctx.get(IAgentLLMRequesterService); + + await expect( + llmRequester.request({ source: { type: 'turn', turnId: 3, step: 1 } }), + ).rejects.toMatchObject({ name: 'APIConnectionError' }); + await expect( + llmRequester.request({ source: { type: 'operation', requestKind: 'full_compaction' } }), + ).rejects.toMatchObject({ name: 'APIConnectionError' }); + + expect(records).toContainEqual({ + event: 'api_error', + properties: expect.objectContaining({ + error_type: 'network', + turn_id: 3, + request_kind: 'turn', + }), + }); + expect(records).toContainEqual({ + event: 'api_error', + properties: expect.objectContaining({ + error_type: 'network', + request_kind: 'full_compaction', + }), + }); + const operationRecord = records.find( + (record) => record.properties?.['request_kind'] === 'full_compaction', + ); + expect(operationRecord?.properties?.['turn_id']).toBeUndefined(); + }); }); describe('request timing and budget', () => { diff --git a/packages/agent-core-v2/test/agent/permissionGate/permissionGate.test.ts b/packages/agent-core-v2/test/agent/permissionGate/permissionGate.test.ts index 0cfb64d2f8..a2206d4856 100644 --- a/packages/agent-core-v2/test/agent/permissionGate/permissionGate.test.ts +++ b/packages/agent-core-v2/test/agent/permissionGate/permissionGate.test.ts @@ -318,6 +318,9 @@ describe('AgentPermissionGate', () => { expect(records).toContainEqual({ event: 'permission_policy_decision', properties: expect.objectContaining({ + agent_id: 'main', + turn_id: 1, + tool_call_id: 'call-AskUserQuestion', policy_name: 'auto-mode-ask-user-question-deny', tool_name: 'AskUserQuestion', decision: 'deny', @@ -460,6 +463,9 @@ describe('AgentPermissionGate', () => { expect(records).toContainEqual({ event: 'permission_approval_result', properties: expect.objectContaining({ + agent_id: 'main', + turn_id: 1, + tool_call_id: 'call-Bash', policy_name: 'fallback-ask', tool_name: 'Bash', permission_mode: 'manual', @@ -537,6 +543,9 @@ describe('AgentPermissionGate', () => { expect(records).toContainEqual({ event: 'permission_approval_result', properties: expect.objectContaining({ + agent_id: 'main', + turn_id: 1, + tool_call_id: 'call-ExitPlanMode', policy_name: 'exit-plan-mode-review-ask', tool_name: 'ExitPlanMode', result: 'error', diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index c5743a3a30..9df609a76f 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -28,6 +28,7 @@ import { type PermissionRule, } from '#/agent/permissionRules/permissionRules'; import { IAgentPlanService, type PlanData } from '#/agent/plan/plan'; +import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ToolAccesses, type ToolAccesses as ToolAccessList } from '#/tool/toolContract'; @@ -59,6 +60,10 @@ describe('AgentPermissionPolicyService chain', () => { ix = createServices(disposables, { additionalServices: (reg) => { reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); + reg.defineInstance( + IAgentScopeContext, + makeAgentScopeContext({ agentId: 'main', agentScope: '' }), + ); reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub({ rules: () => rules, sessionApprovalRulePatterns: () => sessionApprovalRulePatterns, @@ -228,6 +233,10 @@ describe('AgentPermissionPolicyService plan-mode policies', () => { ix = createServices(disposables, { additionalServices: (reg) => { reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); + reg.defineInstance( + IAgentScopeContext, + makeAgentScopeContext({ agentId: 'main', agentScope: '' }), + ); reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub({ sessionApprovalRulePatterns: () => sessionApprovalRulePatterns, })); @@ -422,6 +431,10 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { ix = createServices(disposables, { additionalServices: (reg) => { reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); + reg.defineInstance( + IAgentScopeContext, + makeAgentScopeContext({ agentId: 'main', agentScope: '' }), + ); reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub()); reg.defineInstance(ISessionWorkspaceContext, workspace); reg.defineInstance(IHostEnvironment, kaosStub()); diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/policies/exit-plan-mode-review-ask.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/policies/exit-plan-mode-review-ask.test.ts index a2196b5841..99da2d32b7 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/policies/exit-plan-mode-review-ask.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/policies/exit-plan-mode-review-ask.test.ts @@ -9,6 +9,7 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { ExitPlanModeReviewAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/exit-plan-mode-review-ask'; import { IAgentPlanService, type IAgentPlanService as AgentPlanService } from '#/agent/plan/plan'; +import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ToolAccesses } from '#/tool/toolContract'; @@ -99,6 +100,10 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { ix = createServices(disposables, { additionalServices: (reg) => { reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); + reg.defineInstance( + IAgentScopeContext, + makeAgentScopeContext({ agentId: 'main', agentScope: '' }), + ); reg.defineInstance(ITelemetryService, recordingTelemetry(records)); }, }); @@ -129,7 +134,7 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(result?.kind).toBe('ask'); expect(records).toContainEqual({ event: 'plan_submitted', - properties: { has_options: false }, + properties: { agent_id: 'main', has_options: false }, }); }); @@ -155,11 +160,12 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(exitPlanMode).toHaveBeenCalledTimes(1); expect(records).toContainEqual({ event: 'plan_submitted', - properties: { has_options: true }, + properties: { agent_id: 'main', has_options: true }, }); expect(records).toContainEqual({ event: 'plan_resolved', properties: { + agent_id: 'main', outcome: 'approved', chosen_option: 'Approach B', }, @@ -188,6 +194,7 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(records).toContainEqual({ event: 'plan_resolved', properties: { + agent_id: 'main', outcome: 'revise', has_feedback: true, }, @@ -211,7 +218,7 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(exitPlanMode).not.toHaveBeenCalled(); expect(records).toContainEqual({ event: 'plan_resolved', - properties: { outcome: 'rejected' }, + properties: { agent_id: 'main', outcome: 'rejected' }, }); }); @@ -232,7 +239,7 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(exitPlanMode).not.toHaveBeenCalled(); expect(records).toContainEqual({ event: 'plan_resolved', - properties: { outcome: 'dismissed' }, + properties: { agent_id: 'main', outcome: 'dismissed' }, }); }); @@ -256,7 +263,7 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(exitPlanMode).toHaveBeenCalledTimes(1); expect(records).toContainEqual({ event: 'plan_resolved', - properties: { outcome: 'rejected_and_exited' }, + properties: { agent_id: 'main', outcome: 'rejected_and_exited' }, }); }); @@ -297,6 +304,7 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(records).toContainEqual({ event: 'plan_resolved', properties: { + agent_id: 'main', outcome: 'approved', chosen_option: 'Approach C', }, @@ -315,11 +323,11 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { ); expect(records).toContainEqual({ event: 'plan_submitted', - properties: { has_options: false }, + properties: { agent_id: 'main', has_options: false }, }); expect(records).not.toContainEqual({ event: 'plan_resolved', - properties: { outcome: 'approved' }, + properties: { agent_id: 'main', outcome: 'approved' }, }); }); }); diff --git a/packages/agent-core-v2/test/agent/plan/tools/exit-plan-mode.test.ts b/packages/agent-core-v2/test/agent/plan/tools/exit-plan-mode.test.ts index e4183e5170..be3b4615d3 100644 --- a/packages/agent-core-v2/test/agent/plan/tools/exit-plan-mode.test.ts +++ b/packages/agent-core-v2/test/agent/plan/tools/exit-plan-mode.test.ts @@ -8,12 +8,15 @@ import { } from '#/agent/plan/tools/exit-plan-mode'; import type { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; +import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; import { executeTool } from '../../../tools/fixtures/execute-tool'; const signal = new AbortController().signal; +const scopeContext = makeAgentScopeContext({ agentId: 'main', agentScope: '' }); + const options = [ { label: 'Approach A', description: 'Small change.' }, { label: 'Approach B', description: 'Larger change.' }, @@ -144,6 +147,7 @@ describe('ExitPlanMode option output', () => { new ExitPlanModeTool( { ...planService(), exit }, permissionMode(), + scopeContext, telemetry, ), { @@ -163,7 +167,7 @@ describe('ExitPlanMode option output', () => { const telemetry = recordingTelemetry(); const result = await executeTool( - new ExitPlanModeTool(planService(), permissionMode('auto'), telemetry), + new ExitPlanModeTool(planService(), permissionMode('auto'), scopeContext, telemetry), { turnId: 7, toolCallId: 'call_exit_plan_auto', @@ -185,7 +189,7 @@ describe('ExitPlanMode option output', () => { const telemetry = recordingTelemetry(); const result = await executeTool( - new ExitPlanModeTool(planService(), permissionMode('manual'), telemetry), + new ExitPlanModeTool(planService(), permissionMode('manual'), scopeContext, telemetry), { turnId: 7, toolCallId: 'call_exit_plan_rule', @@ -200,14 +204,17 @@ describe('ExitPlanMode option output', () => { // so the output keeps the user-approved wording. expect(result.output).toContain('## Approved Plan:'); expect(result.output).not.toContain('auto-approved'); - expect(telemetry.track2).toHaveBeenCalledWith('plan_resolved', { outcome: 'approved' }); + expect(telemetry.track2).toHaveBeenCalledWith('plan_resolved', { + agent_id: 'main', + outcome: 'approved', + }); }); it('returns success without a "User feedback:" prefix when revise has no feedback', async () => { const telemetry = recordingTelemetry(); const result = await executeTool( - new ExitPlanModeTool(planService(), permissionMode(), telemetry), + new ExitPlanModeTool(planService(), permissionMode(), scopeContext, telemetry), { turnId: 7, toolCallId: 'call_exit_plan', diff --git a/packages/agent-core-v2/test/agent/plan/tools/plan-tools-telemetry.test.ts b/packages/agent-core-v2/test/agent/plan/tools/plan-tools-telemetry.test.ts index e22d0e9aad..d71fe03379 100644 --- a/packages/agent-core-v2/test/agent/plan/tools/plan-tools-telemetry.test.ts +++ b/packages/agent-core-v2/test/agent/plan/tools/plan-tools-telemetry.test.ts @@ -11,6 +11,7 @@ import type { IAgentPermissionModeService } from '#/agent/permissionMode/permiss import type { ToolResult } from '#/tool/toolContract'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { executeTool } from '../../../tools/fixtures/execute-tool'; import { createFakeHostFs } from '../../../tools/fixtures/fake-exec'; @@ -37,6 +38,8 @@ const options = [ { label: 'Approach B', description: 'Larger change.' }, ] satisfies NonNullable; +const scopeContext = makeAgentScopeContext({ agentId: 'main', agentScope: '' }); + function recordingTelemetry(): { readonly telemetry: ITelemetryService; readonly track2: ReturnType; @@ -91,7 +94,7 @@ function planService({ describe('EnterPlanModeTool telemetry', () => { it('has name, description, parameters, and a stable execution description', async () => { const { telemetry } = recordingTelemetry(); - const tool = new EnterPlanModeTool(planService({ status: null }), telemetry); + const tool = new EnterPlanModeTool(planService({ status: null }), scopeContext, telemetry); expect(tool.name).toBe('EnterPlanMode'); expect(tool.description).toContain('EnterPlanMode'); @@ -110,7 +113,7 @@ describe('EnterPlanModeTool telemetry', () => { it('returns an error when plan mode is already active', async () => { const { telemetry } = recordingTelemetry(); - const result = await executeTool(new EnterPlanModeTool(planService(), telemetry), { + const result = await executeTool(new EnterPlanModeTool(planService(), scopeContext, telemetry), { turnId: 0, toolCallId: 'call_enter_plan', args: {}, @@ -131,7 +134,7 @@ describe('EnterPlanModeTool telemetry', () => { vi.mocked(planMode.status).mockResolvedValue(null); const { telemetry } = recordingTelemetry(); - const result = await executeTool(new EnterPlanModeTool(planMode, telemetry), { + const result = await executeTool(new EnterPlanModeTool(planMode, scopeContext, telemetry), { turnId: 0, toolCallId: 'call_enter_plan', args: {}, @@ -154,7 +157,7 @@ describe('EnterPlanModeTool telemetry', () => { vi.mocked(planMode.status).mockImplementation(async () => (active ? ACTIVE_PLAN : null)); const { telemetry } = recordingTelemetry(); - const result = await executeTool(new EnterPlanModeTool(planMode, telemetry), { + const result = await executeTool(new EnterPlanModeTool(planMode, scopeContext, telemetry), { turnId: 0, toolCallId: 'call_enter_plan', args: {}, @@ -177,6 +180,7 @@ describe('EnterPlanModeTool telemetry', () => { throw new Error('cannot prepare plan directory'); }), }), + scopeContext, telemetry, ), { @@ -204,7 +208,7 @@ describe('EnterPlanModeTool telemetry', () => { vi.mocked(planMode.status).mockImplementation(async () => (active ? ACTIVE_PLAN : null)); const { telemetry, track2 } = recordingTelemetry(); - const result = await executeTool(new EnterPlanModeTool(planMode, telemetry), { + const result = await executeTool(new EnterPlanModeTool(planMode, scopeContext, telemetry), { turnId: 0, toolCallId: 'call_enter_plan', args: {}, @@ -213,6 +217,7 @@ describe('EnterPlanModeTool telemetry', () => { expect(result.isError).toBeFalsy(); expect(track2).toHaveBeenCalledWith('plan_enter_resolved', { + agent_id: 'main', outcome: 'auto_approved', }); }); @@ -271,7 +276,7 @@ describe('AgentPlanService EnterPlanMode telemetry', () => { ).toBe(false); expect(records).toContainEqual({ event: 'plan_enter_resolved', - properties: { outcome: 'auto_approved' }, + properties: { agent_id: 'main', outcome: 'auto_approved' }, }); }); }); @@ -281,7 +286,7 @@ describe('AgentPlanService EnterPlanMode telemetry', () => { describe('ExitPlanModeTool telemetry', () => { it('has name, description, parameters, and a stable execution description', async () => { const { telemetry } = recordingTelemetry(); - const tool = new ExitPlanModeTool(planService(), permissionMode(), telemetry); + const tool = new ExitPlanModeTool(planService(), permissionMode(), scopeContext, telemetry); expect(tool.name).toBe('ExitPlanMode'); expect(tool.description).toContain('ExitPlanMode'); @@ -303,7 +308,7 @@ describe('ExitPlanModeTool telemetry', () => { const { telemetry } = recordingTelemetry(); const result = await executeTool( - new ExitPlanModeTool(planService({ status: null }), permissionMode(), telemetry), + new ExitPlanModeTool(planService({ status: null }), permissionMode(), scopeContext, telemetry), { turnId: 7, toolCallId: 'call_exit_plan', @@ -327,7 +332,7 @@ describe('ExitPlanModeTool telemetry', () => { } as unknown as NonNullable; const result = await executeTool( - new ExitPlanModeTool(planService({ status }), permissionMode(), telemetry), + new ExitPlanModeTool(planService({ status }), permissionMode(), scopeContext, telemetry), { turnId: 7, toolCallId: 'call_exit_plan', @@ -345,7 +350,7 @@ describe('ExitPlanModeTool telemetry', () => { it('exposes options[].description as optional with a default of empty string', () => { const { telemetry } = recordingTelemetry(); - const parameters = new ExitPlanModeTool(planService(), permissionMode(), telemetry).parameters as { + const parameters = new ExitPlanModeTool(planService(), permissionMode(), scopeContext, telemetry).parameters as { properties: { options: { items: { @@ -366,7 +371,7 @@ describe('ExitPlanModeTool telemetry', () => { const exit = vi.fn(); const { telemetry, track2 } = recordingTelemetry(); - const result = await executeTool(new ExitPlanModeTool(planService({ exit }), permissionMode(), telemetry), { + const result = await executeTool(new ExitPlanModeTool(planService({ exit }), permissionMode(), scopeContext, telemetry), { turnId: 7, toolCallId: 'call_exit_plan', args: {}, @@ -375,8 +380,12 @@ describe('ExitPlanModeTool telemetry', () => { expect(result.isError).toBe(false); expect(exit).toHaveBeenCalledTimes(1); - expect(track2).toHaveBeenCalledWith('plan_submitted', { has_options: false }); + expect(track2).toHaveBeenCalledWith('plan_submitted', { + agent_id: 'main', + has_options: false, + }); expect(track2).toHaveBeenCalledWith('plan_resolved', { + agent_id: 'main', outcome: 'auto_approved', }); }); @@ -384,7 +393,7 @@ describe('ExitPlanModeTool telemetry', () => { it('tracks submitted with options only when multiple options are present', async () => { const { telemetry, track2 } = recordingTelemetry(); - const result = await executeTool(new ExitPlanModeTool(planService(), permissionMode(), telemetry), { + const result = await executeTool(new ExitPlanModeTool(planService(), permissionMode(), scopeContext, telemetry), { turnId: 7, toolCallId: 'call_exit_plan_options', args: { options }, @@ -392,8 +401,12 @@ describe('ExitPlanModeTool telemetry', () => { }); expect(result.isError).toBe(false); - expect(track2).toHaveBeenCalledWith('plan_submitted', { has_options: true }); + expect(track2).toHaveBeenCalledWith('plan_submitted', { + agent_id: 'main', + has_options: true, + }); expect(track2).toHaveBeenCalledWith('plan_resolved', { + agent_id: 'main', outcome: 'auto_approved', }); }); @@ -404,7 +417,7 @@ describe('ExitPlanModeTool telemetry', () => { }); const { telemetry, track2 } = recordingTelemetry(); - const result = await executeTool(new ExitPlanModeTool(planService({ exit }), permissionMode(), telemetry), { + const result = await executeTool(new ExitPlanModeTool(planService({ exit }), permissionMode(), scopeContext, telemetry), { turnId: 7, toolCallId: 'call_exit_plan_fail', args: {}, @@ -414,8 +427,12 @@ describe('ExitPlanModeTool telemetry', () => { expect(result.isError).toBe(true); expect(result.output).toContain('Failed to exit plan mode'); expect(exit).toHaveBeenCalledTimes(1); - expect(track2).toHaveBeenCalledWith('plan_submitted', { has_options: false }); + expect(track2).toHaveBeenCalledWith('plan_submitted', { + agent_id: 'main', + has_options: false, + }); expect(track2).not.toHaveBeenCalledWith('plan_resolved', { + agent_id: 'main', outcome: 'auto_approved', }); }); diff --git a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts index ac6f0f1586..108378647e 100644 --- a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts +++ b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts @@ -319,7 +319,8 @@ describe('AgentTaskService — event emission', () => { }), }); expect(agent.telemetry.track2).toHaveBeenCalledWith('background_task_created', { - kind: 'bash', + task_id: taskId, + kind: 'process', }); }); @@ -338,6 +339,7 @@ describe('AgentTaskService — event emission', () => { }), }); expect(agent.telemetry.track2).toHaveBeenCalledWith('background_task_created', { + task_id: taskId, kind: 'agent', }); }); @@ -359,6 +361,7 @@ describe('AgentTaskService — event emission', () => { expect(agent.telemetry.track2).toHaveBeenCalledWith( 'background_task_completed', expect.objectContaining({ + task_id: taskId, kind: 'process', duration_ms: expect.any(Number), status: 'completed', diff --git a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts index 10548659c0..c8f756ff87 100644 --- a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts +++ b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts @@ -308,6 +308,7 @@ describe('AgentToolDedupeService', () => { expect(telemetryEvents).toContainEqual({ event: 'tool_call_dedup_detected', properties: expect.objectContaining({ + agent_id: 'main', turn_id: 3, step_no: 1, tool_call_id: 'call_2', @@ -710,6 +711,8 @@ describe('AgentToolDedupeService', () => { const repeats = telemetryEvents.filter((e) => e.event === 'tool_call_repeat'); expect(repeats.map((e) => e.properties?.['repeat_count'])).toEqual([2, 3]); expect(repeats.every((e) => e.properties?.['tool_name'] === 'Read')).toBe(true); + expect(repeats.every((e) => e.properties?.['agent_id'] === 'main')).toBe(true); + expect(repeats.every((e) => e.properties?.['turn_id'] === 1)).toBe(true); }); it('does not emit telemetry on the first call', async () => { diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index cb98401497..d638f6f8da 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -343,6 +343,36 @@ describe('AgentLifecycleService', () => { expect(log.rewritten).toBeUndefined(); }); + it('create skips auto ids that collide with agents persisted by a previous run', async () => { + ix.stub(ISessionMetadata, { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeMetadata: () => ({ dispose: () => {} }), + read: () => + Promise.resolve({ + id: 'sess_test', + createdAt: 0, + updatedAt: 0, + archived: false, + agents: { + 'agent-0': { homedir: '/tmp/kimi-agentLifecycle-test/agents/agent-0', type: 'sub' }, + 'agent-1': { homedir: '/tmp/kimi-agentLifecycle-test/agents/agent-1', type: 'sub' }, + }, + }), + update: () => Promise.resolve(), + setTitle: () => Promise.resolve(), + setArchived: () => Promise.resolve(), + registerAgent, + }); + const svc = ix.get(IAgentLifecycleService); + + const first = await svc.create({}); + expect(first.id).toBe('agent-2'); + + const second = await svc.create({}); + expect(second.id).toBe('agent-3'); + }); + it('create assigns sequential ids when unspecified', async () => { const svc = ix.get(IAgentLifecycleService); const a = await svc.create({}); diff --git a/packages/agent-core-v2/test/session/cron/cron-tools.test.ts b/packages/agent-core-v2/test/session/cron/cron-tools.test.ts index 8cc76fd642..223a8b4e9e 100644 --- a/packages/agent-core-v2/test/session/cron/cron-tools.test.ts +++ b/packages/agent-core-v2/test/session/cron/cron-tools.test.ts @@ -26,11 +26,14 @@ import { } from '#/session/cron/tools/cron-create'; import { CronDeleteTool, type CronDeleteInput } from '#/session/cron/tools/cron-delete'; import { CronListTool, type CronListInput } from '#/session/cron/tools/cron-list'; +import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; const WALL_ANCHOR = 1_700_000_000_000; const MS_PER_DAY = 24 * 60 * 60 * 1000; const TRUNCATED = '\u2026(truncated)'; +const scopeContext = makeAgentScopeContext({ agentId: 'main', agentScope: '' }); + interface FakeStore { add(init: CronTaskInit, nowMs: number): CronTask; adopt(task: CronTask): void; @@ -41,7 +44,9 @@ interface ToolHarness { readonly store: FakeStore; readonly cron: ISessionCronService; readonly scheduled: CronTask[]; + readonly scheduledAgentIds: (string | undefined)[]; readonly deleted: string[]; + readonly deletedAgentIds: (string | undefined)[]; setNow(value: number): void; setDisabled(value: boolean): void; advance(ms: number): void; @@ -58,7 +63,9 @@ function createToolHarness(options: { let disabled = options.disabled ?? false; const tasks = new Map(); const scheduled: CronTask[] = []; + const scheduledAgentIds: (string | undefined)[] = []; const deleted: string[] = []; + const deletedAgentIds: (string | undefined)[] = []; let idCounter = 0; const store: FakeStore = { @@ -110,11 +117,13 @@ function createToolHarness(options: { : jitteredNextCronRunMs(task, parsed, idealMs, undefined, noJitter); }, getNextFireTime: () => null, - emitScheduled: (task) => { + emitScheduled: (task, agentId) => { scheduled.push(task); + scheduledAgentIds.push(agentId); }, - emitDeleted: (id) => { + emitDeleted: (id, agentId) => { deleted.push(id); + deletedAgentIds.push(agentId); }, loadFromStore: async () => {}, start: () => Promise.resolve(), @@ -128,7 +137,9 @@ function createToolHarness(options: { store, cron, scheduled, + scheduledAgentIds, deleted, + deletedAgentIds, setNow(value: number) { now = value; }, @@ -200,7 +211,7 @@ function pad(value: number): string { describe('CronCreateTool', () => { it('schedules a recurring task and emits scheduled telemetry through the manager', async () => { const harness = createToolHarness(); - const tool = new CronCreateTool(harness.cron); + const tool = new CronCreateTool(harness.cron, scopeContext); const out = assertSuccess( await runTool(tool, { @@ -219,6 +230,7 @@ describe('CronCreateTool', () => { }); expect(task.id).toMatch(/^[0-9a-f]{8}$/); expect(harness.scheduled).toEqual([task]); + expect(harness.scheduledAgentIds).toEqual(['main']); expect(scrubCronOutput(out)).toMatchInlineSnapshot(` "id: cron: */5 * * * * @@ -230,7 +242,7 @@ describe('CronCreateTool', () => { it('stores explicit one-shot tasks with recurring=false', async () => { const harness = createToolHarness(); - const tool = new CronCreateTool(harness.cron); + const tool = new CronCreateTool(harness.cron, scopeContext); const out = assertSuccess( await runTool(tool, { @@ -251,7 +263,7 @@ describe('CronCreateTool', () => { it('returns an error when scheduling is disabled', async () => { const harness = createToolHarness(); harness.setDisabled(true); - const tool = new CronCreateTool(harness.cron); + const tool = new CronCreateTool(harness.cron, scopeContext); const output = assertError( await runTool(tool, { @@ -267,7 +279,7 @@ describe('CronCreateTool', () => { it('rejects an unparseable cron expression', async () => { const harness = createToolHarness(); - const tool = new CronCreateTool(harness.cron); + const tool = new CronCreateTool(harness.cron, scopeContext); const output = assertError( await runTool(tool, { @@ -283,7 +295,7 @@ describe('CronCreateTool', () => { it('rejects a legal expression that has no fire inside the supported window', async () => { const harness = createToolHarness(); - const tool = new CronCreateTool(harness.cron); + const tool = new CronCreateTool(harness.cron, scopeContext); const output = assertError( await runTool(tool, { @@ -298,7 +310,7 @@ describe('CronCreateTool', () => { it('refuses to schedule past the session cap', async () => { const harness = createToolHarness(); - const tool = new CronCreateTool(harness.cron); + const tool = new CronCreateTool(harness.cron, scopeContext); for (let i = 0; i < MAX_CRON_JOBS_PER_SESSION; i++) { harness.store.add({ cron: '*/5 * * * *', prompt: `seed-${i}`, recurring: true }, harness.now()); @@ -317,7 +329,7 @@ describe('CronCreateTool', () => { it('rechecks the session cap inside execute', async () => { const harness = createToolHarness(); - const tool = new CronCreateTool(harness.cron); + const tool = new CronCreateTool(harness.cron, scopeContext); for (let i = 0; i < MAX_CRON_JOBS_PER_SESSION - 1; i++) { harness.store.add({ cron: '*/5 * * * *', prompt: `seed-${i}`, recurring: true }, harness.now()); @@ -354,7 +366,7 @@ describe('CronCreateTool', () => { it('rejects prompts over the UTF-8 byte budget', async () => { const harness = createToolHarness(); - const tool = new CronCreateTool(harness.cron); + const tool = new CronCreateTool(harness.cron, scopeContext); const prompt = '\u4f60'.repeat(3000); const output = assertError( @@ -370,7 +382,7 @@ describe('CronCreateTool', () => { it('normalizes cron field whitespace before storing and rendering', async () => { const harness = createToolHarness(); - const tool = new CronCreateTool(harness.cron); + const tool = new CronCreateTool(harness.cron, scopeContext); const out = assertSuccess( await runTool(tool, { @@ -387,7 +399,7 @@ describe('CronCreateTool', () => { it('uses the execution-time clock for createdAt', async () => { const harness = createToolHarness(); - const tool = new CronCreateTool(harness.cron); + const tool = new CronCreateTool(harness.cron, scopeContext); const execution = tool.resolveExecution({ cron: '*/5 * * * *', prompt: 'delayed approval', @@ -407,7 +419,7 @@ describe('CronCreateTool', () => { it('includes the normalized payload in the approval rule', async () => { const harness = createToolHarness(); - const tool = new CronCreateTool(harness.cron); + const tool = new CronCreateTool(harness.cron, scopeContext); const a = tool.resolveExecution({ cron: '*/5\n* * * *', @@ -439,18 +451,19 @@ describe('CronDeleteTool', () => { it('deletes an existing task and emits deletion through the manager', async () => { const harness = createToolHarness(); const task = harness.store.add({ cron: '*/5 * * * *', prompt: 'ping', recurring: true }, harness.now()); - const tool = new CronDeleteTool(harness.cron); + const tool = new CronDeleteTool(harness.cron, scopeContext); const output = assertSuccess(await runTool(tool, { id: task.id })); expect(output).toBe(`Deleted cron job ${task.id}.`); expect(harness.store.list()).toEqual([]); expect(harness.deleted).toEqual([task.id]); + expect(harness.deletedAgentIds).toEqual(['main']); }); it('reports an error for a well-formed but absent id', async () => { const harness = createToolHarness(); - const tool = new CronDeleteTool(harness.cron); + const tool = new CronDeleteTool(harness.cron, scopeContext); const output = assertError(await runTool(tool, { id: 'deadbeef' })); @@ -463,7 +476,7 @@ describe('CronDeleteTool', () => { async (id) => { const harness = createToolHarness(); harness.store.add({ cron: '*/5 * * * *', prompt: 'ping', recurring: true }, harness.now()); - const tool = new CronDeleteTool(harness.cron); + const tool = new CronDeleteTool(harness.cron, scopeContext); const output = assertError(await runTool(tool, { id })); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 579410fbe3..1e44413c7b 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -606,6 +606,7 @@ describe('Agent tool execution contract', () => { ], ]), ); + const telemetryRecords: Array<{ event: string; properties: unknown }> = []; const requester = { id: 'main', kind: LifecycleScope.Agent, @@ -613,7 +614,14 @@ describe('Agent tool execution contract', () => { get: ((serviceId: unknown) => { if (serviceId === IEventBus) return eventBus; if (serviceId === IAgentLifecycleService) return lifecycle; - if (serviceId === ITelemetryService) return noopTelemetryService; + if (serviceId === ITelemetryService) { + return { + ...noopTelemetryService, + track2: (event: string, properties: unknown) => { + telemetryRecords.push({ event, properties }); + }, + }; + } return undefined; }) as IAgentScopeHandle['accessor']['get'], }, @@ -643,6 +651,16 @@ describe('Agent tool execution contract', () => { parentAgentId: 'main', callerAgentId: 'main', }); + expect(telemetryRecords).toContainEqual({ + event: 'subagent_created', + properties: { + subagent_name: 'explore', + run_in_background: false, + agent_id: 'agent-child', + parent_agent_id: 'main', + parent_tool_call_id: 'call_agent', + }, + }); expect(events.find((event) => event.type === 'subagent.completed')).toMatchObject({ subagentId: 'agent-child', resultSummary: 'child result', From ccd67e57decd84bd783618bcf26c6ca218cba5d2 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 14 Jul 2026 17:22:05 +0800 Subject: [PATCH 09/13] fix(agent-core-v2): preserve telemetry turn attribution --- .changeset/telemetry-agent-id.md | 2 +- .../fullCompaction/fullCompactionService.ts | 19 +++++-- .../src/agent/llmRequester/llmRequester.ts | 1 + .../agent/llmRequester/llmRequesterService.ts | 2 +- .../src/agent/task/taskService.ts | 2 +- .../agent-core-v2/src/app/telemetry/events.ts | 4 +- .../fullCompaction/fullCompaction.test.ts | 55 +++++++++++++++++++ .../agent/llmRequester/llmRequester.test.ts | 9 ++- .../test/agent/task/rpc-events.test.ts | 2 +- 9 files changed, 81 insertions(+), 15 deletions(-) diff --git a/.changeset/telemetry-agent-id.md b/.changeset/telemetry-agent-id.md index 24f2e45670..a7ddc85754 100644 --- a/.changeset/telemetry-agent-id.md +++ b/.changeset/telemetry-agent-id.md @@ -6,4 +6,4 @@ Emit `turn_id` and `agent_id` on turn, tool, and agent-level settings telemetry events (model/thinking/skill/permission toggles) so activity can be attributed to the main agent or a specific subagent within a session. -Extend the same linkage across the rest of the v2 event surface: `parent_tool_call_id` on `subagent_created`; `agent_id`/`turn_id`/`tool_call_id` on permission decisions and approvals; `agent_id` on plan, compaction, context-projection repair, and cron schedule/delete events; `turn_id`/`request_kind` on `api_error` so compaction request failures are distinguishable from turn request failures; `agent_id`/`turn_id` on `tool_call_repeat` while `tool_call_dedup_detected` stops fabricating `turn_id: 0` outside a turn; and `task_id` on background task created/completed events with a unified `kind` vocabulary (`process` replaces the legacy `bash` alias on `background_task_created`). Auto-assigned subagent ids (`agent-N`) now skip ids persisted from previous runs, so a resumed session cannot reissue `agent-0` and collide with earlier telemetry. +Extend the same linkage across the rest of the v2 event surface: `parent_tool_call_id` on `subagent_created`; `agent_id`/`turn_id`/`tool_call_id` on permission decisions and approvals; `agent_id` on plan, compaction, context-projection repair, and cron schedule/delete events; `turn_id`/`request_kind` on `api_error` so compaction request failures are distinguishable from turn request failures; `agent_id`/`turn_id` on `tool_call_repeat` while `tool_call_dedup_detected` stops fabricating `turn_id: 0` outside a turn; and `task_id` on background task created/completed events while preserving the legacy `bash` kind on `background_task_created`. Auto-assigned subagent ids (`agent-N`) now skip ids persisted from previous runs, so a resumed session cannot reissue `agent-0` and collide with earlier telemetry. diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 80c08d18dd..beffb100c0 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -82,6 +82,7 @@ type CompactionTelemetryProperties = Pick< >; interface ActiveCompaction extends FullCompactionTask { + readonly originTurnId?: number; blockedByTurn: boolean; bgRegistration?: IDisposable; } @@ -248,7 +249,11 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull const tokenCount = this.validateCompactionStart(data.source); this.wire.dispatch(fullCompactionBegin(data)); - const active = this.createActiveCompaction(data.source, tokenCount); + const active = this.createActiveCompaction( + data.source, + tokenCount, + data.source === 'auto' ? this.activeTurnId : undefined, + ); this._compacting = active.task; active.task.abortController.signal.addEventListener( 'abort', @@ -286,6 +291,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull private createActiveCompaction( trigger: CompactionBeginData['source'], tokenCount: number, + originTurnId: number | undefined, ): { readonly task: ActiveCompaction; readonly resolve: (result: CompactionResult) => void; @@ -304,6 +310,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull promise, trigger, tokenCount, + originTurnId, blockedByTurn: false, bgRegistration: this.activity.registerBackground('compaction', abortController), }, @@ -537,7 +544,11 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull { messages, maxOutputSize: compactionMaxOutputSize, - source: { type: 'operation', requestKind: 'full_compaction' }, + source: { + type: 'operation', + turnId: active.originTurnId, + requestKind: 'full_compaction', + }, }, undefined, signal, @@ -617,7 +628,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull const properties: CompactionFinishedEvent = { agent_id: this.scopeContext.agentId, - turn_id: this.activeTurnId, + turn_id: active.originTurnId, source: data.source, tokens_before: result.tokensBefore, tokens_after: result.tokensAfter, @@ -635,7 +646,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull if (isAbortError(error)) throw error; const properties: CompactionFailedEvent = { agent_id: this.scopeContext.agentId, - turn_id: this.activeTurnId, + turn_id: active.originTurnId, source: data.source, tokens_before: tokensBefore, duration_ms: Date.now() - startedAt, diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts index 259ecf019b..24ccbb9a7f 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts @@ -16,6 +16,7 @@ export type LLMRequestSource = } | { readonly type: 'operation'; + readonly turnId?: number; readonly requestKind?: string; readonly logFields?: LLMRequestLogFields; }; diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index efafd0c1d2..f956b9aa60 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -192,7 +192,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { protocol: model?.protocol, retryable: isRetryableGenerateError(error), duration_ms: Math.max(0, Date.now() - startedAt), - turn_id: source?.type === 'turn' ? source.turnId : undefined, + turn_id: source?.turnId, request_kind: requestKindForTelemetry(source), }; const statusCode = apiStatusCode(error); diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 862eb92b66..0ef47a1b65 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -966,7 +966,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { this.wire.dispatch(taskStarted({ info })); this.telemetry.track2('background_task_created', { task_id: info.taskId, - kind: info.kind, + kind: info.kind === 'process' ? 'bash' : info.kind, }); } diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 642e957c70..b8548f2a3c 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -225,7 +225,7 @@ export interface ContextProjectionRepairedEvent { export interface BackgroundTaskCreatedEvent { task_id: string; - kind: 'agent' | 'process' | 'question'; + kind: 'bash' | 'agent' | 'question'; } export interface BackgroundTaskCompletedEvent { @@ -644,7 +644,7 @@ export const telemetryEventDefinitions = { comment: 'A background task is created.', properties: { task_id: 'Background task id; joins background_task_created with background_task_completed', - kind: 'Task kind', + kind: 'Task kind; process tasks retain the legacy bash value', }, }), background_task_completed: defineTelemetryEvent({ diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 54d1b52e67..59b6de3b45 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -1508,6 +1508,61 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('attributes background auto compaction to the turn that started it', async () => { + const compactionRequested = deferred(); + const releaseCompaction = deferred(); + const records: TelemetryRecord[] = []; + let ctx!: TestAgentContext; + let llmCallCount = 0; + const generate: GenerateFn = async () => { + llmCallCount += 1; + if (llmCallCount === 1) return textResult('Turn response.'); + if (llmCallCount === 2) { + compactionRequested.resolve(); + await releaseCompaction.promise; + return textResult('Background compacted summary.'); + } + throw new Error(`Unexpected generate call ${String(llmCallCount)}`); + }; + ctx = testAgent({ + generate, + telemetry: recordingTelemetry(records), + }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + tools: SNAPSHOT_VISIBLE_TOOLS, + }); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80); + ctx.get(IAgentLoopService).hooks.onDidFinishStep.register( + 'test-auto-compaction', + async (_step, next) => { + if (!ctx.get(IAgentFullCompactionService).begin({ source: 'auto' })) { + throw new Error('Expected auto compaction to start'); + } + await next(); + }, + ); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start background compaction' }] }); + await compactionRequested.promise; + await ctx.untilTurnEnd(); + + releaseCompaction.resolve(); + await ctx.once('compaction.completed'); + + expect(records).toContainEqual({ + event: 'compaction_finished', + properties: expect.objectContaining({ + agent_id: 'main', + turn_id: 0, + source: 'auto', + }), + }); + await ctx.expectResumeMatches(); + }); + it('keeps a deferred system reminder behind an unresolved tool exchange across compaction', async () => { const ctx = testAgent(); ctx.configure({ diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts index 01fe5b9ee7..f992125456 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts @@ -389,7 +389,9 @@ describe('LLMRequester service migration coverage', () => { llmRequester.request({ source: { type: 'turn', turnId: 3, step: 1 } }), ).rejects.toMatchObject({ name: 'APIConnectionError' }); await expect( - llmRequester.request({ source: { type: 'operation', requestKind: 'full_compaction' } }), + llmRequester.request({ + source: { type: 'operation', turnId: 7, requestKind: 'full_compaction' }, + }), ).rejects.toMatchObject({ name: 'APIConnectionError' }); expect(records).toContainEqual({ @@ -404,13 +406,10 @@ describe('LLMRequester service migration coverage', () => { event: 'api_error', properties: expect.objectContaining({ error_type: 'network', + turn_id: 7, request_kind: 'full_compaction', }), }); - const operationRecord = records.find( - (record) => record.properties?.['request_kind'] === 'full_compaction', - ); - expect(operationRecord?.properties?.['turn_id']).toBeUndefined(); }); }); diff --git a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts index 108378647e..b3114285c5 100644 --- a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts +++ b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts @@ -320,7 +320,7 @@ describe('AgentTaskService — event emission', () => { }); expect(agent.telemetry.track2).toHaveBeenCalledWith('background_task_created', { task_id: taskId, - kind: 'process', + kind: 'bash', }); }); From 5f01a8fe633f1c4e47d255d32eb17a26e8f4aba9 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 15 Jul 2026 12:40:46 +0800 Subject: [PATCH 10/13] chore: split telemetry changeset into two logical changes --- .changeset/subagent-id-reuse.md | 6 ++++++ .changeset/telemetry-agent-id.md | 4 +--- 2 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 .changeset/subagent-id-reuse.md diff --git a/.changeset/subagent-id-reuse.md b/.changeset/subagent-id-reuse.md new file mode 100644 index 0000000000..e4a7d36173 --- /dev/null +++ b/.changeset/subagent-id-reuse.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Skip subagent ids persisted from previous runs when auto-assigning `agent-N` ids, so a resumed session cannot reissue `agent-0` and collide with earlier telemetry. diff --git a/.changeset/telemetry-agent-id.md b/.changeset/telemetry-agent-id.md index a7ddc85754..cc807ae528 100644 --- a/.changeset/telemetry-agent-id.md +++ b/.changeset/telemetry-agent-id.md @@ -4,6 +4,4 @@ "@moonshot-ai/kimi-code": patch --- -Emit `turn_id` and `agent_id` on turn, tool, and agent-level settings telemetry events (model/thinking/skill/permission toggles) so activity can be attributed to the main agent or a specific subagent within a session. - -Extend the same linkage across the rest of the v2 event surface: `parent_tool_call_id` on `subagent_created`; `agent_id`/`turn_id`/`tool_call_id` on permission decisions and approvals; `agent_id` on plan, compaction, context-projection repair, and cron schedule/delete events; `turn_id`/`request_kind` on `api_error` so compaction request failures are distinguishable from turn request failures; `agent_id`/`turn_id` on `tool_call_repeat` while `tool_call_dedup_detected` stops fabricating `turn_id: 0` outside a turn; and `task_id` on background task created/completed events while preserving the legacy `bash` kind on `background_task_created`. Auto-assigned subagent ids (`agent-N`) now skip ids persisted from previous runs, so a resumed session cannot reissue `agent-0` and collide with earlier telemetry. +Emit `turn_id` and `agent_id` on turn, tool, and agent-level telemetry events so activity can be attributed to the main agent or a specific subagent within a session. From b747ad8c61ce27042b8abb392e3bf94c12be665d Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 15 Jul 2026 14:18:43 +0800 Subject: [PATCH 11/13] refactor(agent-core-v2): bind agent telemetry context at scope --- .../contextProjectorService.ts | 3 - .../fullCompaction/fullCompactionService.ts | 4 -- .../agent/llmRequester/llmRequesterService.ts | 3 - .../permissionGate/permissionGateService.ts | 3 - .../policies/exit-plan-mode-review-ask.ts | 7 +- .../src/agent/plan/tools/enter-plan-mode.ts | 3 - .../src/agent/plan/tools/exit-plan-mode.ts | 5 -- .../src/agent/profile/profileService.ts | 7 +- .../agent-core-v2/src/agent/rpc/rpcService.ts | 6 +- .../src/agent/skill/skillService.ts | 4 -- .../src/agent/toolDedupe/toolDedupeService.ts | 4 -- .../agent/toolExecutor/toolExecutorService.ts | 3 - .../telemetry/agentTelemetryContextService.ts | 3 +- .../agent-core-v2/src/app/telemetry/events.ts | 70 ++++++++++++------- .../agentLifecycle/agentLifecycleService.ts | 14 +++- .../projector-tool-exchanges.test.ts | 1 - .../permissionGate/permissionGate.test.ts | 3 - .../exit-plan-mode-review-ask.test.ts | 17 ++--- .../agent/plan/tools/exit-plan-mode.test.ts | 10 +-- .../plan/tools/plan-tools-telemetry.test.ts | 34 ++++----- .../test/agent/toolDedupe/toolDedupe.test.ts | 4 -- .../agent/toolExecutor/toolExecutor.test.ts | 1 - packages/agent-core-v2/test/harness/agent.ts | 4 ++ .../agentLifecycle/agentLifecycle.test.ts | 4 ++ 24 files changed, 93 insertions(+), 124 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index f2144a883b..af425efa2f 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -27,7 +27,6 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ErrorCodes, Error2 } from '#/errors'; import type { ContentPart, Message } from '#/app/llmProtocol/message'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -43,7 +42,6 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi constructor( @ILogService private readonly log: ILogService, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITelemetryService private readonly telemetry: ITelemetryService, ) {} @@ -137,7 +135,6 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi toolCallIds, }); this.telemetry.track2('context_projection_repaired', { - agent_id: this.scopeContext.agentId, reordered, synthesized, dropped_orphan: droppedOrphan, diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index f3ce051966..5426be818b 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -20,7 +20,6 @@ import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry'; import { IAgentLoopService, type LoopErrorContext } from '#/agent/loop/loop'; import { isAbortError } from '#/_base/utils/abort'; import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { stripDynamicToolContext } from '#/agent/toolSelect/dynamicTools'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; @@ -120,7 +119,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull @IAgentContextSizeService private readonly contextSize: IAgentContextSizeService, @IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService, @IAgentProfileService private readonly profile: IAgentProfileService, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService, @IInstantiationService private readonly instantiation: IInstantiationService, @@ -637,7 +635,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull }); const properties: CompactionFinishedEvent = { - agent_id: this.scopeContext.agentId, turn_id: active.originTurnId, source: data.source, tokens_before: result.tokensBefore, @@ -655,7 +652,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } catch (error) { if (isAbortError(error)) throw error; const properties: CompactionFailedEvent = { - agent_id: this.scopeContext.agentId, turn_id: active.originTurnId, source: data.source, tokens_before: tokensBefore, diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 870fd48031..695a54ea0b 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -32,7 +32,6 @@ import { type FaultKind, } from '#/agent/faultInjection/faultInjection'; import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentUsageService } from '#/agent/usage/usage'; @@ -145,7 +144,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { @ITelemetryService private readonly telemetry: ITelemetryService, @IWireService private readonly wire: IWireService, @IFaultInjectionService private readonly faultInjection: IFaultInjectionService, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) {} async request( @@ -189,7 +187,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { const model = this.tryGetProvider(); const properties: ApiErrorEvent = { error_type: apiErrorType(error), - agent_id: this.scopeContext.agentId, model: model?.id ?? modelAlias ?? 'unknown', alias: modelAlias, provider_type: model?.protocol, diff --git a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts index 4d23c619e2..7396be3175 100644 --- a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts +++ b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts @@ -90,7 +90,6 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG const evaluation = await this.policyService.evaluate(context); if (evaluation === undefined) return undefined; this.telemetry.track2('permission_policy_decision', { - agent_id: this.scopeContext.agentId, turn_id: context.turnId, tool_call_id: context.toolCall.id, policy_name: evaluation.policyName, @@ -176,7 +175,6 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG } catch (error) { if (isUserCancellation(error)) throw error; this.telemetry.track2('permission_approval_result', { - agent_id: this.scopeContext.agentId, turn_id: context.turnId, tool_call_id: context.toolCall.id, policy_name: policyName ?? null, @@ -222,7 +220,6 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG result: response, }); this.telemetry.track2('permission_approval_result', { - agent_id: this.scopeContext.agentId, turn_id: context.turnId, tool_call_id: context.toolCall.id, policy_name: policyName ?? null, diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts index 509998f047..ddeb1a162b 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts @@ -1,7 +1,6 @@ import { IAgentPlanService, type IAgentPlanService as AgentPlanService } from '#/agent/plan/plan'; import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { PlanResolvedEvent, PlanSubmittedEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { @@ -28,7 +27,6 @@ export class ExitPlanModeReviewAskPermissionPolicyService implements PermissionP constructor( @IAgentPlanService private readonly plan: AgentPlanService, @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITelemetryService private readonly telemetry: ITelemetryService, ) {} @@ -173,11 +171,10 @@ export class ExitPlanModeReviewAskPermissionPolicyService implements PermissionP event: 'plan_submitted' | 'plan_resolved', properties: Omit | Omit, ): void { - const withAgent = { ...properties, agent_id: this.scopeContext.agentId }; if (event === 'plan_submitted') { - this.telemetry.track2('plan_submitted', withAgent as PlanSubmittedEvent); + this.telemetry.track2('plan_submitted', properties as PlanSubmittedEvent); } else { - this.telemetry.track2('plan_resolved', withAgent as PlanResolvedEvent); + this.telemetry.track2('plan_resolved', properties as PlanResolvedEvent); } } } diff --git a/packages/agent-core-v2/src/agent/plan/tools/enter-plan-mode.ts b/packages/agent-core-v2/src/agent/plan/tools/enter-plan-mode.ts index b71a53c573..c3d1778a22 100644 --- a/packages/agent-core-v2/src/agent/plan/tools/enter-plan-mode.ts +++ b/packages/agent-core-v2/src/agent/plan/tools/enter-plan-mode.ts @@ -12,7 +12,6 @@ import { registerTool } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentPlanService } from '#/agent/plan/plan'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import DESCRIPTION from './enter-plan-mode.md?raw'; @@ -26,7 +25,6 @@ export class EnterPlanModeTool implements BuiltinTool { constructor( @IAgentPlanService private readonly planMode: IAgentPlanService, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITelemetryService private readonly telemetry: ITelemetryService, ) {} @@ -51,7 +49,6 @@ export class EnterPlanModeTool implements BuiltinTool { } this.telemetry.track2('plan_enter_resolved', { - agent_id: this.scopeContext.agentId, outcome: 'auto_approved', }); const after = await this.planMode.status(); diff --git a/packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts b/packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts index cfa241daac..555f805461 100644 --- a/packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts +++ b/packages/agent-core-v2/src/agent/plan/tools/exit-plan-mode.ts @@ -25,7 +25,6 @@ import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentPlanService } from '#/agent/plan/plan'; import type { PlanData } from '#/agent/plan/plan'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import DESCRIPTION from './exit-plan-mode.md?raw'; @@ -91,7 +90,6 @@ export class ExitPlanModeTool implements BuiltinTool { constructor( @IAgentPlanService private readonly planMode: IAgentPlanService, @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITelemetryService private readonly telemetry: ITelemetryService, ) {} @@ -139,7 +137,6 @@ export class ExitPlanModeTool implements BuiltinTool { if (!resolvedPlan.ok) return resolvedPlan.error; this.telemetry.track2('plan_submitted', { - agent_id: this.scopeContext.agentId, has_options: args.options !== undefined && args.options.length >= 2, }); @@ -148,7 +145,6 @@ export class ExitPlanModeTool implements BuiltinTool { if (this.permissionMode.mode === 'auto') { this.telemetry.track2('plan_resolved', { - agent_id: this.scopeContext.agentId, outcome: 'auto_approved', }); return { @@ -158,7 +154,6 @@ export class ExitPlanModeTool implements BuiltinTool { } this.telemetry.track2('plan_resolved', { - agent_id: this.scopeContext.agentId, outcome: 'approved', }); return { diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 09bc78be48..3a949b05f4 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -50,7 +50,6 @@ import { isMcpToolName, type ToolSource } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { WarningEvent } from '@moonshot-ai/protocol'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -118,7 +117,6 @@ export class AgentProfileService implements IAgentProfileService { @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IAgentProfileCatalogService private readonly catalog: IAgentProfileCatalogService, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { this.configure({}); } @@ -182,10 +180,10 @@ export class AgentProfileService implements IAgentProfileService { const model = this.modelFactory.resolve(alias); if (this.profileName === undefined) { await this.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: alias }); - this.telemetry.track2('model_switch', { agent_id: this.scopeContext.agentId, model: alias }); + this.telemetry.track2('model_switch', { model: alias }); } else if (this.modelAlias !== alias) { this.update({ modelAlias: alias }); - this.telemetry.track2('model_switch', { agent_id: this.scopeContext.agentId, model: alias }); + this.telemetry.track2('model_switch', { model: alias }); } return { model: alias, @@ -209,7 +207,6 @@ export class AgentProfileService implements IAgentProfileService { const effort = this.thinkingLevel; if (effort !== previousEffort) { this.telemetry.track2('thinking_toggle', { - agent_id: this.scopeContext.agentId, enabled: effort !== 'off', effort, from: previousEffort, diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index 91186cbe5f..4bba878b05 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -24,7 +24,6 @@ import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionBtwService } from '#/session/btw/btw'; import { IAgentSkillService } from '#/agent/skill/skill'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; @@ -101,7 +100,6 @@ export class AgentRPCService implements IAgentRPCService { @ISessionMetadata private readonly metadata: ISessionMetadata, @ISessionContext private readonly sessionContext: ISessionContext, @ISessionBtwService private readonly btw: ISessionBtwService, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { } async prompt(payload: PromptPayload): Promise { @@ -160,11 +158,11 @@ export class AgentRPCService implements IAgentRPCService { this.permissionMode.setMode(payload.mode); const enabled = this.permissionMode.mode === 'yolo'; if (enabled !== wasYolo) { - this.telemetry.track2('yolo_toggle', { agent_id: this.scopeContext.agentId, enabled }); + this.telemetry.track2('yolo_toggle', { enabled }); } const afkEnabled = this.permissionMode.mode === 'auto'; if (afkEnabled !== wasAuto) { - this.telemetry.track2('afk_toggle', { agent_id: this.scopeContext.agentId, enabled: afkEnabled }); + this.telemetry.track2('afk_toggle', { enabled: afkEnabled }); } } diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index 03ede744d8..3f787ffd3b 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -24,7 +24,6 @@ import { Disposable } from '#/_base/di/lifecycle'; import { ErrorCodes, Error2 } from '#/errors'; import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCatalog/types'; import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { Turn } from '#/agent/loop/loop'; import { IWireService } from '#/wire/wire'; @@ -41,7 +40,6 @@ export class AgentSkillService extends Disposable implements IAgentSkillService @IWireService private readonly wire: IWireService, @ITelemetryService private readonly telemetry: ITelemetryService, @ISessionContext private readonly sessionContext: ISessionContext, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { super(); } @@ -125,13 +123,11 @@ export class AgentSkillService extends Disposable implements IAgentSkillService private publishActivation(origin: SkillActivationOrigin): void { this.telemetry.track2('skill_invoked', { - agent_id: this.scopeContext.agentId, skill_name: origin.skillName, trigger: origin.trigger, }); if (origin.skillType === 'flow') { this.telemetry.track2('flow_invoked', { - agent_id: this.scopeContext.agentId, flow_name: origin.skillName, }); } diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index d97d287b79..ea2bcd7d0e 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -17,7 +17,6 @@ import { canonicalTelemetryArgs } from '#/_base/utils/canonical-args'; import type { ToolCallDedupDetectedEvent, ToolCallRepeatEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolExecutorService, type ToolCallDupType } from '#/agent/toolExecutor/toolExecutor'; import type { ContentPart } from '#/app/llmProtocol/message'; import { IAgentToolDedupeService, type ToolDedupeResult } from './toolDedupe'; @@ -121,7 +120,6 @@ export class AgentToolDedupeService extends Disposable implements IAgentToolDedu @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentLoopService loop: IAgentLoopService, @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { super(); loop.hooks.onWillBeginStep.register('toolDedupe', async (ctx, next) => { @@ -218,7 +216,6 @@ export class AgentToolDedupeService extends Disposable implements IAgentToolDedu this.toolExecutor.recordDupType(toolCallId, dupType); const properties: ToolCallDedupDetectedEvent = { turn_id: this.activeTurnId, - agent_id: this.scopeContext.agentId, step_no: this.activeStep, tool_call_id: toolCallId, tool_name: toolName, @@ -277,7 +274,6 @@ export class AgentToolDedupeService extends Disposable implements IAgentToolDedu if (streak >= 2) { const properties: ToolCallRepeatEvent = { - agent_id: this.scopeContext.agentId, turn_id: this.activeTurnId, tool_name: toolName, repeat_count: streak, diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 54c5dfe071..7f7acffcbd 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -39,7 +39,6 @@ import { } from '#/tool/toolContract'; import type { ToolDidExecuteContext, ToolBeforeExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { ToolCall } from '#/app/llmProtocol/message'; import { ILogService } from '#/_base/log/log'; import type { ToolCallEvent } from '#/app/telemetry/events'; @@ -136,7 +135,6 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentToolResultTruncationService private readonly resultTruncation: IAgentToolResultTruncationService, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ILogService private readonly log?: ILogService, ) {} @@ -277,7 +275,6 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { this.toolCallDupTypes.delete(toolCallId); const properties: ToolCallEvent = { turn_id: turnId, - agent_id: this.scopeContext.agentId, tool_call_id: toolCallId, tool_name: call.toolName, outcome, diff --git a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts index c91ad217ed..62c5ec3367 100644 --- a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts +++ b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts @@ -3,7 +3,8 @@ * * Holds the agent's ambient telemetry context (defaults to `mode: 'agent'`); * merged into turn telemetry through `ITelemetryService.withContext` at turn - * launch. Owns no cross-domain collaborators. Bound at Agent scope. + * launch. Reads the current agent identity from `scopeContext`. Bound at Agent + * scope. */ import { InstantiationType } from '#/_base/di/extensions'; diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index b8548f2a3c..cd5503aa11 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -9,8 +9,10 @@ * transport's `kfc_` server prefix. Naming conventions: events and * properties are snake_case; durations/counts/sizes carry a unit suffix * (`_ms` / `_count` / `_bytes`); never register user content or file paths - * as properties. App-scoped, self-contained — property unions are declared - * locally instead of imported from business domains. + * as properties. Agent-scoped telemetry binds `agent_id` through the scoped + * `ITelemetryService` view, so that property is optional at individual call + * sites. App-scoped, self-contained — property unions are declared locally + * instead of imported from business domains. */ import type { TelemetryPrimitive } from './telemetry'; @@ -44,7 +46,7 @@ export type StrictPropertyCheck = string extends keyof T export interface TurnStartedEvent { turn_id: number; - agent_id: string; + agent_id?: string; mode: 'agent' | 'plan'; provider_type?: string; protocol?: string; @@ -52,7 +54,7 @@ export interface TurnStartedEvent { export interface TurnInterruptedEvent { turn_id: number; - agent_id: string; + agent_id?: string; at_step: number; mode: 'agent' | 'plan'; interrupt_reason: 'user_cancelled' | 'aborted' | 'max_steps' | 'error' | 'filtered' | 'blocked'; @@ -62,7 +64,7 @@ export interface TurnInterruptedEvent { export interface TurnEndedEvent { turn_id: number; - agent_id: string; + agent_id?: string; reason: 'completed' | 'cancelled' | 'failed'; duration_ms: number; mode: 'agent' | 'plan'; @@ -74,7 +76,7 @@ export type ToolCallOutcome = 'success' | 'error' | 'cancelled'; export interface ToolCallEvent { turn_id: number; - agent_id: string; + agent_id?: string; tool_call_id: string; tool_name: string; outcome: ToolCallOutcome; @@ -85,7 +87,7 @@ export interface ToolCallEvent { export interface ApiErrorEvent { error_type: string; - agent_id: string; + agent_id?: string; model: string; alias?: string; retryable: boolean; @@ -99,42 +101,45 @@ export interface ApiErrorEvent { } export interface SkillInvokedEvent { - agent_id: string; + agent_id?: string; skill_name: string; trigger: 'user-slash' | 'model-tool' | 'nested-skill'; } export interface FlowInvokedEvent { - agent_id: string; + agent_id?: string; flow_name: string; } export interface InputSteerEvent { + agent_id?: string; parts: number; } export interface CancelEvent { + agent_id?: string; from: 'streaming' | 'compacting'; } export interface ConversationUndoEvent { + agent_id?: string; count: number; } export interface YoloToggleEvent { - agent_id: string; + agent_id?: string; enabled: boolean; } export interface AfkToggleEvent { - agent_id: string; + agent_id?: string; enabled: boolean; } export type TelemetryPermissionMode = 'manual' | 'yolo' | 'auto'; export interface PermissionPolicyDecisionEvent { - agent_id: string; + agent_id?: string; turn_id: number; tool_call_id: string; policy_name: string; @@ -145,7 +150,7 @@ export interface PermissionPolicyDecisionEvent { } export interface PermissionApprovalResultEvent { - agent_id: string; + agent_id?: string; turn_id: number; tool_call_id: string; policy_name: string | null; @@ -159,12 +164,12 @@ export interface PermissionApprovalResultEvent { } export interface PlanSubmittedEvent { - agent_id: string; + agent_id?: string; has_options: boolean; } export interface PlanResolvedEvent { - agent_id: string; + agent_id?: string; outcome: | 'approved' | 'dismissed' @@ -177,12 +182,12 @@ export interface PlanResolvedEvent { } export interface PlanEnterResolvedEvent { - agent_id: string; + agent_id?: string; outcome: 'auto_approved'; } export interface CompactionFinishedEvent { - agent_id: string; + agent_id?: string; turn_id?: number; source: 'manual' | 'auto'; tokens_before: number; @@ -200,7 +205,7 @@ export interface CompactionFinishedEvent { } export interface CompactionFailedEvent { - agent_id: string; + agent_id?: string; turn_id?: number; source: 'manual' | 'auto'; tokens_before: number; @@ -212,7 +217,7 @@ export interface CompactionFailedEvent { } export interface ContextProjectionRepairedEvent { - agent_id: string; + agent_id?: string; reordered: number; synthesized: number; dropped_orphan: number; @@ -224,11 +229,13 @@ export interface ContextProjectionRepairedEvent { } export interface BackgroundTaskCreatedEvent { + agent_id?: string; task_id: string; kind: 'bash' | 'agent' | 'question'; } export interface BackgroundTaskCompletedEvent { + agent_id?: string; task_id: string; kind: 'agent' | 'process' | 'question'; duration_ms: number | null; @@ -236,12 +243,12 @@ export interface BackgroundTaskCompletedEvent { } export interface ModelSwitchEvent { - agent_id: string; + agent_id?: string; model: string; } export interface ThinkingToggleEvent { - agent_id: string; + agent_id?: string; enabled: boolean; effort: string; from: string; @@ -289,7 +296,7 @@ export interface GoalStatusChangedEvent extends GoalBudgetProperties { export interface ToolCallDedupDetectedEvent { turn_id?: number; - agent_id: string; + agent_id?: string; step_no: number; tool_call_id: string; tool_name: string; @@ -298,7 +305,7 @@ export interface ToolCallDedupDetectedEvent { } export interface ToolCallRepeatEvent { - agent_id: string; + agent_id?: string; turn_id?: number; tool_name: string; repeat_count: number; @@ -510,17 +517,26 @@ export const telemetryEventDefinitions = { input_steer: defineTelemetryEvent({ owner: 'kimi-code', comment: 'The user steers input while a turn is running.', - properties: { parts: 'Number of input parts' }, + properties: { + agent_id: 'Agent id (main or subagent scope id)', + parts: 'Number of input parts', + }, }), cancel: defineTelemetryEvent({ owner: 'kimi-code', comment: 'The user cancels ongoing work.', - properties: { from: 'What was running when cancelled' }, + properties: { + agent_id: 'Agent id (main or subagent scope id)', + from: 'What was running when cancelled', + }, }), conversation_undo: defineTelemetryEvent({ owner: 'kimi-code', comment: 'The user undoes conversation entries.', - properties: { count: 'Number of entries undone' }, + properties: { + agent_id: 'Agent id (main or subagent scope id)', + count: 'Number of entries undone', + }, }), yolo_toggle: defineTelemetryEvent({ owner: 'kimi-code', @@ -643,6 +659,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A background task is created.', properties: { + agent_id: 'Agent id (main or subagent scope id)', task_id: 'Background task id; joins background_task_created with background_task_completed', kind: 'Task kind; process tasks retain the legacy bash value', }, @@ -651,6 +668,7 @@ export const telemetryEventDefinitions = { owner: 'kimi-code', comment: 'A background task reaches a terminal state.', properties: { + agent_id: 'Agent id (main or subagent scope id)', task_id: 'Background task id; joins background_task_created with background_task_completed', kind: 'Task kind', duration_ms: 'Task wall-clock time in milliseconds, null when unknown', diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 3d559014a2..9d9741fb75 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -5,7 +5,8 @@ * serializing same-id bootstrap and dropping incomplete handles after startup * failure. Seeds each agent's identity through `agent` scopeContext, wires * per-agent wire records and the wire state machine, the blob store, and MCP, - * and registers the agent in the session registry. New logs receive a metadata + * and registers the agent in the session registry. Binds the agent id into the + * Agent-scoped telemetry view. New logs receive a metadata * envelope while non-empty unversioned logs are rejected. Removal awaits the * agent task manager's graceful exit policy before draining activity and * disposing the child scope. Bound at Session scope. @@ -63,6 +64,7 @@ import { IAgentExternalHooksService } from '#/agent/externalHooks/externalHooks' import { IAgentPluginService } from '#/agent/plugin/agentPlugin'; import { ISessionInteractionService } from '#/session/interaction/interaction'; import { IWireService } from '#/wire/wire'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; import { type AgentListFilter, type CreateAgentOptions, @@ -99,6 +101,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle @ISessionMcpService private readonly sessionMcp: ISessionMcpService, @ISessionActivityKernel private readonly activityKernel: ISessionActivityKernel, @ISessionInteractionService private readonly interaction: ISessionInteractionService, + @ITelemetryService private readonly telemetry: ITelemetryService, ) { super(); this._register(this.onDidCreate((handle) => this.subscribeInteractionBus(handle))); @@ -178,11 +181,16 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle this.instantiation, LifecycleScope.Agent, agentId, - // The only per-agent seed: identity facts. Every other agent-scope + // Seed identity facts and the telemetry view. Every other agent-scope // service either derives its configuration from `IAgentScopeContext` // (wire, blob) or resolves it through the scope tree (the // session's shared MCP manager via `ISessionMcpService`). - { extra: [[IAgentScopeContext, makeAgentScopeContext({ agentId, agentScope })]] }, + { + extra: [ + [IAgentScopeContext, makeAgentScopeContext({ agentId, agentScope })], + [ITelemetryService, this.telemetry.withContext({ agent_id: agentId })], + ], + }, ) as IAgentScopeHandle; this.handles.set(agentId, handle); try { diff --git a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index ec071f7995..3547c1aa8d 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts @@ -509,7 +509,6 @@ describe('projector tool-exchange normalization', () => { { event: 'context_projection_repaired', properties: { - agent_id: 'main', reordered: 2, synthesized: 0, dropped_orphan: 0, diff --git a/packages/agent-core-v2/test/agent/permissionGate/permissionGate.test.ts b/packages/agent-core-v2/test/agent/permissionGate/permissionGate.test.ts index a2206d4856..db40c3225c 100644 --- a/packages/agent-core-v2/test/agent/permissionGate/permissionGate.test.ts +++ b/packages/agent-core-v2/test/agent/permissionGate/permissionGate.test.ts @@ -318,7 +318,6 @@ describe('AgentPermissionGate', () => { expect(records).toContainEqual({ event: 'permission_policy_decision', properties: expect.objectContaining({ - agent_id: 'main', turn_id: 1, tool_call_id: 'call-AskUserQuestion', policy_name: 'auto-mode-ask-user-question-deny', @@ -463,7 +462,6 @@ describe('AgentPermissionGate', () => { expect(records).toContainEqual({ event: 'permission_approval_result', properties: expect.objectContaining({ - agent_id: 'main', turn_id: 1, tool_call_id: 'call-Bash', policy_name: 'fallback-ask', @@ -543,7 +541,6 @@ describe('AgentPermissionGate', () => { expect(records).toContainEqual({ event: 'permission_approval_result', properties: expect.objectContaining({ - agent_id: 'main', turn_id: 1, tool_call_id: 'call-ExitPlanMode', policy_name: 'exit-plan-mode-review-ask', diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/policies/exit-plan-mode-review-ask.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/policies/exit-plan-mode-review-ask.test.ts index 99da2d32b7..33b3224af4 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/policies/exit-plan-mode-review-ask.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/policies/exit-plan-mode-review-ask.test.ts @@ -134,7 +134,7 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(result?.kind).toBe('ask'); expect(records).toContainEqual({ event: 'plan_submitted', - properties: { agent_id: 'main', has_options: false }, + properties: { has_options: false }, }); }); @@ -160,12 +160,11 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(exitPlanMode).toHaveBeenCalledTimes(1); expect(records).toContainEqual({ event: 'plan_submitted', - properties: { agent_id: 'main', has_options: true }, + properties: { has_options: true }, }); expect(records).toContainEqual({ event: 'plan_resolved', properties: { - agent_id: 'main', outcome: 'approved', chosen_option: 'Approach B', }, @@ -194,7 +193,6 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(records).toContainEqual({ event: 'plan_resolved', properties: { - agent_id: 'main', outcome: 'revise', has_feedback: true, }, @@ -218,7 +216,7 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(exitPlanMode).not.toHaveBeenCalled(); expect(records).toContainEqual({ event: 'plan_resolved', - properties: { agent_id: 'main', outcome: 'rejected' }, + properties: { outcome: 'rejected' }, }); }); @@ -239,7 +237,7 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(exitPlanMode).not.toHaveBeenCalled(); expect(records).toContainEqual({ event: 'plan_resolved', - properties: { agent_id: 'main', outcome: 'dismissed' }, + properties: { outcome: 'dismissed' }, }); }); @@ -263,7 +261,7 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(exitPlanMode).toHaveBeenCalledTimes(1); expect(records).toContainEqual({ event: 'plan_resolved', - properties: { agent_id: 'main', outcome: 'rejected_and_exited' }, + properties: { outcome: 'rejected_and_exited' }, }); }); @@ -304,7 +302,6 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { expect(records).toContainEqual({ event: 'plan_resolved', properties: { - agent_id: 'main', outcome: 'approved', chosen_option: 'Approach C', }, @@ -323,11 +320,11 @@ describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { ); expect(records).toContainEqual({ event: 'plan_submitted', - properties: { agent_id: 'main', has_options: false }, + properties: { has_options: false }, }); expect(records).not.toContainEqual({ event: 'plan_resolved', - properties: { agent_id: 'main', outcome: 'approved' }, + properties: { outcome: 'approved' }, }); }); }); diff --git a/packages/agent-core-v2/test/agent/plan/tools/exit-plan-mode.test.ts b/packages/agent-core-v2/test/agent/plan/tools/exit-plan-mode.test.ts index be3b4615d3..e2cc0d9f33 100644 --- a/packages/agent-core-v2/test/agent/plan/tools/exit-plan-mode.test.ts +++ b/packages/agent-core-v2/test/agent/plan/tools/exit-plan-mode.test.ts @@ -8,14 +8,12 @@ import { } from '#/agent/plan/tools/exit-plan-mode'; import type { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; -import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; import { executeTool } from '../../../tools/fixtures/execute-tool'; const signal = new AbortController().signal; -const scopeContext = makeAgentScopeContext({ agentId: 'main', agentScope: '' }); const options = [ { label: 'Approach A', description: 'Small change.' }, @@ -147,7 +145,6 @@ describe('ExitPlanMode option output', () => { new ExitPlanModeTool( { ...planService(), exit }, permissionMode(), - scopeContext, telemetry, ), { @@ -167,7 +164,7 @@ describe('ExitPlanMode option output', () => { const telemetry = recordingTelemetry(); const result = await executeTool( - new ExitPlanModeTool(planService(), permissionMode('auto'), scopeContext, telemetry), + new ExitPlanModeTool(planService(), permissionMode('auto'), telemetry), { turnId: 7, toolCallId: 'call_exit_plan_auto', @@ -189,7 +186,7 @@ describe('ExitPlanMode option output', () => { const telemetry = recordingTelemetry(); const result = await executeTool( - new ExitPlanModeTool(planService(), permissionMode('manual'), scopeContext, telemetry), + new ExitPlanModeTool(planService(), permissionMode('manual'), telemetry), { turnId: 7, toolCallId: 'call_exit_plan_rule', @@ -205,7 +202,6 @@ describe('ExitPlanMode option output', () => { expect(result.output).toContain('## Approved Plan:'); expect(result.output).not.toContain('auto-approved'); expect(telemetry.track2).toHaveBeenCalledWith('plan_resolved', { - agent_id: 'main', outcome: 'approved', }); }); @@ -214,7 +210,7 @@ describe('ExitPlanMode option output', () => { const telemetry = recordingTelemetry(); const result = await executeTool( - new ExitPlanModeTool(planService(), permissionMode(), scopeContext, telemetry), + new ExitPlanModeTool(planService(), permissionMode(), telemetry), { turnId: 7, toolCallId: 'call_exit_plan', diff --git a/packages/agent-core-v2/test/agent/plan/tools/plan-tools-telemetry.test.ts b/packages/agent-core-v2/test/agent/plan/tools/plan-tools-telemetry.test.ts index d71fe03379..9dcee31cfd 100644 --- a/packages/agent-core-v2/test/agent/plan/tools/plan-tools-telemetry.test.ts +++ b/packages/agent-core-v2/test/agent/plan/tools/plan-tools-telemetry.test.ts @@ -11,7 +11,6 @@ import type { IAgentPermissionModeService } from '#/agent/permissionMode/permiss import type { ToolResult } from '#/tool/toolContract'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { executeTool } from '../../../tools/fixtures/execute-tool'; import { createFakeHostFs } from '../../../tools/fixtures/fake-exec'; @@ -38,7 +37,6 @@ const options = [ { label: 'Approach B', description: 'Larger change.' }, ] satisfies NonNullable; -const scopeContext = makeAgentScopeContext({ agentId: 'main', agentScope: '' }); function recordingTelemetry(): { readonly telemetry: ITelemetryService; @@ -94,7 +92,7 @@ function planService({ describe('EnterPlanModeTool telemetry', () => { it('has name, description, parameters, and a stable execution description', async () => { const { telemetry } = recordingTelemetry(); - const tool = new EnterPlanModeTool(planService({ status: null }), scopeContext, telemetry); + const tool = new EnterPlanModeTool(planService({ status: null }), telemetry); expect(tool.name).toBe('EnterPlanMode'); expect(tool.description).toContain('EnterPlanMode'); @@ -113,7 +111,7 @@ describe('EnterPlanModeTool telemetry', () => { it('returns an error when plan mode is already active', async () => { const { telemetry } = recordingTelemetry(); - const result = await executeTool(new EnterPlanModeTool(planService(), scopeContext, telemetry), { + const result = await executeTool(new EnterPlanModeTool(planService(), telemetry), { turnId: 0, toolCallId: 'call_enter_plan', args: {}, @@ -134,7 +132,7 @@ describe('EnterPlanModeTool telemetry', () => { vi.mocked(planMode.status).mockResolvedValue(null); const { telemetry } = recordingTelemetry(); - const result = await executeTool(new EnterPlanModeTool(planMode, scopeContext, telemetry), { + const result = await executeTool(new EnterPlanModeTool(planMode, telemetry), { turnId: 0, toolCallId: 'call_enter_plan', args: {}, @@ -157,7 +155,7 @@ describe('EnterPlanModeTool telemetry', () => { vi.mocked(planMode.status).mockImplementation(async () => (active ? ACTIVE_PLAN : null)); const { telemetry } = recordingTelemetry(); - const result = await executeTool(new EnterPlanModeTool(planMode, scopeContext, telemetry), { + const result = await executeTool(new EnterPlanModeTool(planMode, telemetry), { turnId: 0, toolCallId: 'call_enter_plan', args: {}, @@ -180,7 +178,6 @@ describe('EnterPlanModeTool telemetry', () => { throw new Error('cannot prepare plan directory'); }), }), - scopeContext, telemetry, ), { @@ -208,7 +205,7 @@ describe('EnterPlanModeTool telemetry', () => { vi.mocked(planMode.status).mockImplementation(async () => (active ? ACTIVE_PLAN : null)); const { telemetry, track2 } = recordingTelemetry(); - const result = await executeTool(new EnterPlanModeTool(planMode, scopeContext, telemetry), { + const result = await executeTool(new EnterPlanModeTool(planMode, telemetry), { turnId: 0, toolCallId: 'call_enter_plan', args: {}, @@ -217,7 +214,6 @@ describe('EnterPlanModeTool telemetry', () => { expect(result.isError).toBeFalsy(); expect(track2).toHaveBeenCalledWith('plan_enter_resolved', { - agent_id: 'main', outcome: 'auto_approved', }); }); @@ -286,7 +282,7 @@ describe('AgentPlanService EnterPlanMode telemetry', () => { describe('ExitPlanModeTool telemetry', () => { it('has name, description, parameters, and a stable execution description', async () => { const { telemetry } = recordingTelemetry(); - const tool = new ExitPlanModeTool(planService(), permissionMode(), scopeContext, telemetry); + const tool = new ExitPlanModeTool(planService(), permissionMode(), telemetry); expect(tool.name).toBe('ExitPlanMode'); expect(tool.description).toContain('ExitPlanMode'); @@ -308,7 +304,7 @@ describe('ExitPlanModeTool telemetry', () => { const { telemetry } = recordingTelemetry(); const result = await executeTool( - new ExitPlanModeTool(planService({ status: null }), permissionMode(), scopeContext, telemetry), + new ExitPlanModeTool(planService({ status: null }), permissionMode(), telemetry), { turnId: 7, toolCallId: 'call_exit_plan', @@ -332,7 +328,7 @@ describe('ExitPlanModeTool telemetry', () => { } as unknown as NonNullable; const result = await executeTool( - new ExitPlanModeTool(planService({ status }), permissionMode(), scopeContext, telemetry), + new ExitPlanModeTool(planService({ status }), permissionMode(), telemetry), { turnId: 7, toolCallId: 'call_exit_plan', @@ -350,7 +346,7 @@ describe('ExitPlanModeTool telemetry', () => { it('exposes options[].description as optional with a default of empty string', () => { const { telemetry } = recordingTelemetry(); - const parameters = new ExitPlanModeTool(planService(), permissionMode(), scopeContext, telemetry).parameters as { + const parameters = new ExitPlanModeTool(planService(), permissionMode(), telemetry).parameters as { properties: { options: { items: { @@ -371,7 +367,7 @@ describe('ExitPlanModeTool telemetry', () => { const exit = vi.fn(); const { telemetry, track2 } = recordingTelemetry(); - const result = await executeTool(new ExitPlanModeTool(planService({ exit }), permissionMode(), scopeContext, telemetry), { + const result = await executeTool(new ExitPlanModeTool(planService({ exit }), permissionMode(), telemetry), { turnId: 7, toolCallId: 'call_exit_plan', args: {}, @@ -381,11 +377,9 @@ describe('ExitPlanModeTool telemetry', () => { expect(result.isError).toBe(false); expect(exit).toHaveBeenCalledTimes(1); expect(track2).toHaveBeenCalledWith('plan_submitted', { - agent_id: 'main', has_options: false, }); expect(track2).toHaveBeenCalledWith('plan_resolved', { - agent_id: 'main', outcome: 'auto_approved', }); }); @@ -393,7 +387,7 @@ describe('ExitPlanModeTool telemetry', () => { it('tracks submitted with options only when multiple options are present', async () => { const { telemetry, track2 } = recordingTelemetry(); - const result = await executeTool(new ExitPlanModeTool(planService(), permissionMode(), scopeContext, telemetry), { + const result = await executeTool(new ExitPlanModeTool(planService(), permissionMode(), telemetry), { turnId: 7, toolCallId: 'call_exit_plan_options', args: { options }, @@ -402,11 +396,9 @@ describe('ExitPlanModeTool telemetry', () => { expect(result.isError).toBe(false); expect(track2).toHaveBeenCalledWith('plan_submitted', { - agent_id: 'main', has_options: true, }); expect(track2).toHaveBeenCalledWith('plan_resolved', { - agent_id: 'main', outcome: 'auto_approved', }); }); @@ -417,7 +409,7 @@ describe('ExitPlanModeTool telemetry', () => { }); const { telemetry, track2 } = recordingTelemetry(); - const result = await executeTool(new ExitPlanModeTool(planService({ exit }), permissionMode(), scopeContext, telemetry), { + const result = await executeTool(new ExitPlanModeTool(planService({ exit }), permissionMode(), telemetry), { turnId: 7, toolCallId: 'call_exit_plan_fail', args: {}, @@ -428,11 +420,9 @@ describe('ExitPlanModeTool telemetry', () => { expect(result.output).toContain('Failed to exit plan mode'); expect(exit).toHaveBeenCalledTimes(1); expect(track2).toHaveBeenCalledWith('plan_submitted', { - agent_id: 'main', has_options: false, }); expect(track2).not.toHaveBeenCalledWith('plan_resolved', { - agent_id: 'main', outcome: 'auto_approved', }); }); diff --git a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts index 4e81246db9..b59b80b1dd 100644 --- a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts +++ b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts @@ -300,7 +300,6 @@ describe('AgentToolDedupeService', () => { expect(telemetryEvents).toContainEqual({ event: 'tool_call_dedup_detected', properties: expect.objectContaining({ - agent_id: 'main', turn_id: 3, step_no: 1, tool_call_id: 'call_2', @@ -634,7 +633,6 @@ describe('AgentToolDedupeService', () => { event: 'tool_call_dedup_detected', properties: { turn_id: 7, - agent_id: 'main', step_no: 1, tool_call_id: 'c2', tool_name: 'Read', @@ -666,7 +664,6 @@ describe('AgentToolDedupeService', () => { event: 'tool_call_dedup_detected', properties: { turn_id: 7, - agent_id: 'main', step_no: 2, tool_call_id: 'c2', tool_name: 'Read', @@ -703,7 +700,6 @@ describe('AgentToolDedupeService', () => { const repeats = telemetryEvents.filter((e) => e.event === 'tool_call_repeat'); expect(repeats.map((e) => e.properties?.['repeat_count'])).toEqual([2, 3]); expect(repeats.every((e) => e.properties?.['tool_name'] === 'Read')).toBe(true); - expect(repeats.every((e) => e.properties?.['agent_id'] === 'main')).toBe(true); expect(repeats.every((e) => e.properties?.['turn_id'] === 1)).toBe(true); }); diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index 65ddb209f6..3ff4f4ed89 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -101,7 +101,6 @@ describe('AgentToolExecutorService', () => { event: 'tool_call', properties: expect.objectContaining({ turn_id: 0, - agent_id: 'main', tool_call_id: 'call_echo', tool_name: 'echo', outcome: 'success', diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 219106b56e..fa2bf92bf3 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -976,6 +976,9 @@ export class AgentTestContext { const bootstrap = this.root.accessor.get(IBootstrapService); const workspaceId = 'test-workspace'; + const agentTelemetry = this.root.accessor + .get(ITelemetryService) + .withContext({ agent_id: agentId }); const sessionScope = bootstrap.sessionScope(workspaceId, sessionId); this.session = this.root.createChild(LifecycleScope.Session, sessionId, { extra: collectScopeSeed( @@ -1055,6 +1058,7 @@ export class AgentTestContext { scope: (subKey?: string): string => subKey === undefined || subKey === '' ? agentScope : `${agentScope}/${subKey}`, }); + reg.defineInstance(ITelemetryService, agentTelemetry); }, ], this.serviceOverrides, diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index e925ac23fa..0e9874e424 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -253,6 +253,10 @@ describe('AgentLifecycleService', () => { ix.stub(ITelemetryService, { _serviceBrand: undefined, track2: () => {}, + withContext: () => ({ + _serviceBrand: undefined, + track2: () => {}, + }) as unknown as ITelemetryService, } as unknown as ITelemetryService); permissionModeSetMode = vi.fn(); ix.stub(IAgentPermissionModeService, { From ba2e033ee297689bd727358f9de290f1b0b70282 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 20 Jul 2026 16:06:45 +0800 Subject: [PATCH 12/13] test(agent-core-v2): fix telemetry assertions for scope-bound agent_id - undoHistory/goal: include the injected agent_id in exact property assertions - rpc-events: assert on the shared records array instead of a track2 spy, which the scoped telemetry view bypasses --- .../test/agent/goal/goal.test.ts | 2 +- .../test/agent/rpc/undoHistory.test.ts | 2 +- .../test/agent/task/rpc-events.test.ts | 60 +++++++++---------- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 572f590751..b8ed49478d 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -471,7 +471,7 @@ describe('AgentGoalService', () => { 'goal_status_changed', 'goal_cleared', ]); - expect(telemetry[0]?.properties).toEqual({ actor: 'user', replace: true }); + expect(telemetry[0]?.properties).toEqual({ agent_id: 'main', actor: 'user', replace: true }); expect(telemetry[1]?.properties).toMatchObject({ actor: 'model', has_token_budget: true }); expect(telemetry[3]?.properties).toMatchObject({ status: 'paused', actor: 'user' }); expect(JSON.stringify(telemetry)).not.toContain('private objective'); diff --git a/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts b/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts index 6e75e478e1..a607af735d 100644 --- a/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts +++ b/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts @@ -29,7 +29,7 @@ describe('undoHistory RPC', () => { expect(undone).toBe(1); expect(records).toContainEqual({ event: 'conversation_undo', - properties: { count: 1 }, + properties: { agent_id: 'main', count: 1 }, }); }); }); diff --git a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts index 4c2c1a0b34..ab2b08d9e9 100644 --- a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts +++ b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts @@ -35,7 +35,7 @@ import { type TestAgentContext, type TestAgentServiceOverride, } from '../../harness'; -import { recordingTelemetry } from '../../app/telemetry/stubs'; +import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import { executeTool, type TestExecutableToolContext } from '../../tools/fixtures/execute-tool'; import { createAgentTaskPersistence, @@ -147,7 +147,6 @@ interface FakeTaskAgent { emitEvent: ReturnType; emittedEvents: Array<{ type: string; info?: unknown }>; kimiConfig?: { task?: { maxRunningTasks?: number } }; - telemetry: { track2: ReturnType }; context: { appendUserMessage: ReturnType }; hooks?: { fireAndForgetTrigger: FireAndForgetTrigger }; } @@ -156,6 +155,7 @@ interface TaskServiceFixture { ctx: TestAgentContext; agent: FakeTaskAgent; manager: TaskServiceTestManager; + records: TelemetryRecord[]; persistence?: ReturnType; } @@ -174,9 +174,8 @@ function createAgentTaskService(options: { maxRunningTasks?: number; hooks?: FakeTaskAgent['hooks']; } = {}): TaskServiceFixture { - const track = vi.fn(); - const telemetry = recordingTelemetry([]); - vi.spyOn(telemetry, 'track2').mockImplementation(track); + const records: TelemetryRecord[] = []; + const telemetry = recordingTelemetry(records); const hookEngine: Pick | undefined = options.hooks === undefined ? undefined : { @@ -218,7 +217,6 @@ function createAgentTaskService(options: { options.maxRunningTasks === undefined ? undefined : { task: { maxRunningTasks: options.maxRunningTasks } }, - telemetry: { track2: track }, context: { appendUserMessage: appendHistorySpy }, hooks: options.hooks, }; @@ -232,6 +230,7 @@ function createAgentTaskService(options: { ctx, agent, manager: ctx.get(IAgentTaskService) as TaskServiceTestManager, + records, persistence, }; } @@ -307,7 +306,7 @@ describe('AgentTaskService — event emission', () => { }); it('emits task.started for process tasks', () => { - const { agent, manager } = createAgentTaskService(); + const { agent, manager, records } = createAgentTaskService(); const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'demo'); expect(agent.emittedEvents).toContainEqual({ @@ -318,14 +317,14 @@ describe('AgentTaskService — event emission', () => { status: 'running', }), }); - expect(agent.telemetry.track2).toHaveBeenCalledWith('background_task_created', { - task_id: taskId, - kind: 'bash', + expect(records).toContainEqual({ + event: 'background_task_created', + properties: { agent_id: 'main', task_id: taskId, kind: 'bash' }, }); }); it('emits task.started for agent tasks', () => { - const { agent, manager } = createAgentTaskService(); + const { agent, manager, records } = createAgentTaskService(); const taskId = manager.registerTask( agentTask(new Promise(() => {}), 'agent task'), ); @@ -338,16 +337,16 @@ describe('AgentTaskService — event emission', () => { status: 'running', }), }); - expect(agent.telemetry.track2).toHaveBeenCalledWith('background_task_created', { - task_id: taskId, - kind: 'agent', + expect(records).toContainEqual({ + event: 'background_task_created', + properties: { agent_id: 'main', task_id: taskId, kind: 'agent' }, }); }); it('emits task.terminated and telemetry on natural exit', async () => { - const { agent, manager } = createAgentTaskService(); + const { agent, manager, records } = createAgentTaskService(); const taskId = registerProcess(manager, immediateProcess(0), 'echo', 'done'); - agent.telemetry.track2.mockClear(); + records.length = 0; await manager.wait(taskId); @@ -358,39 +357,40 @@ describe('AgentTaskService — event emission', () => { status: 'completed', }), }); - expect(agent.telemetry.track2).toHaveBeenCalledWith( - 'background_task_completed', - expect.objectContaining({ + expect(records).toContainEqual({ + event: 'background_task_completed', + properties: expect.objectContaining({ + agent_id: 'main', task_id: taskId, kind: 'process', duration_ms: expect.any(Number), status: 'completed', }), - ); + }); }); it('tracks failed and timed-out terminal statuses', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const { agent, manager } = createAgentTaskService(); + const { manager, records } = createAgentTaskService(); const failedId = registerProcess(manager, immediateProcess(1), 'false', 'failed'); const timedOutId = manager.registerTask( agentTask(new Promise(() => {}), 'slow agent', { timeoutMs: 1 }), ); - agent.telemetry.track2.mockClear(); + records.length = 0; await manager.wait(failedId); const timedOut = manager.wait(timedOutId); await vi.advanceTimersByTimeAsync(5_010); await timedOut; - expect(agent.telemetry.track2).toHaveBeenCalledWith( - 'background_task_completed', - expect.objectContaining({ kind: 'process', status: 'failed' }), - ); - expect(agent.telemetry.track2).toHaveBeenCalledWith( - 'background_task_completed', - expect.objectContaining({ kind: 'agent', status: 'timed_out' }), - ); + expect(records).toContainEqual({ + event: 'background_task_completed', + properties: expect.objectContaining({ agent_id: 'main', kind: 'process', status: 'failed' }), + }); + expect(records).toContainEqual({ + event: 'background_task_completed', + properties: expect.objectContaining({ agent_id: 'main', kind: 'agent', status: 'timed_out' }), + }); }); it('emits task.terminated on stop', async () => { From b6546cfb389c08144b3bbb0513685a10448a520f Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 20 Jul 2026 21:47:50 +0800 Subject: [PATCH 13/13] refactor(agent-core-v2): bind agent identity ambiently in telemetry - TelemetryService.withContext now returns a lightweight forwarding view: transport state (appenders, enabled flag) stays with the App-scoped root, so views created before an appender attaches no longer silently drop events, and enablement changes apply to every live view. - Agent-scope events register with defineAgentTelemetryEvent and compose the centrally declared AgentTelemetryEventContext (agent_id) into their wire schema; business payloads and call sites stay free of agent_id, enforced at compile time and by the registry convention test. - image_compress/image_crop stay plain events: the kap-server prompt routes emit them through a session-scoped view without agent identity. - v1 subagent_created gains parent_tool_call_id for parity with v2. - Add a lifecycle test asserting each agent scope's telemetry view binds its own agent id. --- .agents/skills/agent-core-dev/telemetry.md | 20 +- .changeset/telemetry-agent-id.md | 7 - packages/agent-core-v2/AGENTS.md | 5 +- .../fullCompaction/fullCompactionService.ts | 3 - .../agent/llmRequester/llmRequesterService.ts | 1 - .../src/agent/loop/loopService.ts | 5 +- .../policies/exit-plan-mode-review-ask.ts | 6 +- .../app/telemetry/agentTelemetryContext.ts | 14 +- .../telemetry/agentTelemetryContextService.ts | 13 +- .../agent-core-v2/src/app/telemetry/events.ts | 176 ++++++++---------- .../src/app/telemetry/telemetry.ts | 6 +- .../src/app/telemetry/telemetryService.ts | 75 ++++++-- .../test/agent/profile/profileOps.test.ts | 2 +- .../telemetry/agentTelemetryContext.test.ts | 15 +- .../test/app/telemetry/events.test.ts | 27 ++- .../app/telemetry/telemetryService.test.ts | 20 +- .../agentLifecycle/agentLifecycle.test.ts | 20 ++ .../agent-core/src/session/subagent-host.ts | 1 + .../test/session/subagent-host.test.ts | 1 + 19 files changed, 244 insertions(+), 173 deletions(-) delete mode 100644 .changeset/telemetry-agent-id.md diff --git a/.agents/skills/agent-core-dev/telemetry.md b/.agents/skills/agent-core-dev/telemetry.md index 5eaa274135..0d04dc5da4 100644 --- a/.agents/skills/agent-core-dev/telemetry.md +++ b/.agents/skills/agent-core-dev/telemetry.md @@ -2,13 +2,14 @@ Telemetry infrastructure for agent-core-v2: how business services emit events, how context propagates, and how events reach a destination through appenders. -Telemetry is a **layer-1 root** domain (alongside `log`): pure `App` scope, stateless, no business-domain dependencies. It is a thin facade — enrichment, batching, and transport belong to the appenders, not to this layer. +Telemetry is a **layer-1 root** domain (alongside `log`): the facade lives at `App` scope (a per-Agent ambient context service is bound at `Agent` scope), stateless, with no business-domain dependencies. It is a thin facade — enrichment, batching, and transport belong to the appenders, not to this layer. ## Where things live - `src/app/telemetry/telemetry.ts`: contract — `ITelemetryService` (facade), `ITelemetryAppender` (destination), `TelemetryProperties`, `nullTelemetryAppender`, and `TelemetryServiceOptions`. -- `src/app/telemetry/events.ts`: event registry — `telemetryEventDefinitions` pairs every business event's property type with review metadata (owner / purpose / per-property comment); the single source of truth for `track2`. +- `src/app/telemetry/events.ts`: event registry — `telemetryEventDefinitions` pairs every business event's property type with review metadata (owner / purpose / per-property comment); the single source of truth for `track2`. Agent-scope events register with `defineAgentTelemetryEvent

` and compose the ambient `AgentTelemetryEventContext` (`agent_id`) into their wire schema; all other events register with `defineTelemetryEvent

`. - `src/app/telemetry/telemetryService.ts`: `TelemetryService` impl + `registerScopedService(LifecycleScope.App, …)`. +- `src/app/telemetry/agentTelemetryContext.ts` + `agentTelemetryContextService.ts`: `IAgentTelemetryContextService` — Agent-scoped mutable request context (`mode` / `provider_type` / `protocol` / `turn_id` / `trace_id`) snapshot into turn telemetry at launch. Agent identity (`agent_id`) is not part of it — identity is bound by the Agent-scoped `ITelemetryService` view. - `src/app/telemetry/consoleAppender.ts`: `ConsoleAppender` — echoes events to a log function (dev / debug). - `src/app/telemetry/cloudAppender.ts`: `CloudAppender` — sanitizes + PII-cleans properties, batches + enriches + posts to the telemetry endpoint. - `src/app/telemetry/cloudTransport.ts`: `CloudTransport` — HTTP transport behind `CloudAppender`. @@ -26,20 +27,20 @@ constructor(@ITelemetryService private readonly telemetry: ITelemetryService) {} this.telemetry.track2('cron_fired', { task_id: taskId, coalesced_count: 0, stale: false, buffered: false, recurring: true }); ``` -`track2` is checked against the registry in `events.ts` at compile time: the event name must be a key of `telemetryEventDefinitions`, and the properties must match the registered interface exactly (extra or missing keys are compile errors). **New events must be registered first** — add a properties interface and a `defineTelemetryEvent

({ owner, comment, properties })` entry documenting every property. Naming: snake_case for events and properties, unit suffixes (`_ms` / `_count` / `_bytes`), no user content or file paths; `test/app/telemetry/events.test.ts` enforces the conventions. The low-level `track` remains for appender plumbing and tests only. +`track2` is checked against the registry in `events.ts` at compile time: the event name must be a key of `telemetryEventDefinitions`, and the properties must match the registered interface exactly (extra or missing keys are compile errors). **New events must be registered first** — add a properties interface, then register it with `defineAgentTelemetryEvent

({ owner, comment, properties })` when every emission path goes through an Agent-scoped `ITelemetryService` view, or `defineTelemetryEvent

` otherwise (including events with any non-Agent emission path, e.g. `image_compress` from the kap-server prompt routes), documenting every property. For agent-scope events the registered interface is the business payload only: ambient `agent_id` is declared once in `AgentTelemetryEventContext` and composed into the wire schema, so it must not appear in the payload or at call sites. Naming: snake_case for events and properties, unit suffixes (`_ms` / `_count` / `_bytes`), no user content or file paths; `test/app/telemetry/events.test.ts` enforces the conventions. The low-level `track` remains for appender plumbing and tests only. `TelemetryService.track` merges the bound context into the properties and fans the event out to every registered appender. A single throwing appender is isolated via `onUnexpectedError` and never blocks the rest. -### Context (sessionId / agentId / turnId) +### Context (sessionId / agent_id / turn_id) -The service carries a bound context (`sessionId` / `agentId` / `turnId`) that is merged into every event. Bind it at construction or derive a scoped view: +The root service carries a bound context (`sessionId`) that is merged into every event, and each Agent scope gets its own telemetry view seeded with `agent_id` (by `agentLifecycle`), so Agent-scoped services emit their identity without call-site plumbing. Mutable per-agent request context (`mode` / `provider_type` / `protocol` / `turn_id` / `trace_id`) lives in `IAgentTelemetryContextService` and is snapshot into a per-turn view at turn launch. Derive a scoped view with `withContext`: ```ts -const child = telemetry.withContext({ agentId: 'main', turnId: 't1' }); -child.track2('tool_call', { turn_id: 1, tool_call_id: 'c1', tool_name: 'bash', outcome: 'success', duration_ms: 12 }); // carries sessionId + agentId + turnId +const child = telemetry.withContext({ agent_id: 'agent-0' }); +child.track2('tool_call', { turn_id: 1, tool_call_id: 'c1', tool_name: 'bash', outcome: 'success', duration_ms: 12 }); // wire carries sessionId + agent_id ``` -`withContext(patch)` returns a new service sharing the same appenders; per-call properties override bound context on key collision. `setContext(patch)` mutates the bound context in place and propagates to appenders that implement `setContext`. +`withContext(patch)` returns a lightweight forwarding view: transport state (appenders, enabled flag) stays with the root, so later `addAppender` / `setEnabled` calls apply to every view, and per-call properties override bound context on key collision. `setContext(patch)` on the root mutates the root context and propagates to appenders that implement `setContext`; on a view it mutates only that view's own context. ## Appenders (destinations) @@ -88,8 +89,9 @@ telemetry.addAppender(new CloudAppender({ // production ## Red lines (this topic) - Business services depend only on `ITelemetryService` — never import an appender class. -- Telemetry is layer-1 root: do not inject any business-domain service into it, and do not move it off `App`. +- Telemetry is layer-1 root: do not inject any business-domain service into it, and keep the facade at `App` scope (only the ambient context service binds at `Agent`). - Appenders are plain `ITelemetryAppender` objects, not DI Services — register them with `addAppender`, never via `registerScopedService`. - `track` is fire-and-forget and must not throw; appender `track` must be synchronous — buffer and send asynchronously via `flush` / `shutdown`. - Await `telemetry.shutdown()` before process exit when a buffering appender is registered. - Keep event names stable; register every business event in `events.ts` and emit via `track2` — properties must be JSON-serializable primitives (non-primitives are dropped with a warning by `CloudAppender`). +- Agent identity is ambient: agent-scope events go through `defineAgentTelemetryEvent` and get `agent_id` from the scoped telemetry view — do not pass `agent_id` at business call sites (per-event identities such as `subagent_created` and the cron events are the exception). diff --git a/.changeset/telemetry-agent-id.md b/.changeset/telemetry-agent-id.md deleted file mode 100644 index cc807ae528..0000000000 --- a/.changeset/telemetry-agent-id.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/agent-core-v2": patch -"@moonshot-ai/kimi-code": patch ---- - -Emit `turn_id` and `agent_id` on turn, tool, and agent-level telemetry events so activity can be attributed to the main agent or a specific subagent within a session. diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index 665401a454..af3ccf11cc 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -32,7 +32,10 @@ Impl (`src/session/sessionMetadata/sessionMetadataService.ts`): ## Telemetry -Business events go through `ITelemetryService.track2` — never the low-level `track`, which exists only for appender plumbing and tests. Every event must be registered in `src/app/telemetry/events.ts` (`telemetryEventDefinitions`) before it is emitted: define a properties interface, register it with `defineTelemetryEvent

({ owner, comment, properties })`, and document every property — the compiler rejects unregistered event names and any property mismatch at the call site. +Business events go through `ITelemetryService.track2` — never the low-level `track`, which exists only for appender plumbing and tests. Every event must be registered in `src/app/telemetry/events.ts` (`telemetryEventDefinitions`) before it is emitted: define a properties interface and document every property, then register it one of two ways — the compiler rejects unregistered event names and any property mismatch at the call site. + +- Events whose every emission path goes through an Agent-scoped `ITelemetryService` view use `defineAgentTelemetryEvent

({ owner, comment, properties })`. `agent_id` is ambient Agent identity — declared once as `AgentTelemetryEventContext`, composed into the wire schema, and bound at runtime by the Agent-scoped `ITelemetryService` view that `agentLifecycle` seeds. Keep it out of the payload interface and out of call sites. +- All other events use `defineTelemetryEvent

({ owner, comment, properties })`. This includes Session/App-level events and events with any non-Agent emission path (e.g. `image_compress`, which the kap-server prompt routes emit through a session-scoped view). Per-event agent identity outside the Agent scope (e.g. `subagent_created`, `cron_scheduled`) stays as explicit `agent_id` business properties. - **Naming**: event names and property keys are snake_case (`tool_call`, `duration_ms`). Durations, counts, and sizes carry a unit suffix (`_ms` / `_count` / `_bytes`). Use specific names (`error_type`, not `error`). - **Privacy**: never register user content, prompts, or file paths as properties. `CloudAppender` redacts URLs, emails, tokens, and absolute paths from string values before events leave the process, but that is a safety net, not a license. diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 1e16ba5ce1..cbed1b7d4f 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -560,9 +560,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull type: 'operation', turnId: active.originTurnId, requestKind: 'full_compaction', - // Per-attempt count of messages dropped by overflow/empty - // shrinks so far; recorded on the llm.request wire op so a - // replay can see how much history each retry round blinded. logFields: { droppedCount }, }, }, diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 418a0ac2d5..f0241c9926 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -245,7 +245,6 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { trace_id: traceId, }; if (source?.type === 'turn') { - properties['turn_id'] = source.turnId; if (source.step !== undefined) properties['step_no'] = source.step; } const statusCode = apiStatusCode(error); diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 216ed5c380..430d79b154 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -377,14 +377,13 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.telemetryContext.set({ turn_id: turn.id }); const telemetryContext = this.telemetryContext.get(); const turnTelemetry = this.telemetry.withContext(telemetryContext); - const { agent_id, mode, provider_type, protocol } = telemetryContext; + const { mode, provider_type, protocol } = telemetryContext; let thinkingEffort: string | undefined; let result: TurnResult | undefined; try { thinkingEffort = this.llmRequester.prepareTurnConfig(turn.id)?.thinkingEffort; const started: TurnStartedTelemetryEvent = { turn_id: turn.id, - agent_id, mode, provider_type, protocol, @@ -420,7 +419,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (result.type !== 'completed') { const interrupted: TurnInterruptedEvent = { turn_id: turn.id, - agent_id, at_step: result.steps, mode, interrupt_reason: interruptReasonFor(result), @@ -434,7 +432,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } const ended: TurnEndedTelemetryEvent = { turn_id: turn.id, - agent_id, reason: result?.type ?? 'failed', duration_ms: Date.now() - startedAt, mode, diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts index ddeb1a162b..29c0b98e15 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts @@ -165,11 +165,11 @@ export class ExitPlanModeReviewAskPermissionPolicyService implements PermissionP this.trackPlanTelemetry('plan_resolved', { outcome: 'rejected' }); } - private trackPlanTelemetry(event: 'plan_submitted', properties: Omit): void; - private trackPlanTelemetry(event: 'plan_resolved', properties: Omit): void; + private trackPlanTelemetry(event: 'plan_submitted', properties: PlanSubmittedEvent): void; + private trackPlanTelemetry(event: 'plan_resolved', properties: PlanResolvedEvent): void; private trackPlanTelemetry( event: 'plan_submitted' | 'plan_resolved', - properties: Omit | Omit, + properties: PlanSubmittedEvent | PlanResolvedEvent, ): void { if (event === 'plan_submitted') { this.telemetry.track2('plan_submitted', properties as PlanSubmittedEvent); diff --git a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts index 3735f6a638..901cdb36a3 100644 --- a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts +++ b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts @@ -1,21 +1,17 @@ /** * `telemetry` domain (L1) — `IAgentTelemetryContextService` contract. * - * Agent-scoped ambient telemetry context: a per-agent property bag that domains - * contribute to (the `plan` domain sets `mode`, the `profile` domain mirrors - * the resolved model protocol into `provider_type` / `protocol`, the `loop` - * domain sets `turn_id` at turn start and keeps `trace_id` at the active turn's - * most recent request) and that turn-scoped - * telemetry snapshots at launch. Decouples turn telemetry from any - * specific contributor so the turn domain does not need to know about plan or - * profile. Bound at Agent scope. + * Agent-scoped mutable request context: the `plan` domain sets `mode`, the + * `profile` domain mirrors the resolved model protocol into `provider_type` / + * `protocol`, and the `loop` domain sets `turn_id` and `trace_id`. Turn + * telemetry snapshots it at launch; immutable Agent identity is owned by the + * scoped `ITelemetryService` view instead. Bound at Agent scope. */ import { createDecorator } from '#/_base/di/instantiation'; export type AgentTelemetryContext = { mode: 'agent' | 'plan'; - agent_id: string; provider_type?: string; protocol?: string; turn_id?: number; diff --git a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts index 62c5ec3367..64c6f7c365 100644 --- a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts +++ b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContextService.ts @@ -1,15 +1,14 @@ /** * `telemetry` domain (L1) — `IAgentTelemetryContextService` implementation. * - * Holds the agent's ambient telemetry context (defaults to `mode: 'agent'`); - * merged into turn telemetry through `ITelemetryService.withContext` at turn - * launch. Reads the current agent identity from `scopeContext`. Bound at Agent - * scope. + * Holds mutable request context (defaulting to `mode: 'agent'`) that turn + * telemetry snapshots through `ITelemetryService.withContext` at launch. + * Immutable Agent identity is owned by the scoped telemetry view. Bound at + * Agent scope; has no cross-domain collaborators. */ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentTelemetryContextService, type AgentTelemetryContext, @@ -19,8 +18,8 @@ export class AgentTelemetryContextService implements IAgentTelemetryContextServi declare readonly _serviceBrand: undefined; private context: AgentTelemetryContext; - constructor(@IAgentScopeContext scopeContext: IAgentScopeContext) { - this.context = { mode: 'agent', agent_id: scopeContext.agentId }; + constructor() { + this.context = { mode: 'agent' }; } get(): AgentTelemetryContext { diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 8381034f2d..73dd3f82f8 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -5,14 +5,14 @@ * `ITelemetryService.track2`: each entry pairs the event's property type * (the compile-time contract enforced at call sites) with review metadata * (owner, purpose, per-property comment) whose keys must match the property - * type exactly. Registered names are the raw event names, before the - * transport's `kfc_` server prefix. Naming conventions: events and - * properties are snake_case; durations/counts/sizes carry a unit suffix - * (`_ms` / `_count` / `_bytes`); never register user content or file paths - * as properties. Agent-scoped telemetry binds `agent_id` through the scoped - * `ITelemetryService` view, so that property is optional at individual call - * sites. App-scoped, self-contained — property unions are declared locally - * instead of imported from business domains. + * type exactly. Agent-scoped entries compose their payload with the centrally + * declared Agent telemetry context, keeping ambient identity out of business + * payloads while preserving the effective wire schema. Registered names are + * the raw event names, before the transport's `kfc_` server prefix. Naming + * conventions: events and properties are snake_case; durations/counts/sizes + * carry a unit suffix (`_ms` / `_count` / `_bytes`); never register user + * content or file paths as properties. App-scoped, self-contained — property + * unions are declared locally instead of imported from business domains. */ import type { TelemetryPrimitive } from './telemetry'; @@ -23,7 +23,20 @@ export interface TelemetryEventMeta { readonly properties: Readonly>; } -export interface TelemetryEventDefinition

{ +export interface AgentTelemetryEventContext { + agent_id: string; +} + +export const agentTelemetryContextProperties: { + readonly [K in keyof AgentTelemetryEventContext]-?: string; +} = { + agent_id: 'Agent id (main or subagent scope id)', +}; + +export type TelemetryEventContext = 'none' | 'agent'; + +export interface TelemetryEventDefinition { + readonly context: C; readonly meta: TelemetryEventMeta; readonly _properties?: P; } @@ -31,7 +44,13 @@ export interface TelemetryEventDefinition

{ export function defineTelemetryEvent

( meta: TelemetryEventMeta & { readonly properties: { [K in keyof P]-?: string } }, ): TelemetryEventDefinition

{ - return { meta }; + return { context: 'none', meta }; +} + +export function defineAgentTelemetryEvent

( + meta: TelemetryEventMeta & { readonly properties: { [K in keyof P]-?: string } }, +): TelemetryEventDefinition { + return { context: 'agent', meta }; } export type StrictPropertyCheck = string extends keyof T @@ -46,7 +65,6 @@ export type StrictPropertyCheck = string extends keyof T export interface TurnStartedEvent { turn_id: number; - agent_id?: string; mode: 'agent' | 'plan'; provider_type?: string; protocol?: string; @@ -55,7 +73,6 @@ export interface TurnStartedEvent { export interface TurnInterruptedEvent { turn_id: number; - agent_id?: string; at_step: number; mode: 'agent' | 'plan'; interrupt_reason: 'user_cancelled' | 'aborted' | 'max_steps' | 'error' | 'filtered' | 'blocked'; @@ -67,7 +84,6 @@ export interface TurnInterruptedEvent { export interface TurnEndedEvent { turn_id: number; - agent_id?: string; reason: 'completed' | 'cancelled' | 'failed'; duration_ms: number; mode: 'agent' | 'plan'; @@ -81,7 +97,6 @@ export type ToolCallOutcome = 'success' | 'error' | 'cancelled'; export interface ToolCallEvent { turn_id: number; - agent_id?: string; tool_call_id: string; tool_name: string; outcome: ToolCallOutcome; @@ -93,7 +108,6 @@ export interface ToolCallEvent { export interface ApiErrorEvent { error_type: string; - agent_id?: string; model: string; alias?: string; retryable: boolean; @@ -109,46 +123,38 @@ export interface ApiErrorEvent { } export interface SkillInvokedEvent { - agent_id?: string; skill_name: string; trigger: 'user-slash' | 'model-tool' | 'nested-skill'; } export interface FlowInvokedEvent { - agent_id?: string; flow_name: string; } export interface InputSteerEvent { - agent_id?: string; parts: number; } export interface CancelEvent { - agent_id?: string; from: 'streaming' | 'compacting'; trace_id?: string; } export interface ConversationUndoEvent { - agent_id?: string; count: number; } export interface YoloToggleEvent { - agent_id?: string; enabled: boolean; } export interface AfkToggleEvent { - agent_id?: string; enabled: boolean; } export type TelemetryPermissionMode = 'manual' | 'yolo' | 'auto'; export interface PermissionPolicyDecisionEvent { - agent_id?: string; turn_id: number; tool_call_id: string; policy_name: string; @@ -159,7 +165,6 @@ export interface PermissionPolicyDecisionEvent { } export interface PermissionApprovalResultEvent { - agent_id?: string; turn_id: number; tool_call_id: string; policy_name: string | null; @@ -174,12 +179,10 @@ export interface PermissionApprovalResultEvent { } export interface PlanSubmittedEvent { - agent_id?: string; has_options: boolean; } export interface PlanResolvedEvent { - agent_id?: string; outcome: | 'approved' | 'dismissed' @@ -192,12 +195,10 @@ export interface PlanResolvedEvent { } export interface PlanEnterResolvedEvent { - agent_id?: string; outcome: 'auto_approved'; } export interface CompactionFinishedEvent { - agent_id?: string; turn_id?: number; source: 'manual' | 'auto'; tokens_before: number; @@ -216,7 +217,6 @@ export interface CompactionFinishedEvent { } export interface CompactionFailedEvent { - agent_id?: string; turn_id?: number; source: 'manual' | 'auto'; tokens_before: number; @@ -229,7 +229,6 @@ export interface CompactionFailedEvent { } export interface ContextProjectionRepairedEvent { - agent_id?: string; reordered: number; synthesized: number; dropped_orphan: number; @@ -241,13 +240,11 @@ export interface ContextProjectionRepairedEvent { } export interface BackgroundTaskCreatedEvent { - agent_id?: string; task_id: string; kind: 'bash' | 'agent' | 'question'; } export interface BackgroundTaskCompletedEvent { - agent_id?: string; task_id: string; kind: 'agent' | 'process' | 'question'; duration_ms: number | null; @@ -255,12 +252,10 @@ export interface BackgroundTaskCompletedEvent { } export interface ModelSwitchEvent { - agent_id?: string; model: string; } export interface ThinkingToggleEvent { - agent_id?: string; enabled: boolean; effort: string; from: string; @@ -311,7 +306,6 @@ export interface GoalStatusChangedEvent extends GoalBudgetProperties { export interface ToolCallDedupDetectedEvent { turn_id?: number; - agent_id?: string; step_no: number; tool_call_id: string; tool_name: string; @@ -321,7 +315,6 @@ export interface ToolCallDedupDetectedEvent { } export interface ToolCallRepeatEvent { - agent_id?: string; turn_id?: number; tool_name: string; repeat_count: number; @@ -448,24 +441,22 @@ export interface ExitEvent { } export const telemetryEventDefinitions = { - turn_started: defineTelemetryEvent({ + turn_started: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A turn starts running.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', - agent_id: 'Agent id (main or subagent scope id)', mode: 'Agent mode the turn runs in', provider_type: 'Provider protocol type', protocol: 'Request protocol', thinking_effort: 'Effective thinking effort the turn runs with', }, }), - turn_interrupted: defineTelemetryEvent({ + turn_interrupted: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A running turn is interrupted.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', - agent_id: 'Agent id (main or subagent scope id)', at_step: 'Step index the turn reached before interruption', mode: 'Agent mode the turn ran in', interrupt_reason: 'Why the turn was interrupted', @@ -476,12 +467,11 @@ export const telemetryEventDefinitions = { 'Trace id of the most recent LLM request in this turn (the failed request when the turn errored); absent for non-Kimi protocols', }, }), - turn_ended: defineTelemetryEvent({ + turn_ended: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A turn ends, unconditionally.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', - agent_id: 'Agent id (main or subagent scope id)', reason: 'How the turn ended', duration_ms: 'Turn wall-clock time in milliseconds', mode: 'Agent mode the turn ran in', @@ -492,12 +482,11 @@ export const telemetryEventDefinitions = { 'Trace id of the most recent LLM request in this turn; absent for non-Kimi protocols', }, }), - tool_call: defineTelemetryEvent({ + tool_call: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A tool call finishes execution.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', - agent_id: 'Agent id (main or subagent scope id)', tool_call_id: 'Provider-assigned tool call id', tool_name: 'Registered tool name', outcome: 'Execution outcome', @@ -508,12 +497,11 @@ export const telemetryEventDefinitions = { 'Trace id of the LLM request that produced this tool call; absent for non-Kimi protocols', }, }), - api_error: defineTelemetryEvent({ + api_error: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'An LLM API request fails.', properties: { error_type: 'Classified error category', - agent_id: 'Agent id (main or subagent scope id)', model: 'Model id the request targeted', alias: 'Model alias the request targeted', retryable: 'Whether the error is retryable', @@ -529,61 +517,56 @@ export const telemetryEventDefinitions = { 'Trace id of the failed request, from its response headers or its error response; absent when the failure happened before any response headers arrived (network errors, local aborts), and for non-Kimi protocols', }, }), - skill_invoked: defineTelemetryEvent({ + skill_invoked: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A skill is invoked.', properties: { - agent_id: 'Agent id (main or subagent scope id)', skill_name: 'Skill name', trigger: 'How the skill was triggered', }, }), - flow_invoked: defineTelemetryEvent({ + flow_invoked: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A flow-type skill is invoked.', - properties: { agent_id: 'Agent id (main or subagent scope id)', flow_name: 'Flow name' }, + properties: { flow_name: 'Flow name' }, }), - input_steer: defineTelemetryEvent({ + input_steer: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'The user steers input while a turn is running.', properties: { - agent_id: 'Agent id (main or subagent scope id)', parts: 'Number of input parts', }, }), - cancel: defineTelemetryEvent({ + cancel: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'The user cancels ongoing work.', properties: { - agent_id: 'Agent id (main or subagent scope id)', from: 'What was running when cancelled', trace_id: 'Trace id of the in-flight request, or of the most recent request between steps; absent for non-Kimi protocols', }, }), - conversation_undo: defineTelemetryEvent({ + conversation_undo: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'The user undoes conversation entries.', properties: { - agent_id: 'Agent id (main or subagent scope id)', count: 'Number of entries undone', }, }), - yolo_toggle: defineTelemetryEvent({ + yolo_toggle: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'Yolo permission mode is toggled.', - properties: { agent_id: 'Agent id (main or subagent scope id)', enabled: 'Whether yolo mode is now enabled' }, + properties: { enabled: 'Whether yolo mode is now enabled' }, }), - afk_toggle: defineTelemetryEvent({ + afk_toggle: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'AFK (auto) permission mode is toggled.', - properties: { agent_id: 'Agent id (main or subagent scope id)', enabled: 'Whether auto mode is now enabled' }, + properties: { enabled: 'Whether auto mode is now enabled' }, }), - permission_policy_decision: defineTelemetryEvent({ + permission_policy_decision: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A permission policy evaluates a tool call.', properties: { - agent_id: 'Agent id (main or subagent scope id)', turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', tool_call_id: 'Provider-assigned tool call id', policy_name: 'Name of the deciding policy', @@ -592,11 +575,10 @@ export const telemetryEventDefinitions = { decision: 'Policy decision', }, }), - permission_approval_result: defineTelemetryEvent({ + permission_approval_result: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A permission approval prompt resolves.', properties: { - agent_id: 'Agent id (main or subagent scope id)', turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', tool_call_id: 'Provider-assigned tool call id', policy_name: 'Name of the asking policy, null when unknown', @@ -611,37 +593,33 @@ export const telemetryEventDefinitions = { 'Trace id of the LLM request that produced the gated tool call; absent for non-Kimi protocols', }, }), - plan_submitted: defineTelemetryEvent({ + plan_submitted: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A plan is submitted for review.', properties: { - agent_id: 'Agent id (main or subagent scope id)', has_options: 'Whether the plan offered selectable options', }, }), - plan_resolved: defineTelemetryEvent({ + plan_resolved: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A submitted plan is resolved.', properties: { - agent_id: 'Agent id (main or subagent scope id)', outcome: 'How the plan was resolved', chosen_option: 'Label of the option the user chose', has_feedback: 'Whether the user attached revision feedback', }, }), - plan_enter_resolved: defineTelemetryEvent({ + plan_enter_resolved: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A request to enter plan mode is resolved.', properties: { - agent_id: 'Agent id (main or subagent scope id)', outcome: 'How the request was resolved', }, }), - compaction_finished: defineTelemetryEvent({ + compaction_finished: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'Context compaction completes.', properties: { - agent_id: 'Agent id (main or subagent scope id)', turn_id: 'Per-agent turn index when compaction ran inside a turn; omitted for manual compaction between turns', source: 'Whether compaction was triggered manually or automatically', tokens_before: 'Token count before compaction', @@ -660,11 +638,10 @@ export const telemetryEventDefinitions = { 'Trace id of the final compaction request round; absent for non-Kimi protocols', }, }), - compaction_failed: defineTelemetryEvent({ + compaction_failed: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'Context compaction fails.', properties: { - agent_id: 'Agent id (main or subagent scope id)', turn_id: 'Per-agent turn index when compaction ran inside a turn; omitted for manual compaction between turns', source: 'Whether compaction was triggered manually or automatically', tokens_before: 'Token count before compaction', @@ -677,11 +654,10 @@ export const telemetryEventDefinitions = { 'Trace id of the failed compaction request, from its response headers or its error response; absent when the failure happened before any request or before response headers arrived (network errors), and for non-Kimi protocols', }, }), - context_projection_repaired: defineTelemetryEvent({ + context_projection_repaired: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'The context projector repairs the outgoing request to keep it wire-valid.', properties: { - agent_id: 'Agent id (main or subagent scope id)', reordered: 'Tool results moved back next to their call', synthesized: 'Placeholder results invented for lost ones', dropped_orphan: 'Results with no matching call dropped', @@ -692,42 +668,39 @@ export const telemetryEventDefinitions = { whitespace_dropped: 'Whitespace-only text blocks dropped', }, }), - background_task_created: defineTelemetryEvent({ + background_task_created: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A background task is created.', properties: { - agent_id: 'Agent id (main or subagent scope id)', task_id: 'Background task id; joins background_task_created with background_task_completed', kind: 'Task kind; process tasks retain the legacy bash value', }, }), - background_task_completed: defineTelemetryEvent({ + background_task_completed: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A background task reaches a terminal state.', properties: { - agent_id: 'Agent id (main or subagent scope id)', task_id: 'Background task id; joins background_task_created with background_task_completed', kind: 'Task kind', duration_ms: 'Task wall-clock time in milliseconds, null when unknown', status: 'Terminal task status', }, }), - model_switch: defineTelemetryEvent({ + model_switch: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'The active model is bound or switched.', - properties: { agent_id: 'Agent id (main or subagent scope id)', model: 'Model alias' }, + properties: { model: 'Model alias' }, }), - thinking_toggle: defineTelemetryEvent({ + thinking_toggle: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'Thinking effort is toggled.', properties: { - agent_id: 'Agent id (main or subagent scope id)', enabled: 'Whether thinking is now enabled', effort: 'New thinking effort level', from: 'Previous thinking effort level', }, }), - question_dismissed: defineTelemetryEvent({ + question_dismissed: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A user question prompt is dismissed.', properties: { @@ -735,7 +708,7 @@ export const telemetryEventDefinitions = { 'Trace id of the LLM request that produced the questioning tool call; absent for non-Kimi protocols', }, }), - question_answered: defineTelemetryEvent({ + question_answered: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A user question prompt is answered.', properties: { @@ -745,7 +718,7 @@ export const telemetryEventDefinitions = { 'Trace id of the LLM request that produced the questioning tool call; absent for non-Kimi protocols', }, }), - goal_created: defineTelemetryEvent({ + goal_created: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A goal is created.', properties: { @@ -753,7 +726,7 @@ export const telemetryEventDefinitions = { replace: 'Whether the goal replaces an existing one', }, }), - goal_budget_set: defineTelemetryEvent({ + goal_budget_set: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A goal budget is set.', properties: { @@ -763,17 +736,17 @@ export const telemetryEventDefinitions = { has_wall_clock_budget: 'Whether a wall-clock budget was set', }, }), - goal_continued: defineTelemetryEvent({ + goal_continued: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A goal continues into another turn.', properties: { turns_used: 'Turns consumed so far' }, }), - goal_cleared: defineTelemetryEvent({ + goal_cleared: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A goal is cleared.', properties: { actor: 'Who cleared the goal' }, }), - goal_status_changed: defineTelemetryEvent({ + goal_status_changed: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A goal changes status.', properties: { @@ -787,12 +760,11 @@ export const telemetryEventDefinitions = { has_wall_clock_budget: 'Whether a wall-clock budget was set', }, }), - tool_call_dedup_detected: defineTelemetryEvent({ + tool_call_dedup_detected: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A duplicate tool call is detected.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session; omitted when no turn is active', - agent_id: 'Agent id (main or subagent scope id)', step_no: 'Step index within the turn', tool_call_id: 'Provider-assigned tool call id', tool_name: 'Registered tool name', @@ -802,11 +774,10 @@ export const telemetryEventDefinitions = { 'Trace id of the LLM request that produced the duplicate tool call; absent for non-Kimi protocols', }, }), - tool_call_repeat: defineTelemetryEvent({ + tool_call_repeat: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A repeated tool call streak is detected.', properties: { - agent_id: 'Agent id (main or subagent scope id)', turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session; omitted when no turn is active', tool_name: 'Registered tool name', repeat_count: 'Length of the repeat streak', @@ -815,7 +786,7 @@ export const telemetryEventDefinitions = { 'Trace id of the LLM request that produced the repeated tool call; absent for non-Kimi protocols', }, }), - grep_tool_rg_fallback: defineTelemetryEvent({ + grep_tool_rg_fallback: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'The grep tool falls back when resolving ripgrep.', properties: { @@ -823,7 +794,7 @@ export const telemetryEventDefinitions = { outcome: 'Whether the fallback resolved or failed', }, }), - glob_tool_rg_fallback: defineTelemetryEvent({ + glob_tool_rg_fallback: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'The glob tool falls back when resolving ripgrep.', properties: { @@ -927,7 +898,7 @@ export const telemetryEventDefinitions = { duration_ms: 'Crop wall-clock time in milliseconds', }, }), - video_upload: defineTelemetryEvent({ + video_upload: defineAgentTelemetryEvent({ owner: 'kimi-code', comment: 'A video is uploaded for the model.', properties: { @@ -967,5 +938,12 @@ export type TelemetryEventRegistry = typeof telemetryEventDefinitions; export type TelemetryEventName = keyof TelemetryEventRegistry; +export type TelemetryEventPayload = + TelemetryEventRegistry[K] extends TelemetryEventDefinition + ? P + : never; + export type TelemetryEventProperties = - TelemetryEventRegistry[K] extends TelemetryEventDefinition ? P : never; + TelemetryEventRegistry[K] extends TelemetryEventDefinition + ? P & (C extends 'agent' ? AgentTelemetryEventContext : object) + : never; diff --git a/packages/agent-core-v2/src/app/telemetry/telemetry.ts b/packages/agent-core-v2/src/app/telemetry/telemetry.ts index 2b2aa09df2..f9d61c49d8 100644 --- a/packages/agent-core-v2/src/app/telemetry/telemetry.ts +++ b/packages/agent-core-v2/src/app/telemetry/telemetry.ts @@ -15,7 +15,7 @@ import type { IDisposable } from '#/_base/di/lifecycle'; import type { StrictPropertyCheck, TelemetryEventName, - TelemetryEventProperties, + TelemetryEventPayload, } from './events'; export type TelemetryPrimitive = string | number | boolean | null | undefined; @@ -45,9 +45,9 @@ export interface ITelemetryService { readonly _serviceBrand: undefined; track(event: string, properties?: TelemetryProperties): void; - track2 = never>( + track2 = never>( event: K, - properties?: StrictPropertyCheck, E>, + properties?: StrictPropertyCheck, E>, ): void; withContext(patch: TelemetryContextPatch): ITelemetryService; setContext(patch: TelemetryContextPatch): void; diff --git a/packages/agent-core-v2/src/app/telemetry/telemetryService.ts b/packages/agent-core-v2/src/app/telemetry/telemetryService.ts index e5eb2d68d1..e361946b2c 100644 --- a/packages/agent-core-v2/src/app/telemetry/telemetryService.ts +++ b/packages/agent-core-v2/src/app/telemetry/telemetryService.ts @@ -1,10 +1,10 @@ /** * `telemetry` domain (L1) — `ITelemetryService` implementation. * - * Merges bound context into each tracked event and fans it out to the - * registered `ITelemetryAppender` destinations; owns the appender set, the - * enabled flag, and the bound context, but no enrichment or transport of its - * own. Bound at App scope; has no cross-domain collaborators. + * Owns the appender set, enabled flag, and root context, and creates forwarding + * context views that merge scoped properties at emission time. Views retain no + * transport state, so appender and enablement changes remain controlled by the + * App-scoped root. Has no cross-domain collaborators. */ import { InstantiationType } from '#/_base/di/extensions'; @@ -15,7 +15,7 @@ import { onUnexpectedError } from '#/_base/errors/unexpectedError'; import type { StrictPropertyCheck, TelemetryEventName, - TelemetryEventProperties, + TelemetryEventPayload, } from './events'; import { ITelemetryService, @@ -46,19 +46,15 @@ export class TelemetryService implements ITelemetryService { } } - track2 = never>( + track2 = never>( event: K, - properties?: StrictPropertyCheck, E>, + properties?: StrictPropertyCheck, E>, ): void { this.track(event, properties as TelemetryProperties); } withContext(patch: TelemetryContextPatch): ITelemetryService { - const child = new TelemetryService(); - child.appenders = this.appenders.map((appender) => appender.withContext?.(patch) ?? appender); - child.context = { ...this.context, ...patch }; - child.enabled = this.enabled; - return child; + return new TelemetryContextView(this, patch); } setContext(patch: TelemetryContextPatch): void { @@ -102,6 +98,61 @@ export class TelemetryService implements ITelemetryService { } } +class TelemetryContextView implements ITelemetryService { + declare readonly _serviceBrand: undefined; + private context: TelemetryProperties; + + constructor( + private readonly root: ITelemetryService, + context: TelemetryProperties, + ) { + this.context = context; + } + + track(event: string, properties?: TelemetryProperties): void { + this.root.track(event, { ...this.context, ...properties }); + } + + track2 = never>( + event: K, + properties?: StrictPropertyCheck, E>, + ): void { + this.track(event, properties as TelemetryProperties); + } + + withContext(patch: TelemetryContextPatch): ITelemetryService { + return new TelemetryContextView(this.root, { ...this.context, ...patch }); + } + + setContext(patch: TelemetryContextPatch): void { + this.context = { ...this.context, ...patch }; + } + + addAppender(appender: ITelemetryAppender): IDisposable { + return this.root.addAppender(appender); + } + + removeAppender(appender: ITelemetryAppender): void { + this.root.removeAppender(appender); + } + + setAppender(appender: ITelemetryAppender): void { + this.root.setAppender(appender); + } + + setEnabled(enabled: boolean): void { + this.root.setEnabled(enabled); + } + + flush(): Promise { + return this.root.flush(); + } + + shutdown(): Promise { + return this.root.shutdown(); + } +} + registerScopedService( LifecycleScope.App, ITelemetryService, diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index 0e10a82966..125d722d54 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -98,7 +98,7 @@ function buildHost(key: string): { host.stub(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' })); host.stub( IAgentTelemetryContextService, - new AgentTelemetryContextService(makeAgentScopeContext({ agentId: 'main', agentScope: '' })), + new AgentTelemetryContextService(), ); host.stub(IConfigService, createConfigStub()); host.stub(IModelResolver, modelResolver); diff --git a/packages/agent-core-v2/test/app/telemetry/agentTelemetryContext.test.ts b/packages/agent-core-v2/test/app/telemetry/agentTelemetryContext.test.ts index 94a3faeb5d..fd9710b335 100644 --- a/packages/agent-core-v2/test/app/telemetry/agentTelemetryContext.test.ts +++ b/packages/agent-core-v2/test/app/telemetry/agentTelemetryContext.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from 'vitest'; -import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { AgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContextService'; import { recordingTelemetry, type TelemetryRecord } from './stubs'; @@ -12,30 +11,26 @@ describe('AgentTelemetryContextService', () => { it('defaults to agent mode and merges into telemetry through withContext', () => { const records: TelemetryRecord[] = []; const telemetry = recordingTelemetry(records); - const ctx = new AgentTelemetryContextService( - makeAgentScopeContext({ agentId: 'main', agentScope: '' }), - ); + const ctx = new AgentTelemetryContextService(); telemetry.withContext(ctx.get()).track('turn_started'); expect(records).toContainEqual({ event: 'turn_started', - properties: { mode: 'agent', agent_id: 'main' }, + properties: { mode: 'agent' }, }); ctx.set({ mode: 'plan' }); telemetry.withContext(ctx.get()).track('turn_interrupted', { at_step: 2 }); expect(records).toContainEqual({ event: 'turn_interrupted', - properties: { mode: 'plan', agent_id: 'main', at_step: 2 }, + properties: { mode: 'plan', at_step: 2 }, }); }); it('snapshots the context at withContext time', () => { const records: TelemetryRecord[] = []; const telemetry = recordingTelemetry(records); - const ctx = new AgentTelemetryContextService( - makeAgentScopeContext({ agentId: 'main', agentScope: '' }), - ); + const ctx = new AgentTelemetryContextService(); ctx.set({ mode: 'plan' }); const fork = telemetry.withContext(ctx.get()); @@ -44,7 +39,7 @@ describe('AgentTelemetryContextService', () => { fork.track('turn_interrupted', { at_step: 1 }); expect(records).toContainEqual({ event: 'turn_interrupted', - properties: { mode: 'plan', agent_id: 'main', at_step: 1 }, + properties: { mode: 'plan', at_step: 1 }, }); }); }); diff --git a/packages/agent-core-v2/test/app/telemetry/events.test.ts b/packages/agent-core-v2/test/app/telemetry/events.test.ts index 14988ac596..5c0191ead8 100644 --- a/packages/agent-core-v2/test/app/telemetry/events.test.ts +++ b/packages/agent-core-v2/test/app/telemetry/events.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, expectTypeOf, it } from 'vitest'; -import { telemetryEventDefinitions } from '#/app/telemetry/events'; +import { + agentTelemetryContextProperties, + telemetryEventDefinitions, + type TelemetryEventProperties, +} from '#/app/telemetry/events'; const NAME_PATTERN = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/; @@ -24,4 +28,23 @@ describe('telemetry event registry', () => { } } }); + + it('declares Agent identity once as ambient context', () => { + expect(agentTelemetryContextProperties).toEqual({ + agent_id: 'Agent id (main or subagent scope id)', + }); + for (const [name, definition] of Object.entries(telemetryEventDefinitions)) { + if (definition.context === 'agent') { + expect( + definition.meta.properties, + `${name}: agent-scope events keep agent_id out of the payload`, + ).not.toHaveProperty('agent_id'); + } + } + expect(telemetryEventDefinitions.goal_created.context).toBe('agent'); + expect(telemetryEventDefinitions.image_compress.context).toBe('none'); + expectTypeOf>().toMatchTypeOf<{ + agent_id: string; + }>(); + }); }); diff --git a/packages/agent-core-v2/test/app/telemetry/telemetryService.test.ts b/packages/agent-core-v2/test/app/telemetry/telemetryService.test.ts index df4d86e368..3595468cd8 100644 --- a/packages/agent-core-v2/test/app/telemetry/telemetryService.test.ts +++ b/packages/agent-core-v2/test/app/telemetry/telemetryService.test.ts @@ -123,13 +123,29 @@ describe('TelemetryService (unit)', () => { expect(appender.events).toEqual([{ event: 'sent', properties: {} }]); }); - it('withContext child inherits enabled state at creation', () => { + it('withContext view follows root enablement changes', () => { const appender = new CapturingAppender(); const root = telemetryWithAppenders(appender); - root.setEnabled(false); const child = root.withContext({ turnId: 't1' }); + + root.setEnabled(false); child.track('dropped'); expect(appender.events).toHaveLength(0); + + root.setEnabled(true); + child.track('sent'); + expect(appender.events).toEqual([{ event: 'sent', properties: { turnId: 't1' } }]); + }); + + it('withContext view follows root appender changes', () => { + const root = new TelemetryService(); + const child = root.withContext({ agent_id: 'main' }); + const appender = new CapturingAppender(); + + root.setAppender(appender); + child.track('sent'); + + expect(appender.events).toEqual([{ event: 'sent', properties: { agent_id: 'main' } }]); }); it('flush fans out to every appender', async () => { diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index a85e6cd9b0..8fd7a753b4 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -53,6 +53,7 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentMediaToolsRegistrar } from '#/agent/media/mediaTools'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; +import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; const noopLog = { _serviceBrand: undefined, @@ -414,6 +415,25 @@ describe('AgentLifecycleService', () => { expect(second.id).toBe('agent-3'); }); + it('seeds each agent scope with a telemetry view bound to its own agent id', async () => { + const records: TelemetryRecord[] = []; + ix.stub(ITelemetryService, recordingTelemetry(records)); + const svc = ix.get(IAgentLifecycleService); + const main = await svc.create({ agentId: 'main' }); + const sub = await svc.create({}); + + main.accessor.get(ITelemetryService).track2('yolo_toggle', { enabled: true }); + sub.accessor.get(ITelemetryService).track2('yolo_toggle', { enabled: false }); + + expect(records).toContainEqual({ + event: 'yolo_toggle', + properties: { agent_id: 'main', enabled: true }, + }); + expect(records).toContainEqual({ + event: 'yolo_toggle', + properties: { agent_id: sub.id, enabled: false }, + }); + }); it('create assigns sequential ids when unspecified', async () => { const svc = ix.get(IAgentLifecycleService); diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 8dfaa929d9..bb80a18209 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -519,6 +519,7 @@ export class SessionSubagentHost { parent.telemetry.track('subagent_created', { agent_id: childId, parent_agent_id: this.ownerAgentId, + parent_tool_call_id: options.parentToolCallId ?? '', subagent_name: profileName, run_in_background: options.runInBackground, }); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 1149570b12..bcaace75d6 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -336,6 +336,7 @@ describe('SessionSubagentHost', () => { run_in_background: false, agent_id: 'agent-0', parent_agent_id: 'main', + parent_tool_call_id: 'call_agent', }); expect(parent.allEvents).toContainEqual( expect.objectContaining({