fix(core): detect subagent tool call loops (#6543)

This commit is contained in:
易良 2026-07-08 23:51:09 +08:00 committed by GitHub
parent b330ec884f
commit 016d624021
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 171 additions and 1 deletions

View file

@ -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<string>();
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,7 +914,79 @@ 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<string, unknown>,
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) {

View file

@ -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',