diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 6188dd7b44..ca1fbc0bca 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -34,9 +34,12 @@ import { import { createDuplicateProviderToolCallResponse, findRepeatedDuplicateProviderToolCall, + GeminiEventType, markDuplicateProviderToolCallResponseSent, + type ServerGeminiStreamEvent, type ToolCallRequestInfo, } from '../../core/turn.js'; +import { LoopDetectionService } from '../../services/loopDetectionService.js'; import { CoreToolScheduler, type ToolCall, @@ -88,6 +91,8 @@ import { matchesMcpPattern } from '../../permissions/rule-parser.js'; import { ToolNames } from '../../tools/tool-names.js'; import { DEFAULT_QWEN_MODEL } from '../../config/models.js'; import { type ContextState, templateString } from './agent-headless.js'; +import { getResponseText } from '../../utils/partUtils.js'; +import { getThoughtSummary } from '../../utils/thoughtUtils.js'; import { isTeammate, getTeammateContext, @@ -758,6 +763,19 @@ export class AgentCore { // provider id would keep deterministic providers in a tool-result loop. const duplicateProviderToolCallResponseIds = new Set(); let stickyMaxOutputTokens: number | undefined; + const loopDetector = new LoopDetectionService(this.runtimeContext); + loopDetector.reset( + `${this.runtimeContext.getSessionId()}#${this.subagentId}`, + ); + const checkSubagentLoop = (event: ServerGeminiStreamEvent): boolean => { + if (loopDetector.checkAlwaysOnSafeties(event)) { + return true; + } + return ( + !this.runtimeContext.getSkipLoopDetection() && + loopDetector.addAndCheckHeuristicLoops(event) + ); + }; while (true) { // Check abort before starting a new round — prevents unnecessary API @@ -821,6 +839,7 @@ export class AgentCore { undefined; let currentResponseId: string | undefined = undefined; let wasOutputTruncated = false; + let loopDetectedInStream = false; for await (const streamEvent of responseStream) { if (roundAbortController.signal.aborted) { @@ -835,6 +854,11 @@ export class AgentCore { // retry does not inherit stale data (e.g. wasOutputTruncated) from a // previous attempt that may have hit MAX_TOKENS. if (streamEvent.type === 'retry') { + if (checkSubagentLoop({ type: GeminiEventType.Retry })) { + terminateMode = AgentTerminateMode.LOOP_DETECTED; + loopDetectedInStream = true; + break; + } if (streamEvent.maxOutputTokensEscalated !== undefined) { stickyMaxOutputTokens = streamEvent.maxOutputTokensEscalated; } @@ -866,7 +890,8 @@ export class AgentCore { if (resp.responseId) { currentResponseId = resp.responseId; } - if (resp.functionCalls) functionCalls.push(...resp.functionCalls); + const chunkFunctionCalls = resp.functionCalls ?? []; + functionCalls.push(...chunkFunctionCalls); if ( resp.candidates?.[0]?.finishReason === FinishReason.MAX_TOKENS ) { @@ -889,9 +914,81 @@ export class AgentCore { }); } if (resp.usageMetadata) lastUsage = resp.usageMetadata; + + const thoughtSummary = getThoughtSummary(resp); + if ( + thoughtSummary && + checkSubagentLoop({ + type: GeminiEventType.Thought, + value: thoughtSummary, + }) + ) { + terminateMode = AgentTerminateMode.LOOP_DETECTED; + loopDetectedInStream = true; + break; + } + + const responseText = getResponseText(resp); + if ( + responseText && + checkSubagentLoop({ + type: GeminiEventType.Content, + value: responseText, + }) + ) { + terminateMode = AgentTerminateMode.LOOP_DETECTED; + loopDetectedInStream = true; + break; + } + + for (const fc of chunkFunctionCalls) { + const toolName = String(fc.name); + if ( + checkSubagentLoop({ + type: GeminiEventType.ToolCallRequest, + value: { + callId: fc.id ?? `${toolName}-${Date.now()}`, + providerCallId: getProviderToolCallId(fc), + name: toolName, + args: (fc.args ?? {}) as Record, + isClientInitiated: false, + prompt_id: promptId, + response_id: currentResponseId, + wasOutputTruncated, + }, + }) + ) { + terminateMode = AgentTerminateMode.LOOP_DETECTED; + loopDetectedInStream = true; + break; + } + } + if (loopDetectedInStream) { + break; + } + + const finishReason = resp.candidates?.[0]?.finishReason; + if ( + finishReason && + checkSubagentLoop({ + type: GeminiEventType.Finished, + value: { + reason: finishReason, + usageMetadata: resp.usageMetadata, + }, + }) + ) { + terminateMode = AgentTerminateMode.LOOP_DETECTED; + loopDetectedInStream = true; + break; + } } } + if (loopDetectedInStream) { + break; + } + if (roundText || roundThoughtText) { this.eventEmitter?.emit(AgentEventType.ROUND_TEXT, { subagentId: this.subagentId, diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 8d7c4df047..a0336c8b1e 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -1288,6 +1288,79 @@ describe('subagent.ts', () => { expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); }); + it('should stop consecutive identical tool calls with fresh ids', async () => { + const listDirectoryToolDef: FunctionDeclaration = { + name: 'list_directory', + description: 'Lists a directory', + parameters: { type: Type.OBJECT, properties: {} }, + }; + + const { config } = await createMockConfig({ + getFunctionDeclarationsFiltered: vi + .fn() + .mockReturnValue([listDirectoryToolDef]), + getTool: vi.fn().mockReturnValue(undefined), + }); + const toolConfig: ToolConfig = { tools: ['list_directory'] }; + const missingPath = '/workspace/project/missing-directory'; + + mockSendMessageStream.mockImplementation( + createMockStream([ + ...Array.from({ length: 5 }, (_, index) => [ + { + id: `call_${index + 1}`, + name: 'list_directory', + args: { path: missingPath }, + }, + ]), + 'stop', + ]), + ); + + const listDirectoryInvocation = { + params: { path: missingPath }, + getDescription: vi.fn().mockReturnValue('List directory'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + execute: vi.fn().mockResolvedValue({ + llmContent: + 'Error: ENOENT: no such file or directory, scandir ' + + missingPath, + returnDisplay: 'Directory not found', + }), + }; + const listDirectoryTool = { + name: 'list_directory', + displayName: 'List Directory', + description: 'List directory contents', + kind: 'READ' as const, + schema: listDirectoryToolDef, + build: vi.fn().mockImplementation(() => listDirectoryInvocation), + canUpdateOutput: false, + isOutputMarkdown: true, + } as unknown as AnyDeclarativeTool; + vi.mocked( + (config.getToolRegistry() as unknown as ToolRegistry).getTool, + ).mockImplementation((name: string) => + name === 'list_directory' ? listDirectoryTool : undefined, + ); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + ); + + await scope.execute(new ContextState()); + + expect(mockSendMessageStream).toHaveBeenCalledTimes(5); + expect(listDirectoryInvocation.execute).toHaveBeenCalledTimes(4); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.LOOP_DETECTED); + }); + it('should ignore duplicate provider tool-call ids already present in chat history', async () => { const listFilesToolDef: FunctionDeclaration = { name: 'list_files',