From dd9077595db4eb92a4009beb2063efc49d44402c Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 6 Jul 2026 20:52:40 +0800 Subject: [PATCH] chore(agent-core): classify turn_interrupted telemetry cause (#1431) Add an `interrupt_reason` field to the `turn_interrupted` telemetry event so the data can tell a deliberate user cancel (`user_cancelled`) apart from a programmatic abort (`aborted`), max-steps exhaustion (`max_steps`), an error (`error`), or a hook-filtered turn (`filtered`). The user-cancel signal comes from the existing UserCancellationError carried as the abort signal's reason, reused here without changing any loop control or external protocol semantics. --- packages/agent-core/src/agent/turn/index.ts | 53 ++++++++++++++++++-- packages/agent-core/src/loop/events.ts | 6 +++ packages/agent-core/src/loop/run-turn.ts | 10 +++- packages/agent-core/test/agent/turn.test.ts | 54 ++++++++++++++++++++- 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 31575418d..a253fcb27 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -604,7 +604,17 @@ export class TurnFlow { this.agent.emitEvent(errorEvent); } if (ended.reason !== 'completed') { - this.trackTurnInterrupted(turnId, this.currentStepByTurn.get(turnId) ?? this.currentStep); + // Fallback for turns that end abnormally without a `turn.interrupted` + // loop event reaching `trackLoopTelemetry` (e.g. a user-prompt hook block + // or an abort that bypasses the step loop). `ended.reason` maps onto the + // same interrupt-reason taxonomy the loop-event path uses; for a + // `cancelled` end the signal's reason decides user_cancelled vs aborted. + const interruptReason = telemetryInterruptReason(ended.reason, isUserCancellation(signal.reason)); + this.trackTurnInterrupted( + turnId, + this.currentStepByTurn.get(turnId) ?? this.currentStep, + interruptReason, + ); } this.telemetryModeByTurn.delete(turnId); this.currentStepByTurn.delete(turnId); @@ -938,7 +948,11 @@ export class TurnFlow { if (event.reason === 'error' && event.activeStep !== undefined) { this.stepFailureByTurn.set(turnId, event); } - this.trackTurnInterrupted(turnId, interruptedStep(event)); + this.trackTurnInterrupted( + turnId, + interruptedStep(event), + event.interruptReason ?? telemetryInterruptReason(event.reason, false), + ); return; } this.trackToolLifecycle(event, turnId); @@ -1024,12 +1038,17 @@ export class TurnFlow { return false; } - private trackTurnInterrupted(turnId: number, atStep: number): void { + private trackTurnInterrupted( + turnId: number, + atStep: number, + interruptReason: TelemetryInterruptReason, + ): void { if (this.interruptedTelemetryTurnIds.has(turnId)) return; this.interruptedTelemetryTurnIds.add(turnId); this.agent.telemetry.track('turn_interrupted', { mode: this.telemetryModeByTurn.get(turnId) ?? this.telemetryMode(), at_step: atStep, + interrupt_reason: interruptReason, ...this.requestProtocolProps(), }); } @@ -1232,6 +1251,34 @@ function interruptedStep(event: LoopTurnInterruptedEvent): number { return event.activeStep ?? event.attemptedSteps; } +/** + * Telemetry-facing interrupt reason. The loop reports `LoopInterruptReason` + * (`aborted` | `max_steps` | `error`); we split `aborted` into a deliberate + * user cancel vs. any other programmatic abort so telemetry can tell them + * apart. `filtered` is folded in for the fallback path (turn ends flagged + * `filtered` never emit a `turn.interrupted` loop event). + */ +type TelemetryInterruptReason = + | 'user_cancelled' + | 'aborted' + | 'max_steps' + | 'error' + | 'filtered'; + +function telemetryInterruptReason( + reason: LoopTurnInterruptedEvent['reason'] | Exclude, + userCancelled: boolean, +): TelemetryInterruptReason { + if ((reason === 'aborted' || reason === 'cancelled') && userCancelled) { + return 'user_cancelled'; + } + if (reason === 'aborted' || reason === 'cancelled') return 'aborted'; + if (reason === 'failed') return 'error'; + // Remaining values are `max_steps` | `error` | `filtered`, which match the + // telemetry enum. + return reason; +} + interface ApiErrorClassification { readonly errorType: string; readonly statusCode?: number; diff --git a/packages/agent-core/src/loop/events.ts b/packages/agent-core/src/loop/events.ts index 6fbc6c356..5b19e3633 100644 --- a/packages/agent-core/src/loop/events.ts +++ b/packages/agent-core/src/loop/events.ts @@ -4,6 +4,7 @@ import type { ToolInputDisplay } from '../tools/display'; import type { ExecutableToolResult, LoopStepStopReason, ToolUpdate } from './types'; export type LoopInterruptReason = 'aborted' | 'max_steps' | 'error'; +export type LoopInterruptCause = LoopInterruptReason | 'user_cancelled'; export interface LoopStepBeginEvent { readonly type: 'step.begin'; @@ -94,6 +95,11 @@ export interface LoopTurnInterruptedEvent { readonly attemptedSteps: number; readonly activeStep?: number | undefined; readonly message?: string | undefined; + /** + * Telemetry-facing interrupt cause. `aborted` is split into a deliberate user + * cancel vs. any other abort; `max_steps`/`error` mirror `reason`. + */ + readonly interruptReason?: LoopInterruptCause | undefined; } export interface LoopTextDeltaEvent { diff --git a/packages/agent-core/src/loop/run-turn.ts b/packages/agent-core/src/loop/run-turn.ts index d6646ca1e..06a59c1d5 100644 --- a/packages/agent-core/src/loop/run-turn.ts +++ b/packages/agent-core/src/loop/run-turn.ts @@ -10,6 +10,7 @@ import { addUsage, emptyUsage, type TokenUsage } from '@moonshot-ai/kosong'; import type { Logger } from '#/logging/types'; +import { isUserCancellation } from '../utils/abort'; import { createMaxStepsExceededError, errorMessage, @@ -148,7 +149,12 @@ export async function runTurn(input: RunTurnInput): Promise { } } catch (error) { if (isAbortError(error) || signal.aborted) { - dispatchEvent(makeInterruptedEvent('aborted', steps, activeStep)); + // A deliberate user cancel travels as the signal's reason (and may be the + // thrown error itself). Report it distinctly from a timeout or other + // programmatic abort so telemetry can tell the two apart. + const interruptReason = + isUserCancellation(signal.reason) || isUserCancellation(error) ? 'user_cancelled' : 'aborted'; + dispatchEvent(makeInterruptedEvent('aborted', steps, activeStep, undefined, interruptReason)); return { stopReason: 'aborted', steps, usage }; } const reason: LoopInterruptReason = isMaxStepsExceededError(error) ? 'max_steps' : 'error'; @@ -164,6 +170,7 @@ function makeInterruptedEvent( attemptedSteps: number, activeStep: number | undefined, message?: string | undefined, + interruptReason: LoopTurnInterruptedEvent['interruptReason'] = reason, ): LoopTurnInterruptedEvent { return { type: 'turn.interrupted', @@ -171,5 +178,6 @@ function makeInterruptedEvent( attemptedSteps, ...(activeStep !== undefined ? { activeStep } : {}), ...(message !== undefined ? { message } : {}), + interruptReason, }; } diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 852b95d42..2413c700b 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -73,7 +73,59 @@ describe('Agent turn flow', () => { }); expect(records).toContainEqual({ event: 'turn_interrupted', - properties: { mode: 'agent', at_step: 0 }, + properties: { mode: 'agent', at_step: 0, interrupt_reason: 'error' }, + }); + }); + + it('reports turn_interrupted telemetry as user_cancelled on manual abort', async () => { + const records: TelemetryRecord[] = []; + const ctx = testAgent({ + kaos: createCommandKaos('should-not-run'), + telemetry: recordingTelemetry(records), + }); + ctx.configure({ tools: ['Bash'] }); + + ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall()); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a command' }] }); + await ctx.untilApprovalRequest(); + + // User presses stop: the RPC cancel carries no explicit reason, which the + // turn treats as a deliberate user cancellation. + await ctx.rpc.cancel({ turnId: 0 }); + await ctx.untilTurnEnd(); + + const interrupted = records.find((candidate) => candidate.event === 'turn_interrupted'); + expect(interrupted).toEqual({ + event: 'turn_interrupted', + properties: expect.objectContaining({ + mode: 'agent', + interrupt_reason: 'user_cancelled', + }), + }); + }); + + it('reports turn_interrupted telemetry as aborted on programmatic abort', async () => { + const records: TelemetryRecord[] = []; + const ctx = testAgent({ + kaos: createCommandKaos('should-not-run'), + telemetry: recordingTelemetry(records), + }); + ctx.configure({ tools: ['Bash'] }); + + ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall()); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a command' }] }); + await ctx.untilApprovalRequest(); + + // A programmatic abort (e.g. a subagent deadline timeout) carries a plain + // AbortError as its reason, not a UserCancellationError, so telemetry must + // not report it as a user cancellation. + ctx.agent.turn.cancel(0, abortError()); + await ctx.untilTurnEnd(); + + const interrupted = records.find((candidate) => candidate.event === 'turn_interrupted'); + expect(interrupted).toEqual({ + event: 'turn_interrupted', + properties: expect.objectContaining({ mode: 'agent', interrupt_reason: 'aborted' }), }); });