From 2d00599010302234b05ca3bf94c57e5a53310ad1 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Mon, 24 Aug 2026 20:25:15 +0800 Subject: [PATCH] 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 --- .../agent-core-v2/docs/state-manifest.d.ts | 9 ++- .../src/agent/toolDedupe/toolDedupeService.ts | 78 ++++++++++++++++++- .../agent-core-v2/src/app/telemetry/events.ts | 24 ++++++ .../test/agent/toolDedupe/toolDedupe.test.ts | 60 ++++++++++++++ 4 files changed, 169 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 4653589ab..bc51251a9 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -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; 'toolDedupe.stepCalls': string[]; 'toolDedupe.syntheticCallIds': Set; + 'toolDedupe.turnCallRecords': Map; + 'toolDedupe.turnRepeatCount': number; // src/agent/toolExecutor/toolExecutorService.ts 'toolExecutor.dupTypeTurnId': number | undefined; 'toolExecutor.toolCallDupTypes': Map; diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 5f8020fab..3ed810324 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -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( () => undefined as number | undefined, ); export const toolDedupeActiveStepKey = defineState('toolDedupe.activeStep', () => 0); +export const toolDedupeTurnCallRecordsKey = defineState>( + 'toolDedupe.turnCallRecords', + () => new Map(), +); +export const toolDedupeTurnRepeatCountKey = defineState( + '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 { + 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()); this.originalCallIndex.set(toolCallId, index); if (this.consecutiveKey === key && this.consecutiveCount > 0) { diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index eff3455bd..bcdf23067 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -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({ + 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({ owner: 'kimi-code', comment: 'An AGENTS.md discovery reminder is appended to a tool result.', 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 b9d824299..df49c89ca 100644 --- a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts +++ b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts @@ -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(''); + 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'));