fix(agent-core): count validation-rejected tool calls toward the repeat breaker (#2313)

* fix(agent-core): count validation-rejected tool calls toward the repeat breaker

Args-rejected calls returned before prepareToolExecution, so the breaker
never counted them and the model could re-issue the same invalid call
until maxSteps. Register them in finalizeToolResult so reminders fire at
3/5/8 and the turn force-stops at 12.

* fix(agent-core): key parse-failed repeats on raw argument text

Malformed JSON arguments normalize to {} on parse failure, which keyed
every malformed-but-different attempt identically and could force-stop a
turn whose calls were evolving rather than identical. Register skipped
calls on the raw arguments text when parsing failed.

---------

Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>
This commit is contained in:
STAR-QUAKE 2026-07-28 18:39:26 +08:00 committed by GitHub
parent d03a4886fd
commit de0ba9d065
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 193 additions and 0 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/agent-core": patch
---
Count validation-rejected tool calls toward the repeat breaker so reminders fire at 3/5/8 and the turn force-stops at 12.

View file

@ -978,6 +978,16 @@ export class TurnFlow {
return this.agent.permission.beforeToolCall(ctx);
},
finalizeToolResult: async (ctx) => {
// Calls rejected in preflight (e.g. invalid args) never reach
// prepareToolExecution, so register them here — otherwise the
// repeat breaker cannot count them and the model can re-issue
// the same invalid call indefinitely.
deduper.registerSkipped(
ctx.toolCall.id,
ctx.toolCall.name,
ctx.args,
ctx.toolCall.arguments,
);
// Resolve dedup BEFORE firing the PostToolUse hook so same-step
// dups (whose ctx.result is the dedup placeholder) report the
// original's real outcome, not an empty success.

View file

@ -2,6 +2,7 @@ import type { ContentPart } from '@moonshot-ai/kosong';
import type { TelemetryClient } from '../../telemetry';
import type { LLMRequestTrace } from '../../loop/llm';
import { parseToolCallArguments } from '../../loop/tool-args-parse';
import type { ExecutableToolResult } from '../../loop/types';
import { canonicalTelemetryArgs } from './canonical-args';
@ -190,6 +191,35 @@ export class ToolCallDeduplicator {
return null;
}
/**
* Register a call that bypassed `prepareToolExecution` e.g. args
* validation rejected it in preflight, so the prepare hook never ran. Must
* be called before `finalizeResult` for such calls, otherwise the repeat
* circuit breaker never counts rejected calls and the model can re-issue
* the same invalid call without ever tripping the streak. No-op when the
* call was already registered through the normal prepare path.
*
* `rawArguments` is the provider's raw arguments string. Args that failed
* JSON parsing were normalized to `{}` by the loop, which would key every
* malformed-but-different attempt identically; those are keyed on the raw
* text so only true re-issues count as repeats.
*/
registerSkipped(
toolCallId: string,
toolName: string,
args: unknown,
rawArguments?: string | null,
): void {
if (this.callKeyByCallId.has(toolCallId)) return;
const keyArgs =
rawArguments !== undefined &&
rawArguments !== null &&
parseToolCallArguments(rawArguments).parseFailed
? rawArguments
: args;
this.checkSameStep(toolCallId, toolName, keyArgs);
}
/**
* Called from `finalizeToolResult`, in provider order. For first-occurrence
* calls, projects the consecutive streak ending at this call and, if the

View file

@ -793,6 +793,61 @@ describe('Agent turn flow', () => {
});
});
it('force-stops a turn that keeps re-issuing the same validation-rejected call', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({
kaos: createCommandKaos('bad'),
telemetry: recordingTelemetry(records),
});
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
records.length = 0;
// 12 identical calls missing the required "command": each is rejected in
// preflight. If the breaker did not count them, the turn would keep going
// and consume the 13th scripted response.
for (let i = 0; i < 12; i += 1) {
ctx.mockNextResponse(invalidBashCallWithId(`call_bad_${String(i)}`));
}
ctx.mockNextResponse({ type: 'text', text: 'must never be generated' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Repeat the bad call' }] });
await ctx.untilTurnEnd();
expect(ctx.llmCalls).toHaveLength(12);
const actions = records
.filter((entry) => entry.event === 'tool_call_repeat')
.map((entry) => entry.properties?.['action']);
expect(actions).toEqual([
'none', 'r1', 'r1', 'r2', 'r2', 'r2', 'r3', 'r3', 'r3', 'r3', 'stop',
]);
});
it('does not force-stop when the malformed argument text keeps changing', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({
kaos: createCommandKaos('bad'),
telemetry: recordingTelemetry(records),
});
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
records.length = 0;
// 12 rejected calls, each with DIFFERENT malformed raw JSON: all normalize
// to {} on parse failure, but they are not repeats of the same call, so
// the turn must not be force-stopped.
for (let i = 0; i < 12; i += 1) {
ctx.mockNextResponse(malformedBashCallWithId(`call_mal_${String(i)}`, i));
}
ctx.mockNextResponse({ type: 'text', text: 'recovered' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Repeat the bad call' }] });
await ctx.untilTurnEnd();
expect(ctx.llmCalls).toHaveLength(13);
expect(records.filter((entry) => entry.event === 'tool_call_repeat')).toHaveLength(0);
});
it('fires PostToolUse for same-step dups with the original real output, not the dedup placeholder', async () => {
// Hook command asserts the dup's PostToolUse payload carries the real
// stdout ('dup'), not the placeholder ('').
@ -2799,6 +2854,25 @@ function bashCallWithId(id: string, command: string): ToolCall {
};
}
function invalidBashCallWithId(id: string): ToolCall {
return {
type: 'function',
id,
name: 'Bash',
arguments: JSON.stringify({ timeout: 60 }),
};
}
function malformedBashCallWithId(id: string, variant: number): ToolCall {
// Invalid JSON (unquoted key), unique per variant.
return {
type: 'function',
id,
name: 'Bash',
arguments: `{"command_${String(variant)}: "ls"`,
};
}
function agentSwarmCall(): ToolCall {
return {
type: 'function',

View file

@ -573,4 +573,78 @@ describe('ToolCallDeduplicator', () => {
expect(true).toBe(true);
});
});
describe('skipped registration (calls rejected before prepareToolExecution)', () => {
// Calls rejected in preflight (e.g. invalid args) never reach
// prepareToolExecution/checkSameStep; the loop registers them late via
// registerSkipped at finalize time so the breaker still counts them.
async function runRejected(
dedup: ToolCallDeduplicator,
callId: string,
result: ExecutableToolResult,
): Promise<ExecutableToolResult> {
dedup.registerSkipped(callId, 'Bash', { timeout: 60 });
return dedup.finalizeResult(callId, 'Bash', { timeout: 60 }, result);
}
it('counts skipped calls toward the streak and force-stops at 12, keeping the error flag', async () => {
const dedup = new ToolCallDeduplicator();
let last: ExecutableToolResult | undefined;
for (let i = 0; i < 12; i += 1) {
dedup.beginStep();
last = await runRejected(dedup, `c${String(i)}`, errResult('Invalid args'));
dedup.endStep();
}
expect(last!.isError).toBe(true);
expect(last!.stopTurn).toBe(true);
expect(last!.output as string).toContain(REMINDER_TEXT_3.trim());
});
it('does not double-register a call that already went through checkSameStep', async () => {
const { client, events } = makeRecordingTelemetry();
const dedup = new ToolCallDeduplicator({ telemetry: client });
for (let i = 0; i < 2; i += 1) {
dedup.beginStep();
const callId = `c${String(i)}`;
expect(dedup.checkSameStep(callId, 'Read', { p: 1 })).toBeNull();
dedup.registerSkipped(callId, 'Read', { p: 1 });
await dedup.finalizeResult(callId, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
}
// Exactly one repeat at count 2 — a double registration would inflate
// the streak and fire the reminder one occurrence early.
const repeats = events.filter((e) => e.event === 'tool_call_repeat');
expect(repeats.map((e) => e.properties?.['repeat_count'])).toEqual([2]);
});
it('counts identical malformed argument texts as repeats', async () => {
const { client, events } = makeRecordingTelemetry();
const dedup = new ToolCallDeduplicator({ telemetry: client });
for (let i = 0; i < 2; i += 1) {
dedup.beginStep();
const callId = `c${String(i)}`;
dedup.registerSkipped(callId, 'Bash', {}, '{"command":');
await dedup.finalizeResult(callId, 'Bash', {}, errResult('Invalid args'));
dedup.endStep();
}
const repeats = events.filter((e) => e.event === 'tool_call_repeat');
expect(repeats.map((e) => e.properties?.['repeat_count'])).toEqual([2]);
});
it('does not treat different malformed argument texts as the same call', async () => {
const { client, events } = makeRecordingTelemetry();
const dedup = new ToolCallDeduplicator({ telemetry: client });
const raws = ['{"command":', '{"comand":', '{"command": "ls"'];
for (let i = 0; i < 3; i += 1) {
dedup.beginStep();
const callId = `c${String(i)}`;
dedup.registerSkipped(callId, 'Bash', {}, raws[i]);
await dedup.finalizeResult(callId, 'Bash', {}, errResult('Invalid args'));
dedup.endStep();
}
// All three normalize to {} on parse failure, but the raw texts differ,
// so no repeat streak may form.
expect(events.filter((e) => e.event === 'tool_call_repeat')).toHaveLength(0);
});
});
});