fix: 修复Agent Loop工具调用重复问题,移除web_fetch截断
- 新增工具调用缓存机制,避免重复调用相同工具+参数 - 新增同轮内重复检测和跨轮次重复检测,连续两轮相同调用自动终止循环 - TOOLS_USAGE_GUIDE 新增第8条严禁重复调用规则 - web_fetch 默认不截断(max_chars=0),返回完整内容 - 移除 tool-registry.ts executeTool 中重复的 logToolStart 调用
This commit is contained in:
@@ -433,9 +433,9 @@ export async function handleWebFetch(params: { url: string; max_chars?: number;
|
|||||||
return { success: false, error: '仅支持 http/https 协议' };
|
return { success: false, error: '仅支持 http/https 协议' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxChars = params.max_chars || 10000;
|
const maxChars = params.max_chars || 0; // 0 = 不截断,返回全部内容
|
||||||
|
|
||||||
sendLog('info', `🌐 web_fetch`, `${url} (${maxChars} chars)`);
|
sendLog('info', `🌐 web_fetch`, `${url} (${maxChars > 0 ? maxChars + ' chars' : '完整内容'})`);
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
headers: { 'User-Agent': 'MetonaOllama/1.0' },
|
headers: { 'User-Agent': 'MetonaOllama/1.0' },
|
||||||
signal: AbortSignal.timeout(15000)
|
signal: AbortSignal.timeout(15000)
|
||||||
@@ -466,7 +466,7 @@ export async function handleWebFetch(params: { url: string; max_chars?: number;
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncated = text.length > maxChars;
|
const truncated = maxChars > 0 && text.length > maxChars;
|
||||||
if (truncated) text = text.slice(0, maxChars);
|
if (truncated) text = text.slice(0, maxChars);
|
||||||
|
|
||||||
sendLog('success', `🌐 web_fetch 完成`, `${resp.status} | ${text.length} chars${truncated ? ' (已截断)' : ''}`);
|
sendLog('success', `🌐 web_fetch 完成`, `${resp.status} | ${text.length} chars${truncated ? ' (已截断)' : ''}`);
|
||||||
|
|||||||
@@ -39,7 +39,30 @@ const TOOL_USAGE_GUIDE = `【工具使用规则】
|
|||||||
- 用户让你查看/修改/分析文件 → read_file / list_directory / search_files
|
- 用户让你查看/修改/分析文件 → read_file / list_directory / search_files
|
||||||
- 用户问项目状态、代码内容 → git status / read_file
|
- 用户问项目状态、代码内容 → git status / read_file
|
||||||
- 用户让你执行操作 → run_command / write_file 等
|
- 用户让你执行操作 → run_command / write_file 等
|
||||||
宁可多调一次工具,也不要给出过时或编造的信息。`;
|
宁可多调一次工具,也不要给出过时或编造的信息。
|
||||||
|
8. 严禁重复调用:如果本次对话中已经成功调用过某个工具并获得了结果,不要再次调用相同工具和相同参数。直接使用已有结果继续工作。例如:已经 web_fetch 抓取了页面内容,下一步应直接处理该内容(如 write_file 保存),而不是重新抓取。`;
|
||||||
|
|
||||||
|
/** 工具调用缓存:key = toolName + JSON(args),用于避免重复调用 */
|
||||||
|
const toolResultCache = new Map<string, ToolResult>();
|
||||||
|
|
||||||
|
/** 生成工具调用缓存 key */
|
||||||
|
function getToolCacheKey(name: string, args: Record<string, unknown>): string {
|
||||||
|
try {
|
||||||
|
// 对 arguments 排序 key 以保证一致性
|
||||||
|
return name + '::' + JSON.stringify(args, Object.keys(args).sort());
|
||||||
|
} catch {
|
||||||
|
return name + '::' + String(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检测当前轮次是否存在重复工具调用 */
|
||||||
|
function isDuplicateCall(call: ToolCall, loopCalls: ToolCall[]): boolean {
|
||||||
|
const callKey = getToolCacheKey(call.function.name, call.function.arguments);
|
||||||
|
return loopCalls.some((prev, idx) => {
|
||||||
|
if (idx === loopCalls.length - 1) return false; // 跳过自身
|
||||||
|
return getToolCacheKey(prev.function.name, prev.function.arguments) === callKey;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentCallbacks {
|
export interface AgentCallbacks {
|
||||||
onThinking: (text: string) => void;
|
onThinking: (text: string) => void;
|
||||||
@@ -65,6 +88,9 @@ export async function runAgentLoop(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新一轮对话,清空工具缓存
|
||||||
|
toolResultCache.clear();
|
||||||
|
|
||||||
// 检查模型是否支持 Tool Calling
|
// 检查模型是否支持 Tool Calling
|
||||||
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
|
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
|
||||||
if (!modelSupportsTools) {
|
if (!modelSupportsTools) {
|
||||||
@@ -126,6 +152,8 @@ export async function runAgentLoop(
|
|||||||
const loopStartTime = Date.now();
|
const loopStartTime = Date.now();
|
||||||
let content = '';
|
let content = '';
|
||||||
let totalEvalCount = 0;
|
let totalEvalCount = 0;
|
||||||
|
/** 记录前一轮的工具调用 key,用于跨轮去重 */
|
||||||
|
let prevLoopToolKeys: string[] = [];
|
||||||
|
|
||||||
const makeStats = () => {
|
const makeStats = () => {
|
||||||
const totalDuration = (Date.now() - loopStartTime) * 1e6; // 转为纳秒
|
const totalDuration = (Date.now() - loopStartTime) * 1e6; // 转为纳秒
|
||||||
@@ -263,6 +291,17 @@ export async function runAgentLoop(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 跨轮次完整重复检测:如果本轮所有工具调用与上一轮完全相同,终止循环
|
||||||
|
const currentLoopKeys = toolCalls.map(c => getToolCacheKey(c.function.name, c.function.arguments)).sort();
|
||||||
|
const currentKeysStr = JSON.stringify(currentLoopKeys);
|
||||||
|
const prevKeysStr = JSON.stringify([...prevLoopToolKeys].sort());
|
||||||
|
if (currentKeysStr === prevKeysStr && currentLoopKeys.length > 0) {
|
||||||
|
logWarn('检测到连续两轮工具调用完全相同,终止 Agent Loop', currentKeysStr.slice(0, 200));
|
||||||
|
callbacks.onDone(content || '(检测到重复工具调用,已自动停止)', allToolRecords, makeStats());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevLoopToolKeys = currentLoopKeys;
|
||||||
|
|
||||||
for (const call of toolCalls) {
|
for (const call of toolCalls) {
|
||||||
// 检查是否已中止
|
// 检查是否已中止
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
@@ -270,6 +309,36 @@ export async function runAgentLoop(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同一轮内重复调用检测
|
||||||
|
if (isDuplicateCall(call, toolCalls)) {
|
||||||
|
logWarn(`跳过重复工具调用: ${call.function.name}`, JSON.stringify(call.function.arguments).slice(0, 200));
|
||||||
|
const cachedKey = getToolCacheKey(call.function.name, call.function.arguments);
|
||||||
|
const cached = toolResultCache.get(cachedKey);
|
||||||
|
if (cached) {
|
||||||
|
messages.push({
|
||||||
|
role: 'tool',
|
||||||
|
tool_name: call.function.name,
|
||||||
|
content: JSON.stringify(cached)
|
||||||
|
});
|
||||||
|
callbacks.onToolCallResult(call.function.name, cached, call);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跨轮次缓存命中检测
|
||||||
|
const cacheKey = getToolCacheKey(call.function.name, call.function.arguments);
|
||||||
|
const cachedResult = toolResultCache.get(cacheKey);
|
||||||
|
if (cachedResult) {
|
||||||
|
logInfo(`工具缓存命中: ${call.function.name}`, JSON.stringify(call.function.arguments).slice(0, 200));
|
||||||
|
messages.push({
|
||||||
|
role: 'tool',
|
||||||
|
tool_name: call.function.name,
|
||||||
|
content: JSON.stringify(cachedResult)
|
||||||
|
});
|
||||||
|
callbacks.onToolCallResult(call.function.name, cachedResult, call);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
callbacks.onToolCallStart(call);
|
callbacks.onToolCallStart(call);
|
||||||
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
|
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
|
||||||
|
|
||||||
@@ -333,6 +402,10 @@ export async function runAgentLoop(
|
|||||||
tool_name: call.function.name,
|
tool_name: call.function.name,
|
||||||
content: JSON.stringify(result)
|
content: JSON.stringify(result)
|
||||||
});
|
});
|
||||||
|
// 缓存成功结果(非 run_command,因为命令执行可能有副作用)
|
||||||
|
if (result.success && call.function.name !== 'run_command') {
|
||||||
|
toolResultCache.set(cacheKey, result);
|
||||||
|
}
|
||||||
callbacks.onToolCallResult(call.function.name, result, call);
|
callbacks.onToolCallResult(call.function.name, result, call);
|
||||||
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
|
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
required: ['url'],
|
required: ['url'],
|
||||||
properties: {
|
properties: {
|
||||||
url: { type: 'string', description: 'URL to fetch (http/https).' },
|
url: { type: 'string', description: 'URL to fetch (http/https).' },
|
||||||
max_chars: { type: 'integer', description: 'Max characters to return. Default: 10000.' },
|
max_chars: { type: 'integer', description: 'Max characters to return. Default: 0 (no limit, return full content).' },
|
||||||
extract_mode: { type: 'string', enum: ['text', 'markdown'], description: 'Extraction mode. Default: text.' }
|
extract_mode: { type: 'string', enum: ['text', 'markdown'], description: 'Extraction mode. Default: text.' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,8 +409,6 @@ export async function executeTool(toolName: string, args: Record<string, unknown
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logToolStart(toolName, JSON.stringify(args));
|
|
||||||
|
|
||||||
// run_command 走 workspace IPC
|
// run_command 走 workspace IPC
|
||||||
if (toolName === 'run_command') {
|
if (toolName === 'run_command') {
|
||||||
const command = args.command as string;
|
const command = args.command as string;
|
||||||
|
|||||||
Reference in New Issue
Block a user