Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-583-telemetry-bash-cancel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---

Fix tool_call telemetry misclassifying user-cancelled Bash runs as errors.
27 changes: 1 addition & 26 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context';
import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks';
import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args';
import { ToolCallDeduplicator } from './tool-dedup';
import { telemetryToolErrorType, telemetryToolOutcome } from './tool-telemetry';

interface ActiveTurn {
readonly turnId: number;
Expand Down Expand Up @@ -1101,29 +1102,3 @@ function currentTurnInputTokens(usage: TokenUsage | undefined): number | undefin
if (usage === undefined) return undefined;
return inputTotal(usage);
}

type ToolTelemetryResult = Extract<LoopEvent, { type: 'tool.result' }>['result'];

function telemetryToolOutcome(result: ToolTelemetryResult): 'success' | 'error' | 'cancelled' {
if (result.isError !== true) return 'success';
const text = toolResultText(result).toLowerCase();
return text.includes('aborted') ||
text.includes('cancelled') ||
text.includes('manually interrupted')
? 'cancelled'
: 'error';
}

function telemetryToolErrorType(result: ToolTelemetryResult): string {
const text = toolResultText(result);
if (text.startsWith('Tool "') && text.includes('" not found')) return 'ToolNotFound';
if (text.startsWith('Invalid args for tool "')) return 'ToolInputError';
if (text.includes('prepareToolExecution hook failed')) return 'HookError';
if (text.includes('finalizeToolResult hook failed')) return 'HookError';
if (text.includes('blocked')) return 'ToolBlocked';
return 'ToolError';
}

function toolResultText(result: ToolTelemetryResult): string {
return toolOutputText(result.output);
}
37 changes: 37 additions & 0 deletions packages/agent-core/src/agent/turn/tool-telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ContentPart } from '@moonshot-ai/kosong';

import type { ExecutableToolResult } from '../../loop/types';

export type ToolTelemetryResult = ExecutableToolResult;

function toolOutputText(output: ExecutableToolResult['output']): string {
if (typeof output === 'string') return output;
return output
.filter((part): part is Extract<ContentPart, { type: 'text' }> => {
return typeof part === 'object' && part !== null && part.type === 'text';
})
.map((part) => part.text)
.join('');
}

function toolResultText(result: ToolTelemetryResult): string {
return toolOutputText(result.output);
}

export function telemetryToolOutcome(
result: ToolTelemetryResult,
): 'success' | 'error' | 'cancelled' {
if (result.isError !== true) return 'success';
if (result.cancelledByUser === true) return 'cancelled';
return 'error';
Comment on lines +25 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve cancelled telemetry for Agent tool cancels

When a user stops a foreground Agent tool call, the tool handles isUserCancellation(signal.reason) itself and returns { output: ..., isError: true } with the “manually interrupted this subagent” message (checked packages/agent-core/src/tools/builtin/collaboration/agent.ts lines 283-303 and 309-318), but it does not set cancelledByUser. Because this new classifier now treats every unflagged error as error, those user-cancelled Agent calls regress from the old string heuristic’s cancelled outcome to error; either propagate the new flag from that tool path or keep a safe fallback for known internal cancellation outputs.

Useful? React with 👍 / 👎.

}

