fix(agent): detect repeated tool call loops and make max_iterations configurable
Local LLMs often get stuck calling the same tool repeatedly because they lack the sophistication to synthesize results. The agent loop had no safeguard — it re-executed whatever the model requested up to 10 times. Add fingerprint-based loop detection: if the same tool+args combination repeats 3 consecutive times, break the loop and return the last results. Also add agents.max_iterations to the config schema so the iteration limit is user-configurable (default: 10).
This commit is contained in:
@@ -121,14 +121,18 @@ describe('NativeAgent tool loop', () => {
|
||||
expect(mockClient.chat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('respects max iterations', async () => {
|
||||
// Model always returns tool_use
|
||||
it('respects max iterations when tool calls vary', async () => {
|
||||
// Model always returns tool_use but with different args each time (no loop detection)
|
||||
let callCount = 0;
|
||||
const mockClient: ModelClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
content: '',
|
||||
stopReason: 'tool_use',
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
toolCalls: [{ id: 'call_1', name: 'test.echo', args: { text: 'loop' } }],
|
||||
chat: vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
return {
|
||||
content: '',
|
||||
stopReason: 'tool_use',
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
toolCalls: [{ id: `call_${callCount}`, name: 'test.echo', args: { text: `attempt_${callCount}` } }],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -150,6 +154,37 @@ describe('NativeAgent tool loop', () => {
|
||||
expect(mockClient.chat).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('detects repeated identical tool calls and breaks the loop', async () => {
|
||||
// Model always returns the exact same tool call — simulates local LLM stuck in a loop
|
||||
const mockClient: ModelClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
content: '',
|
||||
stopReason: 'tool_use',
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
toolCalls: [{ id: 'call_1', name: 'test.echo', args: { text: 'same thing' } }],
|
||||
}),
|
||||
};
|
||||
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(echoTool);
|
||||
const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
|
||||
const executor = new ToolExecutor(registry, hooks);
|
||||
|
||||
const agent = new NativeAgent({
|
||||
modelClient: mockClient,
|
||||
systemPrompt: 'You are helpful.',
|
||||
toolRegistry: registry,
|
||||
toolExecutor: executor,
|
||||
maxIterations: 10,
|
||||
});
|
||||
|
||||
const response = await agent.process('search for news');
|
||||
expect(response).toContain('Tool loop detected');
|
||||
expect(response).toContain('same thing'); // includes the last tool result
|
||||
// Should break after 3 consecutive identical calls, not 10
|
||||
expect(mockClient.chat).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('works without tools (backward compatible)', async () => {
|
||||
const mockClient: ModelClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user