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.
This commit is contained in:
7Sageer 2026-07-06 20:52:40 +08:00 committed by GitHub
parent 6c0ce09414
commit dd9077595d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 118 additions and 5 deletions

View file

@ -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<TurnEndedEvent['reason'], 'completed'>,
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;

View file

@ -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 {

View file

@ -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<TurnResult> {
}
} 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,
};
}

View file

@ -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' }),
});
});