export function telemetryToolErrorType(result: ToolTelemetryResult): string {
const text = toolResultText(result);
if (text.startsWith('Tool "') && text.includes('" not found')) return 'ToolNotFound';
if (text.startsWith('Invalid args for tool "')) return 'ToolInputError';
if (text.includes('prepareToolExecution hook failed')) return 'HookError';
if (text.includes('finalizeToolResult hook failed')) return 'HookError';
if (text.includes('blocked')) return 'ToolBlocked';
return 'ToolError';
}
39 changes: 33 additions & 6 deletions packages/agent-core/src/loop/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { ToolScheduler, type ToolCallTask } from './tool-scheduler';
import type {
AuthorizeToolExecutionResult,
ExecutableTool,
ExecutableToolErrorResult,
LoopHooks,
ToolCall,
PrepareToolExecutionResult,
Expand Down Expand Up @@ -242,9 +243,12 @@ async function prepareToolCall(
args: unknown,
output: string,
displayFields?: ToolCallDisplayFields,
cancelledByUser?: true,
): Promise<PreparedToolCallTask> => {
await dispatchToolCall(step, call, args, displayFields);
return { task: makeResolvedToolCallTask(makeErrorToolResult(call, args, output)) };
return {
task: makeResolvedToolCallTask(makeErrorToolResult(call, args, output, cancelledByUser)),
};
};

const settleSynthetic = async (
Expand Down Expand Up @@ -299,7 +303,12 @@ async function prepareToolCall(

const displayFields = toolCallDisplayFieldsFromExecution(execution);
const settleAborted = (): Promise<PreparedToolCallTask> =>
settleError(effectiveArgs, abortedToolOutput(call.toolName, step.signal), displayFields);
settleError(
effectiveArgs,
abortedToolOutput(call.toolName, step.signal),
displayFields,
isUserCancellation(step.signal.reason) ? true : undefined,
);

if (step.signal.aborted) return settleAborted();

Expand Down Expand Up @@ -467,7 +476,12 @@ async function runRunnableToolCall(
const { toolCall, toolName } = call;

if (signal.aborted) {
return makeErrorToolResult(call, effectiveArgs, abortedToolOutput(toolName, signal));
return makeErrorToolResult(
call,
effectiveArgs,
abortedToolOutput(toolName, signal),
isUserCancellation(signal.reason) ? true : undefined,
);
}

let toolResult: ExecutableToolResult;
Expand All @@ -486,7 +500,12 @@ async function runRunnableToolCall(
const output = aborted
? abortedToolOutput(toolName, signal)
: `Tool "${toolName}" failed: ${errorMessage(error)}`;
return makeErrorToolResult(call, effectiveArgs, output);
return makeErrorToolResult(
call,
effectiveArgs,
output,
aborted && isUserCancellation(signal.reason) ? true : undefined,
);
}

return makeToolResult(call, effectiveArgs, toolResult);
Expand Down Expand Up @@ -663,7 +682,14 @@ function normalizeToolResult(r: ExecutableToolResult): ExecutableToolResult {
output = textJoined.length > 0 ? textJoined : TOOL_OUTPUT_EMPTY;
}
}
return r.isError === true ? { output, isError: true } : { output };
if (r.isError === true) {
const errorResult: ExecutableToolErrorResult =
r.cancelledByUser === true
? { output, isError: true, cancelledByUser: true }
: { output, isError: true };
return errorResult;
}
return { output };
}

function makeToolResult(
Expand All @@ -688,8 +714,9 @@ function makeErrorToolResult(
call: PreflightedToolCall,
args: unknown,
output: string,
cancelledByUser?: true,
): PendingToolResult {
return makeToolResult(call, args, { output, isError: true });
return makeToolResult(call, args, { output, isError: true, cancelledByUser });
}

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core/src/loop/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export interface ExecutableToolErrorResult {
readonly isError: true;
/** See {@link ExecutableToolSuccessResult.message}. */
readonly message?: string | undefined;
/**
* Internal telemetry-only hint. Tool result events may carry this field;
* it is not projected into model-facing tool messages.
*/
readonly cancelledByUser?: true | undefined;
/** See {@link ExecutableToolSuccessResult.stopTurn}. */
readonly stopTurn?: boolean | undefined;
}
Expand Down
13 changes: 11 additions & 2 deletions packages/agent-core/src/tools/builtin/shell/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ import { z } from 'zod';

import { ProcessBackgroundTask, type BackgroundManager } from '../../../agent/background';
import type { BuiltinTool } from '../../../agent/tool';
import type { ExecutableToolResult, ToolExecution } from '../../../loop/types';
import type { ExecutableToolErrorResult, ExecutableToolResult, ToolExecution } from '../../../loop/types';
import { renderPrompt } from '../../../utils/render-prompt';
import { isUserCancellation } from '../../../utils/abort';
import { toInputJsonSchema } from '../../support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match';
import { ToolResultBuilder } from '../../support/result-builder';
Expand Down Expand Up @@ -325,7 +326,15 @@ export class BashTool implements BuiltinTool<BashInput> {
});
}
if (aborted) {
return builder.error('Interrupted by user', { brief: 'Interrupted by user' });
const built = builder.error('Interrupted by user', { brief: 'Interrupted by user' });
const errorResult: ExecutableToolErrorResult = {
output: built.output,
isError: true,
message: built.message,
};
return isUserCancellation(signal.reason)
? { ...errorResult, cancelledByUser: true }
: errorResult;
}

const isError = exitCode !== 0;
Expand Down
Loading