mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(core): detect subagent tool call loops (#6543)
This commit is contained in:
parent
b330ec884f
commit
016d624021
2 changed files with 171 additions and 1 deletions
|
|
@ -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,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<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) {
|
||||
this.eventEmitter?.emit(AgentEventType.ROUND_TEXT, {
|
||||
subagentId: this.subagentId,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue