feat(agent-core-v2): record turn-level tool repeats (#3209)

* feat(agent-core-v2): record turn-level tool repeats

* fix(agent-core-v2): bound turn repeat signatures
This commit is contained in:
Haozhe 2026-08-24 20:25:15 +08:00 committed by GitHub
parent 496bb6ce4e
commit 2d00599010
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 169 additions and 2 deletions

View file

@ -27,7 +27,7 @@
// references become '(circular)', and class instances collapse to a '(ClassName)'
// marker — the wire shape of an entry is the JSON projection of the type here.
//
// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 78 keys)
// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 80 keys)
// App
// Workspace
// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts
@ -118,6 +118,8 @@
// toolDedupe.originalCallIndex src/agent/toolDedupe/toolDedupeService.ts
// toolDedupe.stepCalls src/agent/toolDedupe/toolDedupeService.ts
// toolDedupe.syntheticCallIds src/agent/toolDedupe/toolDedupeService.ts
// toolDedupe.turnCallRecords src/agent/toolDedupe/toolDedupeService.ts
// toolDedupe.turnRepeatCount src/agent/toolDedupe/toolDedupeService.ts
// toolExecutor.dupTypeTurnId src/agent/toolExecutor/toolExecutorService.ts
// toolExecutor.toolCallDupTypes src/agent/toolExecutor/toolExecutorService.ts
// toolSelect.pendingLoaded src/agent/toolSelect/toolSelectService.ts
@ -1415,6 +1417,11 @@ export interface AgentStateSnapshot {
'toolDedupe.originalCallIndex': Map<string, number>;
'toolDedupe.stepCalls': string[];
'toolDedupe.syntheticCallIds': Set<string>;
'toolDedupe.turnCallRecords': Map<string, /* TurnCallRecord — packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts */ {
count: number;
lastStep: number;
}>;
'toolDedupe.turnRepeatCount': number;
// src/agent/toolExecutor/toolExecutorService.ts
'toolExecutor.dupTypeTurnId': number | undefined;
'toolExecutor.toolCallDupTypes': Map<string, /* ToolCallDupType — packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts */ 'same_step' | 'cross_step'>;

View file

@ -5,12 +5,18 @@ import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { defineState } from '#/state/state';
import { canonicalTelemetryArgs } from '#/_base/utils/canonical-args';
import type { ToolCallDedupDetectedEvent, ToolCallRepeatEvent } from '#/app/telemetry/events';
import type {
ToolCallDedupDetectedEvent,
ToolCallRepeatEvent,
ToolCallTurnRepeatEvent,
} from '#/app/telemetry/events';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import type { LLMRequestTrace } from '#/kosong/contract/requestTrace';
import { parseToolCallArguments } from '#/tool/tool-args-parse';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentStateService } from '#/agent/state/agentState';
import { IEventBus } from '#/app/event/eventBus';
import { TurnEnded } from '#/agent/loop/turnOps';
import { wrapSystemReminder } from '#/agent/systemReminder/systemReminder';
import { IAgentToolExecutorService, type ToolCallDupType } from '#/agent/toolExecutor/toolExecutor';
import type { ContentPart } from '#/kosong/contract/message';
@ -71,10 +77,19 @@ function argsHash(args: unknown): string {
return createHash('sha256').update(canonicalTelemetryArgs(args)).digest('hex').slice(0, 8);
}
function callSignature(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
interface CheckedToolCall {
readonly syntheticResult: ToolDedupeResult | null;
}
interface TurnCallRecord {
count: number;
lastStep: number;
}
function appendReminder(result: ToolDedupeResult, reminderText: string): ToolDedupeResult {
const output = result.output;
let newOutput: string | ContentPart[];
@ -128,6 +143,14 @@ export const toolDedupeActiveTurnIdKey = defineState<number | undefined>(
() => undefined as number | undefined,
);
export const toolDedupeActiveStepKey = defineState<number>('toolDedupe.activeStep', () => 0);
export const toolDedupeTurnCallRecordsKey = defineState<Map<string, TurnCallRecord>>(
'toolDedupe.turnCallRecords',
() => new Map(),
);
export const toolDedupeTurnRepeatCountKey = defineState<number>(
'toolDedupe.turnRepeatCount',
() => 0,
);
export class AgentToolDedupeService extends Service implements IAgentToolDedupeService {
declare readonly _serviceBrand: undefined;
@ -138,6 +161,7 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS
@IAgentLoopService loop: IAgentLoopService,
@IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService,
@IAgentStateService private readonly states: IAgentStateService,
@IEventBus eventBus: IEventBus,
) {
super();
this.states.contributeState(toolDedupeStepCallsKey);
@ -148,6 +172,9 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS
this.states.contributeState(toolDedupeConsecutiveCountKey);
this.states.contributeState(toolDedupeActiveTurnIdKey);
this.states.contributeState(toolDedupeActiveStepKey);
this.states.contributeState(toolDedupeTurnCallRecordsKey);
this.states.contributeState(toolDedupeTurnRepeatCountKey);
this._register(eventBus.subscribe(TurnEnded, () => this.clearTurnRecords()));
loop.hooks.onWillBeginStep.register('toolDedupe', async (ctx, next) => {
this.beginStep(ctx.turnId, ctx.step);
await next();
@ -241,11 +268,29 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS
this.states.set(toolDedupeActiveStepKey, value);
}
private get turnCallRecords(): Map<string, TurnCallRecord> {
return this.states.get(toolDedupeTurnCallRecordsKey);
}
private get turnRepeatCount(): number {
return this.states.get(toolDedupeTurnRepeatCountKey);
}
private set turnRepeatCount(value: number) {
this.states.set(toolDedupeTurnRepeatCountKey, value);
}
private clearTurnRecords(): void {
this.turnCallRecords.clear();
this.turnRepeatCount = 0;
}
private beginStep(turnId?: number, step?: number): void {
if (turnId !== undefined && turnId !== this.activeTurnId) {
this.activeTurnId = turnId;
this.consecutiveKey = null;
this.consecutiveCount = 0;
this.clearTurnRecords();
}
if (step !== undefined) {
this.activeStep = step;
@ -275,6 +320,36 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS
}
}
private recordTurnRepeat(
toolCallId: string,
toolName: string,
args: unknown,
key: string,
trace: LLMRequestTrace | undefined,
): void {
const signature = callSignature(key);
const record = this.turnCallRecords.get(signature);
if (record === undefined) {
this.turnCallRecords.set(signature, { count: 0, lastStep: this.activeStep });
return;
}
if (record.lastStep === this.activeStep) return;
record.count += 1;
record.lastStep = this.activeStep;
this.turnRepeatCount += 1;
const properties: ToolCallTurnRepeatEvent = {
turn_id: this.activeTurnId,
step_no: this.activeStep,
tool_call_id: toolCallId,
tool_name: toolName,
turn_repeat_count: this.turnRepeatCount,
args_hash: argsHash(args),
trace_id: trace?.traceId,
};
this.telemetry.track2('tool_call_turn_repeat', properties);
}
private checkToolCall(
toolCallId: string,
toolName: string,
@ -292,6 +367,7 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS
this.recordDupType(toolCallId, toolName, args, 'same_step', trace);
return { syntheticResult: DEDUPE_PLACEHOLDER_RESULT };
}
this.recordTurnRepeat(toolCallId, toolName, args, key, trace);
this.stepDeferreds.set(key, makeDeferred<ToolDedupeResult>());
this.originalCallIndex.set(toolCallId, index);
if (this.consecutiveKey === key && this.consecutiveCount > 0) {

View file

@ -325,6 +325,16 @@ export interface ToolCallRepeatEvent {
trace_id?: string;
}
export interface ToolCallTurnRepeatEvent {
turn_id?: number;
step_no: number;
tool_call_id: string;
tool_name: string;
turn_repeat_count: number;
args_hash: string;
trace_id?: string;
}
export interface AgentsMdReminderShownEvent {
turn_id: number;
tool_name: string;
@ -830,6 +840,20 @@ export const telemetryEventDefinitions = {
'Trace id of the LLM request that produced the repeated tool call; absent for non-Kimi protocols',
},
}),
tool_call_turn_repeat: defineAgentTelemetryEvent<ToolCallTurnRepeatEvent>({
owner: 'kimi-code',
comment: 'A tool call reappears within the same turn.',
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',
step_no: 'Step index within the turn',
tool_call_id: 'Provider-assigned tool call id',
tool_name: 'Registered tool name',
turn_repeat_count: 'Number of prior-step tool-call reappearances counted in the turn',
args_hash: 'Hash of the tool call arguments',
trace_id:
'Trace id of the LLM request that produced the repeated tool call; absent for non-Kimi protocols',
},
}),
agents_md_reminder_shown: defineAgentTelemetryEvent<AgentsMdReminderShownEvent>({
owner: 'kimi-code',
comment: 'An AGENTS.md discovery reminder is appended to a tool result.',

View file

@ -702,6 +702,66 @@ describe('AgentToolDedupeService', () => {
});
});
it('counts interleaved tool calls across a turn without injecting a reminder', async () => {
const h = createHarness();
h.registry.register(new EchoTool('A'));
h.registry.register(new EchoTool('B'));
h.registry.register(new EchoTool('C'));
await runStep(h, 7, 1, [toolCall('a1', 'A', {})]);
await runStep(h, 7, 2, [toolCall('b1', 'B', {})]);
await runStep(h, 7, 3, [toolCall('c1', 'C', {})]);
await runStep(h, 7, 4, [toolCall('a2', 'A', {})]);
await runStep(h, 7, 5, [toolCall('b2', 'B', {})]);
const [last] = await runStep(h, 7, 6, [toolCall('c2', 'C', {})]);
expect(last!.result.output as string).not.toContain('<system-reminder>');
expect(telemetryEvents.filter((e) => e.event === 'tool_call_turn_repeat')).toEqual([
expect.objectContaining({
event: 'tool_call_turn_repeat',
properties: expect.objectContaining({
turn_id: 7,
step_no: 4,
tool_call_id: 'a2',
tool_name: 'A',
turn_repeat_count: 1,
}),
}),
expect.objectContaining({
event: 'tool_call_turn_repeat',
properties: expect.objectContaining({
turn_id: 7,
step_no: 5,
tool_call_id: 'b2',
tool_name: 'B',
turn_repeat_count: 2,
}),
}),
expect.objectContaining({
event: 'tool_call_turn_repeat',
properties: expect.objectContaining({
turn_id: 7,
step_no: 6,
tool_call_id: 'c2',
tool_name: 'C',
turn_repeat_count: 3,
}),
}),
]);
expect(telemetryEvents.filter((e) => e.event === 'tool_call_repeat')).toHaveLength(0);
});
it('does not carry turn repeat telemetry across turns', async () => {
const h = createHarness();
h.registry.register(new EchoTool('Read'));
await runStep(h, 7, 1, [toolCall('first', 'Read', { path: '/a' })]);
telemetryEvents.length = 0;
await runStep(h, 8, 1, [toolCall('new-turn', 'Read', { path: '/a' })]);
expect(telemetryEvents.filter((e) => e.event === 'tool_call_turn_repeat')).toHaveLength(0);
});
it('merges the request trace id into dedupe and repeat telemetry', async () => {
const h = createHarness();
h.registry.register(new EchoTool('Read'));