Skip to content

Commit ee7486c

Browse files
sailistRealKai42
andauthored
fix(agent-core): tier anti-repeat reminders at streak counts 3, 5, and 8 (#15)
Replace the single threshold of 7 with tiered reminders triggered at streak counts 3, 5, and 8.\nThe first reminder is a generic nudge, while the second and third include\nthe tool name, repeat count, and arguments for stronger guidance. Co-authored-by: haozhe.yang <yanghaozhe@moonshot.ai> Co-authored-by: Kai <me@kaiyi.cool>
1 parent 35726d7 commit ee7486c

2 files changed

Lines changed: 128 additions & 37 deletions

File tree

‎packages/agent-core/src/agent/turn/tool-dedup.ts‎

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,28 @@ import type { ExecutableToolResult } from '../../loop/types';
44

55
import { canonicalTelemetryArgs } from './canonical-args';
66

7-
const CROSS_STEP_DEDUP_TRIGGER_COUNT = 7;
8-
9-
const REMINDER_TEXT =
7+
const REMINDER_TEXT_1 =
108
'\n\n<system-reminder>\n' +
119
'You are repeating the exact same tool call with identical parameters.' +
1210
' Please carefully analyze the previous result. If the task is not yet complete,' +
1311
' try a different method or parameters instead of repeating the same call.' +
1412
'\n</system-reminder>';
1513

14+
function makeReminderText2(toolName: string, repeatCount: number, args: unknown): string {
15+
const argsStr = canonicalTelemetryArgs(args);
16+
return (
17+
'\n\n<system-reminder>\n' +
18+
'You have repeatedly called the same tool with identical parameters many times.\n' +
19+
'Repeated tool call detected:\n' +
20+
`- tool: ${toolName}\n` +
21+
`- repeated_times: ${String(repeatCount)}\n` +
22+
`- arguments: ${argsStr}\n` +
23+
'The previous repeated calls did not make progress. Do not call this exact same tool with the exact same arguments again.\n' +
24+
'Carefully inspect the latest tool result and choose a different next action, different parameters, or finish the task if enough evidence has been gathered.' +
25+
'\n</system-reminder>'
26+
);
27+
}
28+
1629
interface Deferred<T> {
1730
readonly promise: Promise<T>;
1831
resolve(value: T): void;
@@ -30,18 +43,18 @@ function makeKey(toolName: string, args: unknown): string {
3043
return `${toolName} ${canonicalTelemetryArgs(args)}`;
3144
}
3245

33-
function appendReminder(result: ExecutableToolResult): ExecutableToolResult {
46+
function appendReminder(result: ExecutableToolResult, reminderText: string): ExecutableToolResult {
3447
const output = result.output;
3548
let newOutput: string | ContentPart[];
3649
if (typeof output === 'string') {
37-
newOutput = output + REMINDER_TEXT;
50+
newOutput = output + reminderText;
3851
} else {
3952
const arr: ContentPart[] = [...output];
4053
const last = arr.at(-1);
4154
if (last !== undefined && last.type === 'text') {
42-
arr[arr.length - 1] = { type: 'text', text: last.text + REMINDER_TEXT };
55+
arr[arr.length - 1] = { type: 'text', text: last.text + reminderText };
4356
} else {
44-
arr.push({ type: 'text', text: REMINDER_TEXT });
57+
arr.push({ type: 'text', text: reminderText });
4558
}
4659
newOutput = arr;
4760
}
@@ -67,10 +80,10 @@ const DEDUP_PLACEHOLDER_RESULT: ExecutableToolResult = { output: '' };
6780
* Two behaviours are layered:
6881
* - Same-step dedup: a duplicate `(toolName, args)` issued in the same LLM step
6982
* reuses the original call's result instead of executing the tool twice.
70-
* - Cross-step dedup: when the exact same call is repeated for
71-
* `CROSS_STEP_DEDUP_TRIGGER_COUNT` consecutive occurrences (counting across
72-
* steps), the result returned to the model is suffixed with a system reminder
73-
* nudging it to try a different approach.
83+
* - Cross-step dedup: when the exact same call is repeated consecutively
84+
* across steps, the result returned to the model is suffixed with a system
85+
* reminder at specific streak thresholds (3, 5, and 8) to nudge the model
86+
* to try a different approach.
7487
*/
7588
export class ToolCallDeduplicator {
7689
private stepDeferreds = new Map<string, Deferred<ExecutableToolResult>>();
@@ -150,8 +163,8 @@ export class ToolCallDeduplicator {
150163
*/
151164
async finalizeResult(
152165
toolCallId: string,
153-
_toolName: string,
154-
_args: unknown,
166+
toolName: string,
167+
args: unknown,
155168
result: ExecutableToolResult,
156169
): Promise<ExecutableToolResult> {
157170
// Use the key recorded at registration time, NOT a fresh key from the args
@@ -181,15 +194,19 @@ export class ToolCallDeduplicator {
181194
}
182195
}
183196

184-
const finalResult =
185-
streak >= CROSS_STEP_DEDUP_TRIGGER_COUNT ? appendReminder(result) : result;
197+
let finalResult = result;
198+
if (streak === 3) {
199+
finalResult = appendReminder(result, REMINDER_TEXT_1);
200+
} else if (streak === 5 || streak === 8) {
201+
finalResult = appendReminder(result, makeReminderText2(toolName, streak, args));
202+
}
186203

187204
this.stepDeferreds.get(key)?.resolve(finalResult);
188205
return finalResult;
189206
}
190207
}
191208

192209
export const __testing = {
193-
CROSS_STEP_DEDUP_TRIGGER_COUNT,
194-
REMINDER_TEXT,
210+
REMINDER_TEXT_1,
211+
makeReminderText2,
195212
};

‎packages/agent-core/test/agent/turn/tool-dedup.test.ts‎

Lines changed: 94 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
33
import type { ExecutableToolResult } from '../../../src/loop/types';
44
import { ToolCallDeduplicator, __testing } from '../../../src/agent/turn/tool-dedup';
55

6-
const { REMINDER_TEXT, CROSS_STEP_DEDUP_TRIGGER_COUNT } = __testing;
6+
const { REMINDER_TEXT_1, makeReminderText2 } = __testing;
77

88
function okResult(text: string): ExecutableToolResult {
99
return { output: text };
@@ -73,10 +73,10 @@ describe('ToolCallDeduplicator', () => {
7373
});
7474

7575
describe('cross-step streak', () => {
76-
it(`does not inject reminder below ${String(CROSS_STEP_DEDUP_TRIGGER_COUNT)} consecutive`, async () => {
76+
it('does not inject reminder below 3 consecutive', async () => {
7777
const dedup = new ToolCallDeduplicator();
7878
let last: ExecutableToolResult | undefined;
79-
for (let i = 0; i < CROSS_STEP_DEDUP_TRIGGER_COUNT - 1; i += 1) {
79+
for (let i = 0; i < 2; i += 1) {
8080
dedup.beginStep();
8181
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
8282
dedup.endStep();
@@ -85,22 +85,72 @@ describe('ToolCallDeduplicator', () => {
8585
expect(last!.output as string).not.toContain('<system-reminder>');
8686
});
8787

88-
it(`injects reminder at exactly ${String(CROSS_STEP_DEDUP_TRIGGER_COUNT)} consecutive`, async () => {
88+
it('injects reminder1 at exactly 3 consecutive', async () => {
8989
const dedup = new ToolCallDeduplicator();
9090
let last: ExecutableToolResult | undefined;
91-
for (let i = 0; i < CROSS_STEP_DEDUP_TRIGGER_COUNT; i += 1) {
91+
for (let i = 0; i < 3; i += 1) {
9292
dedup.beginStep();
9393
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
9494
dedup.endStep();
9595
}
9696
expect(last!.output as string).toContain('<system-reminder>');
9797
expect(last!.output as string).toContain('repeating the exact same tool call');
98+
expect(last!.output as string).not.toContain('repeated_times');
99+
});
100+
101+
it('does not inject reminder at 4 consecutive', async () => {
102+
const dedup = new ToolCallDeduplicator();
103+
let last: ExecutableToolResult | undefined;
104+
for (let i = 0; i < 4; i += 1) {
105+
dedup.beginStep();
106+
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
107+
dedup.endStep();
108+
}
109+
expect(last!.output as string).not.toContain('<system-reminder>');
110+
});
111+
112+
it('injects reminder2 at exactly 5 consecutive', async () => {
113+
const dedup = new ToolCallDeduplicator();
114+
let last: ExecutableToolResult | undefined;
115+
for (let i = 0; i < 5; i += 1) {
116+
dedup.beginStep();
117+
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
118+
dedup.endStep();
119+
}
120+
expect(last!.output as string).toContain('<system-reminder>');
121+
expect(last!.output as string).toContain('repeated_times: 5');
122+
expect(last!.output as string).toContain('tool: Read');
123+
expect(last!.output as string).toContain('arguments:');
124+
});
125+
126+
it('does not inject reminder at 6 or 7 consecutive', async () => {
127+
const dedup = new ToolCallDeduplicator();
128+
let last: ExecutableToolResult | undefined;
129+
for (let i = 0; i < 7; i += 1) {
130+
dedup.beginStep();
131+
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
132+
dedup.endStep();
133+
}
134+
expect(last!.output as string).not.toContain('<system-reminder>');
135+
});
136+
137+
it('injects reminder2 at exactly 8 consecutive', async () => {
138+
const dedup = new ToolCallDeduplicator();
139+
let last: ExecutableToolResult | undefined;
140+
for (let i = 0; i < 8; i += 1) {
141+
dedup.beginStep();
142+
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
143+
dedup.endStep();
144+
}
145+
expect(last!.output as string).toContain('<system-reminder>');
146+
expect(last!.output as string).toContain('repeated_times: 8');
147+
expect(last!.output as string).toContain('tool: Read');
98148
});
99149

100150
it('resets streak when a different call is interleaved', async () => {
101151
const dedup = new ToolCallDeduplicator();
102-
// 6× Read({p:1}) — should NOT trigger yet
103-
for (let i = 0; i < 6; i += 1) {
152+
// 2× Read({p:1}) — should NOT trigger yet
153+
for (let i = 0; i < 2; i += 1) {
104154
dedup.beginStep();
105155
await runOriginal(dedup, `a${String(i)}`, 'Read', { p: 1 }, okResult('R'));
106156
dedup.endStep();
@@ -116,15 +166,15 @@ describe('ToolCallDeduplicator', () => {
116166
expect(last.output as string).not.toContain('<system-reminder>');
117167
});
118168

119-
it('same-step dups inherit the reminder when streak triggers on original', async () => {
169+
it('same-step dups inherit reminder1 when streak triggers on original', async () => {
120170
const dedup = new ToolCallDeduplicator();
121-
// Build streak up to N-1 across previous steps.
122-
for (let i = 0; i < CROSS_STEP_DEDUP_TRIGGER_COUNT - 1; i += 1) {
171+
// Build streak up to 2 across previous steps.
172+
for (let i = 0; i < 2; i += 1) {
123173
dedup.beginStep();
124174
await runOriginal(dedup, `p${String(i)}`, 'Read', { p: 1 }, okResult('R'));
125175
dedup.endStep();
126176
}
127-
// Next step: same call appears twice. First is the original (triggers reminder),
177+
// Next step: same call appears twice. First is the original (triggers reminder1 at streak=3),
128178
// second is a same-step dup that should inherit it.
129179
dedup.beginStep();
130180
const original = await runOriginal(
@@ -140,18 +190,20 @@ describe('ToolCallDeduplicator', () => {
140190
dedup.endStep();
141191

142192
expect(original.output as string).toContain('<system-reminder>');
193+
expect(original.output as string).toContain('repeating the exact same tool call');
143194
expect(finalDup.output as string).toContain('<system-reminder>');
195+
expect(finalDup.output as string).toContain('repeating the exact same tool call');
144196
});
145197

146198
it('same-step spam alone does not trigger reminder', async () => {
147199
const dedup = new ToolCallDeduplicator();
148-
// 7 occurrences of the same call within a single step, but no prior
200+
// 8 occurrences of the same call within a single step, but no prior
149201
// streak — the trigger is about sustained behaviour across steps, not
150202
// intra-step spam. Same-step dedup already short-circuits execution.
151203
dedup.beginStep();
152204
const cached = dedup.checkSameStep('orig', 'Read', { p: 1 });
153205
expect(cached).toBeNull();
154-
for (let i = 0; i < CROSS_STEP_DEDUP_TRIGGER_COUNT - 1; i += 1) {
206+
for (let i = 0; i < 7; i += 1) {
155207
dedup.checkSameStep(`dup${String(i)}`, 'Read', { p: 1 });
156208
}
157209
const final = await dedup.finalizeResult('orig', 'Read', { p: 1 }, okResult('R'));
@@ -160,13 +212,13 @@ describe('ToolCallDeduplicator', () => {
160212
});
161213

162214
describe('reminder injection into ContentPart[] outputs', () => {
163-
it('appends to a trailing text part', async () => {
215+
it('appends reminder1 to a trailing text part at streak 3', async () => {
164216
const dedup = new ToolCallDeduplicator();
165217
const arrayResult: ExecutableToolResult = {
166218
output: [{ type: 'text', text: 'hello' }],
167219
};
168-
// Force streak = trigger by running N-1 prior steps then this one.
169-
for (let i = 0; i < CROSS_STEP_DEDUP_TRIGGER_COUNT - 1; i += 1) {
220+
// Build streak up to 2 prior steps then this one (streak=3).
221+
for (let i = 0; i < 2; i += 1) {
170222
dedup.beginStep();
171223
await runOriginal(dedup, `p${String(i)}`, 'X', {}, okResult('R'));
172224
dedup.endStep();
@@ -177,15 +229,36 @@ describe('ToolCallDeduplicator', () => {
177229
const arr = final.output as Array<{ type: string; text: string }>;
178230
expect(arr).toHaveLength(1);
179231
expect(arr[0]!.type).toBe('text');
180-
expect(arr[0]!.text).toBe('hello' + REMINDER_TEXT);
232+
expect(arr[0]!.text).toBe('hello' + REMINDER_TEXT_1);
233+
});
234+
235+
it('appends reminder2 to a trailing text part at streak 5', async () => {
236+
const dedup = new ToolCallDeduplicator();
237+
const arrayResult: ExecutableToolResult = {
238+
output: [{ type: 'text', text: 'hello' }],
239+
};
240+
// Build streak up to 4 prior steps then this one (streak=5).
241+
for (let i = 0; i < 4; i += 1) {
242+
dedup.beginStep();
243+
await runOriginal(dedup, `p${String(i)}`, 'X', { a: 1 }, okResult('R'));
244+
dedup.endStep();
245+
}
246+
dedup.beginStep();
247+
const final = await runOriginal(dedup, 'final', 'X', { a: 1 }, arrayResult);
248+
dedup.endStep();
249+
const arr = final.output as Array<{ type: string; text: string }>;
250+
expect(arr).toHaveLength(1);
251+
expect(arr[0]!.type).toBe('text');
252+
expect(arr[0]!.text).toBe('hello' + makeReminderText2('X', 5, { a: 1 }));
181253
});
182254

183255
it('pushes a new text part when trailing part is non-text', async () => {
184256
const dedup = new ToolCallDeduplicator();
185257
const arrayResult: ExecutableToolResult = {
186258
output: [{ type: 'image_url', imageUrl: { url: 'data:foo' } }],
187259
};
188-
for (let i = 0; i < CROSS_STEP_DEDUP_TRIGGER_COUNT - 1; i += 1) {
260+
// Build streak to 3.
261+
for (let i = 0; i < 2; i += 1) {
189262
dedup.beginStep();
190263
await runOriginal(dedup, `p${String(i)}`, 'X', {}, okResult('R'));
191264
dedup.endStep();
@@ -197,12 +270,13 @@ describe('ToolCallDeduplicator', () => {
197270
expect(arr).toHaveLength(2);
198271
expect(arr[0]!.type).toBe('image_url');
199272
expect(arr[1]!.type).toBe('text');
200-
expect(arr[1]!.text).toBe(REMINDER_TEXT);
273+
expect(arr[1]!.text).toBe(REMINDER_TEXT_1);
201274
});
202275

203276
it('preserves isError flag when injecting reminder', async () => {
204277
const dedup = new ToolCallDeduplicator();
205-
for (let i = 0; i < CROSS_STEP_DEDUP_TRIGGER_COUNT - 1; i += 1) {
278+
// Build streak to 3.
279+
for (let i = 0; i < 2; i += 1) {
206280
dedup.beginStep();
207281
await runOriginal(dedup, `p${String(i)}`, 'X', {}, errResult('boom'));
208282
dedup.endStep();

0 commit comments

Comments
 (0)