v0.14.10: Agent Loop 引擎优化 - 修复误杀+去重+稳定性增强
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-v0.14.9-E8734A?style=flat-square" alt="version">
|
<img src="https://img.shields.io/badge/version-v0.14.10-E8734A?style=flat-square" alt="version">
|
||||||
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
|
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
|
||||||
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
|
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
||||||
@@ -253,7 +253,7 @@ npm start
|
|||||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||||
```
|
```
|
||||||
|
|
||||||
产出:`release/Metona Ollama Setup v0.14.9.exe`
|
产出:`release/Metona Ollama Setup v0.14.10.exe`
|
||||||
|
|
||||||
## 🛠️ 常用命令
|
## 🛠️ 常用命令
|
||||||
|
|
||||||
@@ -501,7 +501,7 @@ npm start
|
|||||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||||
```
|
```
|
||||||
|
|
||||||
Output: `release/Metona Ollama Setup v0.14.9.exe`
|
Output: `release/Metona Ollama Setup v0.14.10.exe`
|
||||||
|
|
||||||
## 🛠️ Common Commands
|
## 🛠️ Common Commands
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.14.9",
|
"version": "0.14.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.14.9",
|
"version": "0.14.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.14.9",
|
"version": "0.14.10",
|
||||||
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
||||||
"main": "dist/main/main.js",
|
"main": "dist/main/main.js",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
||||||
<span class="app-title">Metona Ollama</span>
|
<span class="app-title">Metona Ollama</span>
|
||||||
<span class="app-version">v0.14.9</span>
|
<span class="app-version">v0.14.10</span>
|
||||||
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||||
|
|||||||
@@ -19,12 +19,11 @@
|
|||||||
## 核心规则
|
## 核心规则
|
||||||
|
|
||||||
1. **直接行动**:不要只说"我来帮你xxx"然后结束。说了要做就必须调用工具。
|
1. **直接行动**:不要只说"我来帮你xxx"然后结束。说了要做就必须调用工具。
|
||||||
2. **不要过早停止**:工具调用后如果信息还不完整,继续调用工具。不要在信息不足时给出不完整的回答。
|
2. **坚持完成任务**:信息不足时继续调用工具,不要过早停止。途径失败时换方法重试,不要反复重试相同的失败操作。
|
||||||
3. **多步任务逐步完成**:复杂任务拆解为多步,逐步执行。某个途径失败时换其他方法,不要卡住。
|
3. **多步任务逐步完成**:复杂任务拆解为多步,逐步执行。某个途径失败时换其他方法,不要卡住。
|
||||||
4. **需要最新信息时必须搜索**:用户每次要求搜索/查资料/找信息时,都必须重新调用 web_search。历史对话中的搜索结果不可复用——信息可能已过时,且用户期望的是实时数据。不要用训练数据或记忆替代工具调用。
|
4. **需要最新信息时必须搜索**:用户每次要求搜索/查资料/找信息时,都必须重新调用 web_search。历史对话中的搜索结果不可复用——信息可能已过时,且用户期望的是实时数据。不要用训练数据或记忆替代工具调用。
|
||||||
5. **出错换方法重试**:同一途径连续失败后换其他方法,不要反复重试相同的失败操作。
|
5. **优先复用已有结果**:相同参数的工具不重复执行。先检查已有结果是否够用,优先从已获取的结果中提取答案。
|
||||||
6. **不重复调用**:相同参数的工具不重复执行。先检查已有结果是否够用。
|
6. **工具返回的数据是事实来源**:工具返回的结果(role: tool 消息)是真实数据,严格基于这些数据回答。不要用系统提示词内容、训练数据或记忆替代工具返回的实际数据。
|
||||||
7. **善用已有信息**:优先从已获取的结果中提取答案,减少不必要的重复调用。
|
|
||||||
|
|
||||||
## 工具联动模式
|
## 工具联动模式
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,60 @@ function smartTruncate(text: string, maxLen: number): string {
|
|||||||
return text.slice(0, maxLen) + `... (${text.length - maxLen}字符 已截断)`;
|
return text.slice(0, maxLen) + `... (${text.length - maxLen}字符 已截断)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** P2-4: 工具参数前置校验 — 轻量级参数检查,避免无效参数浪费一轮迭代 */
|
||||||
|
function validateToolArgs(toolName: string, args: Record<string, unknown>): string | null {
|
||||||
|
const getString = (key: string): string | null => {
|
||||||
|
const v = args[key];
|
||||||
|
if (typeof v === 'string' && v.trim().length > 0) return v;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
switch (toolName) {
|
||||||
|
case 'read_file':
|
||||||
|
case 'write_file':
|
||||||
|
case 'delete_file':
|
||||||
|
case 'create_directory':
|
||||||
|
case 'get_file_info':
|
||||||
|
case 'tree':
|
||||||
|
case 'compress':
|
||||||
|
if (!getString('path')) return `${toolName} 缺少有效的 path 参数`;
|
||||||
|
break;
|
||||||
|
case 'list_directory':
|
||||||
|
if (!getString('path')) return 'list_directory 缺少 path 参数';
|
||||||
|
break;
|
||||||
|
case 'search_files':
|
||||||
|
if (!getString('query')) return 'search_files 缺少有效的 query 参数';
|
||||||
|
if (!getString('path')) return 'search_files 缺少 path 参数';
|
||||||
|
break;
|
||||||
|
case 'web_fetch':
|
||||||
|
if (!args.url || typeof args.url !== 'string' || !/^https?:\/\//.test(args.url as string))
|
||||||
|
return 'web_fetch 的 url 参数无效(需以 http:// 或 https:// 开头)';
|
||||||
|
break;
|
||||||
|
case 'web_search':
|
||||||
|
if (!getString('query')) return 'web_search 缺少有效的 query 参数';
|
||||||
|
break;
|
||||||
|
case 'run_command':
|
||||||
|
if (!getString('command')) return 'run_command 缺少有效的 command 参数';
|
||||||
|
break;
|
||||||
|
case 'move_file':
|
||||||
|
case 'copy_file':
|
||||||
|
if (!getString('source')) return `${toolName} 缺少 source 参数`;
|
||||||
|
if (!getString('destination')) return `${toolName} 缺少 destination 参数`;
|
||||||
|
break;
|
||||||
|
case 'edit_file':
|
||||||
|
if (!getString('path')) return 'edit_file 缺少 path 参数';
|
||||||
|
if (args.old_text === undefined || args.old_text === null)
|
||||||
|
return 'edit_file 缺少 old_text 参数';
|
||||||
|
if (args.new_text === undefined || args.new_text === null)
|
||||||
|
return 'edit_file 缺少 new_text 参数';
|
||||||
|
break;
|
||||||
|
case 'diff_files':
|
||||||
|
if (!getString('file1')) return 'diff_files 缺少 file1 参数';
|
||||||
|
if (!getString('file2')) return 'diff_files 缺少 file2 参数';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 工具名白名单:用于文本解析兜底时过滤非法工具名 */
|
/** 工具名白名单:用于文本解析兜底时过滤非法工具名 */
|
||||||
const VALID_TOOL_NAMES = new Set([
|
const VALID_TOOL_NAMES = new Set([
|
||||||
'read_file', 'write_file', 'list_directory', 'search_files', 'create_directory',
|
'read_file', 'write_file', 'list_directory', 'search_files', 'create_directory',
|
||||||
@@ -246,35 +300,25 @@ const VALID_TOOL_NAMES = new Set([
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 文本解析兜底:当模型没有通过 tool_calls 字段返回工具调用,
|
* 文本解析兜底:当模型没有通过 tool_calls 字段返回工具调用,
|
||||||
* 而是在文本中写了 "Action: xxx" / "Action Input: {...}" 时,
|
* 而是在文本中写了工具调用时,从文本中提取。
|
||||||
* 从文本中提取工具调用。
|
*
|
||||||
|
* P2-3 增强:支持4种格式
|
||||||
|
* 1. Action/Action Input 格式(原有)
|
||||||
|
* 2. <tool_call> XML 标签格式
|
||||||
|
* 3. ```json 代码块中含 "name" 字段
|
||||||
|
* 4. 函数调用语法 func_name({...})
|
||||||
*/
|
*/
|
||||||
function parseToolCallsFromText(content: string): ToolCall[] {
|
function parseToolCallsFromText(content: string): ToolCall[] {
|
||||||
const calls: ToolCall[] = [];
|
const calls: ToolCall[] = [];
|
||||||
|
|
||||||
const actionRegex = /\*{0,2}Action:?\*{0,2}\s*(\w+)\s+[\r\n\s]*\*{0,2}Action\s*Input:?\*{0,2}\s*(\{[\s\S]*?\})/gi;
|
// 辅助函数:尝试解析 JSON 参数字符串,容错处理
|
||||||
|
const tryParseArgs = (argsStr: string): Record<string, unknown> | null => {
|
||||||
let match;
|
|
||||||
while ((match = actionRegex.exec(content)) !== null) {
|
|
||||||
const toolName = match[1].trim();
|
|
||||||
const argsStr = match[2].trim();
|
|
||||||
|
|
||||||
if (!VALID_TOOL_NAMES.has(toolName)) continue;
|
|
||||||
|
|
||||||
const TICK = String.fromCharCode(96);
|
const TICK = String.fromCharCode(96);
|
||||||
const tickJson = TICK + TICK + TICK + 'json';
|
const tickJson = TICK + TICK + TICK + 'json';
|
||||||
const tick3 = TICK + TICK + TICK;
|
const tick3 = TICK + TICK + TICK;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let cleaned = argsStr
|
let cleaned = argsStr.split(tickJson).join('').split(tick3).join('').trim();
|
||||||
.split(tickJson).join('')
|
return JSON.parse(cleaned);
|
||||||
.split(tick3).join('')
|
|
||||||
.trim();
|
|
||||||
const args = JSON.parse(cleaned);
|
|
||||||
calls.push({
|
|
||||||
type: 'function',
|
|
||||||
function: { name: toolName, arguments: args }
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
let fixed = argsStr
|
let fixed = argsStr
|
||||||
@@ -284,17 +328,79 @@ function parseToolCallsFromText(content: string): ToolCall[] {
|
|||||||
.split(tickJson).join('')
|
.split(tickJson).join('')
|
||||||
.split(tick3).join('')
|
.split(tick3).join('')
|
||||||
.trim();
|
.trim();
|
||||||
const args = JSON.parse(fixed);
|
return JSON.parse(fixed);
|
||||||
calls.push({
|
|
||||||
type: 'function',
|
|
||||||
function: { name: toolName, arguments: args }
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 辅助函数:验证工具名并添加到结果
|
||||||
|
const tryAddCall = (toolName: string, argsStr: string): boolean => {
|
||||||
|
toolName = toolName.trim();
|
||||||
|
if (!VALID_TOOL_NAMES.has(toolName)) return false;
|
||||||
|
const args = tryParseArgs(argsStr);
|
||||||
|
if (!args) {
|
||||||
logWarn("文本解析兜底: 工具 " + toolName + " 的参数 JSON 解析失败", argsStr.slice(0, 100));
|
logWarn("文本解析兜底: 工具 " + toolName + " 的参数 JSON 解析失败", argsStr.slice(0, 100));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
calls.push({ type: 'function', function: { name: toolName, arguments: args } });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 格式1: Action/Action Input(原有格式)──
|
||||||
|
const actionRegex = /\*{0,2}Action:?\*{0,2}\s*(\w+)\s+[\r\n\s]*\*{0,2}Action\s*Input:?\*{0,2}\s*(\{[\s\S]*?\})/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = actionRegex.exec(content)) !== null) {
|
||||||
|
tryAddCall(match[1], match[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 格式2: <tool_call> XML 标签 ──
|
||||||
|
// 匹配 <tool_call>{"name": "xxx", "arguments": {...}}</tool_call>
|
||||||
|
const xmlRegex = /<tool_call>\s*([\s\S]*?)<\/tool_call>/gi;
|
||||||
|
while ((match = xmlRegex.exec(content)) !== null) {
|
||||||
|
const inner = match[1].trim().replace(/```json\s*/g, '').replace(/```/g, '').trim();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(inner);
|
||||||
|
const toolName = parsed.name || parsed.function?.name || '';
|
||||||
|
const toolArgs = parsed.arguments || parsed.function?.arguments || parsed.parameters || {};
|
||||||
|
if (toolName && VALID_TOOL_NAMES.has(toolName)) {
|
||||||
|
calls.push({ type: 'function', function: { name: toolName, arguments: toolArgs } });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON 解析失败,尝试分别提取 name 和 arguments
|
||||||
|
const nameMatch = inner.match(/"name"\s*:\s*"(\w+)"/i);
|
||||||
|
if (nameMatch) {
|
||||||
|
const argsMatch = inner.match(/"arguments"\s*:\s*(\{[\s\S]*\})/i);
|
||||||
|
if (argsMatch) tryAddCall(nameMatch[1], argsMatch[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 格式3: ```json 代码块中含 "name" 字段 ──
|
||||||
|
// 匹配 ```json\n{"name": "xxx", "arguments": {...}}\n```
|
||||||
|
const codeBlockRegex = /```(?:json)?\s*(\{[\s\S]*?"name"\s*:\s*"\w+"[\s\S]*?\})\s*```/gi;
|
||||||
|
while ((match = codeBlockRegex.exec(content)) !== null) {
|
||||||
|
const jsonStr = match[1].trim();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
const toolName = parsed.name || '';
|
||||||
|
const toolArgs = parsed.arguments || parsed.parameters || {};
|
||||||
|
if (toolName && VALID_TOOL_NAMES.has(toolName)) {
|
||||||
|
calls.push({ type: 'function', function: { name: toolName, arguments: toolArgs } });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 解析失败忽略,其他格式可能匹配
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 格式4: 函数调用语法 func_name({"key": "value"}) ──
|
||||||
|
// 匹配 read_file({"path": "xxx"})
|
||||||
|
const funcCallRegex = /\b(\w+)\s*\(\s*(\{[^}]*\})\s*\)/g;
|
||||||
|
while ((match = funcCallRegex.exec(content)) !== null) {
|
||||||
|
tryAddCall(match[1], match[2]);
|
||||||
|
}
|
||||||
|
|
||||||
if (calls.length > 0) {
|
if (calls.length > 0) {
|
||||||
logInfo("文本解析兜底: 从回复中提取到 " + calls.length + " 个工具调用", calls.map(c => c.function.name).join(', '));
|
logInfo("文本解析兜底: 从回复中提取到 " + calls.length + " 个工具调用", calls.map(c => c.function.name).join(', '));
|
||||||
}
|
}
|
||||||
@@ -718,15 +824,11 @@ Shell: ${osInfo.shell}
|
|||||||
用户目录: ${osInfo.homeDir}
|
用户目录: ${osInfo.homeDir}
|
||||||
换行符: ${osInfo.lineEnding}
|
换行符: ${osInfo.lineEnding}
|
||||||
路径分隔符: ${osInfo.pathSep}
|
路径分隔符: ${osInfo.pathSep}
|
||||||
|
⚠️ 命令语法必须匹配当前OS(${osInfo.os}),跨平台命令会失败。`);
|
||||||
|
|
||||||
⚠️ 重要:必须使用与上述操作系统匹配的命令语法。
|
// 日期(精简强调措辞,保留核心信息)
|
||||||
- 如果是 Windows,使用 CMD/PowerShell 命令(如 dir、type、findstr,路径用 \\)
|
|
||||||
- 如果是 Linux/macOS,使用 Bash 命令(如 ls、cat、grep,路径用 /)
|
|
||||||
- 严禁在 Windows 上执行 Linux 命令,严禁在 Linux 上执行 Windows 命令。`);
|
|
||||||
|
|
||||||
// 日期
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
parts.push(`[日期] ${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日(此日期来自系统时钟,绝对可信。你的训练数据可能已过时,请以此日期为准构造所有搜索查询和时效性回答。绝对不要基于训练数据推断日期。)`);
|
parts.push(`[日期] ${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日(系统时钟,以此为准)`);
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
@@ -1155,36 +1257,38 @@ async function handleThinking(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 任务感知 — 检测用户请求需要工具但模型尚未行动 ──
|
// P1-1 优化:合并任务感知、待办追踪、Token 预算警告为1条结构化提醒
|
||||||
|
// 减少上下文中合成 user 消息数量,降低模型注意力分散
|
||||||
|
const _reminders: string[] = [];
|
||||||
|
|
||||||
|
// 任务感知 — 检测用户请求需要工具但模型尚未行动
|
||||||
|
// P1-2 优化:用更精确的短语替代单字匹配,避免"查无此人"等误报
|
||||||
if (ctx.loopCount === 2 && ctx.allToolRecords.length === 0) {
|
if (ctx.loopCount === 2 && ctx.allToolRecords.length === 0) {
|
||||||
// 从用户第一条消息中检测是否需要工具
|
|
||||||
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
|
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
|
||||||
const userText = (firstUserMsg?.content || '').toLowerCase();
|
const userText = (firstUserMsg?.content || '').toLowerCase();
|
||||||
const actionVerbs = ['搜索', '查找', '查', '写入', '创建', '生成', '运行', '执行', '打开', '抓取',
|
const actionVerbs = [
|
||||||
'获取', '下载', '提交', '推送', '克隆', '读取', '删除', '移动', '复制', '压缩',
|
'搜索', '查找', '查一下', '搜一下', '检索', '查询',
|
||||||
'search', 'find', 'write', 'create', 'run', 'execute', 'fetch', 'download', 'clone'];
|
'写入文件', '创建文件', '生成文件', '保存文件',
|
||||||
const needsTools = actionVerbs.some(v => userText.includes(v)) && userText.length > 20;
|
'运行命令', '执行命令', '运行脚本', '编译',
|
||||||
|
'抓取网页', '获取内容', '抓取全文',
|
||||||
|
'下载文件', '提交代码', '推送代码', '克隆仓库',
|
||||||
|
'读取文件', '删除文件', '移动文件', '复制文件', '压缩文件',
|
||||||
|
'search', 'find', 'write', 'create', 'run', 'execute', 'fetch', 'download', 'clone',
|
||||||
|
];
|
||||||
|
const needsTools = actionVerbs.some(v => userText.includes(v)) && userText.length > 30;
|
||||||
if (needsTools) {
|
if (needsTools) {
|
||||||
ctx.messages.push({
|
_reminders.push('请直接调用工具完成任务,不要只描述做法');
|
||||||
role: 'user',
|
logInfo('任务感知: 检测到需要工具');
|
||||||
content: '请直接调用工具完成任务,不要只描述做法。',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
logInfo('任务感知: 注入工具调用提醒');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 多步骤任务待办追踪 — 用户一句话里有多个动作时,提醒未完成的 ──
|
// 多步骤任务待办追踪 — 用户一句话里有多个动作时,提醒未完成的
|
||||||
if (ctx.loopCount >= 2 && ctx.allToolRecords.length > 0) {
|
if (ctx.loopCount >= 2 && ctx.allToolRecords.length > 0) {
|
||||||
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
|
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
|
||||||
const userText = firstUserMsg?.content || '';
|
const userText = firstUserMsg?.content || '';
|
||||||
const pending = detectPendingActions(userText, ctx.allToolRecords);
|
const pending = detectPendingActions(userText, ctx.allToolRecords);
|
||||||
if (pending.length > 0) {
|
if (pending.length > 0) {
|
||||||
ctx.messages.push({
|
_reminders.push(`待办: ${pending.join('、')}`);
|
||||||
role: 'user',
|
|
||||||
content: `还有任务未完成: ${pending.join('、')}`,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
logInfo('待办追踪', pending.join(', '));
|
logInfo('待办追踪', pending.join(', '));
|
||||||
}
|
}
|
||||||
// 注:不再注入"全部完成"确认信号。工具调用数量≠任务完成度,
|
// 注:不再注入"全部完成"确认信号。工具调用数量≠任务完成度,
|
||||||
@@ -1195,8 +1299,15 @@ async function handleThinking(
|
|||||||
// Token 预算警告
|
// Token 预算警告
|
||||||
const remaining = ctx.maxLoops - ctx.loopCount + 1;
|
const remaining = ctx.maxLoops - ctx.loopCount + 1;
|
||||||
if (remaining <= 3 && remaining > 0) {
|
if (remaining <= 3 && remaining > 0) {
|
||||||
const warning = `剩余 ${remaining} 轮,尽快完成。`;
|
_reminders.push(`剩余 ${remaining} 轮,尽快完成`);
|
||||||
ctx.messages.push({ role: 'user', content: warning, ephemeral: true });
|
}
|
||||||
|
|
||||||
|
// 合并为1条结构化提醒(减少上下文噪音)
|
||||||
|
if (_reminders.length > 0) {
|
||||||
|
const reminderContent = _reminders.length === 1
|
||||||
|
? `[系统提醒] ${_reminders[0]}`
|
||||||
|
: `[系统提醒]\n${_reminders.map((r, i) => `${i + 1}. ${r}`).join('\n')}`;
|
||||||
|
ctx.messages.push({ role: 'user', content: reminderContent, ephemeral: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const abortController = registerAbortController();
|
const abortController = registerAbortController();
|
||||||
@@ -1460,6 +1571,17 @@ async function handleExecuting(
|
|||||||
toolResultCache.delete(cacheKey);
|
toolResultCache.delete(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P2-4: 工具参数前置校验 — 参数无效直接返回错误,不执行实际工具,节省一轮迭代
|
||||||
|
const paramError = validateToolArgs(call.function.name, call.function.arguments);
|
||||||
|
if (paramError) {
|
||||||
|
logWarn(`参数校验失败: ${call.function.name}`, paramError);
|
||||||
|
return [{
|
||||||
|
name: call.function.name, arguments: call.function.arguments,
|
||||||
|
result: { success: false, error: paramError },
|
||||||
|
status: 'error' as const, timestamp: Date.now()
|
||||||
|
}, null];
|
||||||
|
}
|
||||||
|
|
||||||
// P2-10 修复:queueMicrotask 替代 rAF,在隐藏窗口下不会降频
|
// P2-10 修复:queueMicrotask 替代 rAF,在隐藏窗口下不会降频
|
||||||
await new Promise<void>(r => { queueMicrotask(r); });
|
await new Promise<void>(r => { queueMicrotask(r); });
|
||||||
callbacks.onToolCallStart(call);
|
callbacks.onToolCallStart(call);
|
||||||
@@ -1506,10 +1628,10 @@ async function handleExecuting(
|
|||||||
const result = await executeTool(call.function.name, call.function.arguments)
|
const result = await executeTool(call.function.name, call.function.arguments)
|
||||||
.catch(err => ({ success: false, error: err?.message || String(err) }) as ToolResult);
|
.catch(err => ({ success: false, error: err?.message || String(err) }) as ToolResult);
|
||||||
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
|
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
|
||||||
// 记录 write_file 成功路径,供 FileWriteDedup Hook 拦截重复写入
|
// 记录 write_file 成功路径及内容指纹,供 FileWriteDedup Hook 做内容级去重
|
||||||
if (call.function.name === 'write_file' && result.success && call.function.arguments?.path) {
|
if (call.function.name === 'write_file' && result.success && call.function.arguments?.path) {
|
||||||
const { addWrittenFile } = await import('./hooks.js');
|
const { addWrittenFile } = await import('./hooks.js');
|
||||||
addWrittenFile(String(call.function.arguments.path));
|
addWrittenFile(String(call.function.arguments.path), String(call.function.arguments.content || ''));
|
||||||
}
|
}
|
||||||
return [{
|
return [{
|
||||||
name: call.function.name, arguments: call.function.arguments,
|
name: call.function.name, arguments: call.function.arguments,
|
||||||
@@ -1563,19 +1685,10 @@ async function handleExecuting(
|
|||||||
role: 'tool', tool_name: record.name,
|
role: 'tool', tool_name: record.name,
|
||||||
content: `<<<TOOL_RESULT_START name="${record.name}">>>\n${formatToolResultForModel(record.name, record.result!)}\n<<<TOOL_RESULT_END>>>`
|
content: `<<<TOOL_RESULT_START name="${record.name}">>>\n${formatToolResultForModel(record.name, record.result!)}\n<<<TOOL_RESULT_END>>>`
|
||||||
});
|
});
|
||||||
// ── 读数据类工具结果加硬提示,防止模型忽略实际数据去用系统提示词编造 ──
|
// P1-3 优化:移除逐次 nudge 注入,改为 AGENT.md 通用规则
|
||||||
if (record.status === 'success') {
|
// 原逻辑:memory/session_read/session_list/spawn_task 后注入额外 user 消息
|
||||||
const nudges: Record<string, string> = {
|
// 问题:每轮最多多4条合成 user 消息,污染上下文
|
||||||
memory: '以上 tool 消息是 memory 工具返回的实际记忆数据。严格基于这些数据回答,不要把系统提示词里的内容当成记忆列出来。',
|
// 方案:在 AGENT.md 核心规则中增加"工具返回数据是事实来源"通用规则
|
||||||
session_read: '以上 tool 消息是 session_read 返回的实际历史会话内容。严格基于这些数据回答,不要编造。',
|
|
||||||
session_list: '以上 tool 消息是 session_list 返回的实际会话列表。严格基于这些数据回答,不要编造。',
|
|
||||||
spawn_task: '以上 tool 消息是 spawn_task 子代理返回的实际执行结果。严格基于这些数据回答,不要编造。',
|
|
||||||
};
|
|
||||||
const nudge = nudges[record.name];
|
|
||||||
if (nudge) {
|
|
||||||
ctx.messages.push({ role: 'user', content: `⚠️ ${nudge}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cacheKey) toolResultCache.set(cacheKey, { result: record.result!, timestamp: Date.now() });
|
if (cacheKey) toolResultCache.set(cacheKey, { result: record.result!, timestamp: Date.now() });
|
||||||
// 记录度量
|
// 记录度量
|
||||||
recordToolCall(record.name, record.status, Date.now() - record.timestamp);
|
recordToolCall(record.name, record.status, Date.now() - record.timestamp);
|
||||||
@@ -1630,6 +1743,29 @@ async function handleObserving(
|
|||||||
// 保存本轮工具调用
|
// 保存本轮工具调用
|
||||||
ctx.prevToolCalls = [...ctx.toolCalls];
|
ctx.prevToolCalls = [...ctx.toolCalls];
|
||||||
|
|
||||||
|
// P2-1: 工具调用失败恢复指引 — 同一工具同一参数连续失败2次以上时注入恢复建议
|
||||||
|
{
|
||||||
|
const errorRecords = ctx.allToolRecords.filter(r => r.status === 'error');
|
||||||
|
if (errorRecords.length > 0) {
|
||||||
|
const lastError = errorRecords[errorRecords.length - 1];
|
||||||
|
const lastErrorKey = getToolCacheKey(lastError.name, lastError.arguments);
|
||||||
|
// 检查是否连续失败同一工具同一参数
|
||||||
|
const sameFailures = errorRecords.filter(r =>
|
||||||
|
r.name === lastError.name &&
|
||||||
|
getToolCacheKey(r.name, r.arguments) === lastErrorKey
|
||||||
|
);
|
||||||
|
if (sameFailures.length >= 2) {
|
||||||
|
const errorMsg = lastError.result?.error || '未知错误';
|
||||||
|
ctx.messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: `[失败恢复] "${lastError.name}" 已连续失败 ${sameFailures.length} 次(${errorMsg})。建议:1) 检查参数是否正确 2) 换一种方法 3) 如果无法解决,说明原因并给出替代方案。不要重复调用相同参数。`,
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
logInfo(`失败恢复指引: ${lastError.name} 连续失败 ${sameFailures.length} 次`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 记录本轮迭代度量
|
// 记录本轮迭代度量
|
||||||
recordIteration(ctx);
|
recordIteration(ctx);
|
||||||
|
|
||||||
@@ -1655,7 +1791,17 @@ async function handleObserving(
|
|||||||
const allSameTool = recentSuccess.length >= 2
|
const allSameTool = recentSuccess.length >= 2
|
||||||
&& recentSuccess.every(r => r.name === recentSuccess[0].name)
|
&& recentSuccess.every(r => r.name === recentSuccess[0].name)
|
||||||
&& getToolCacheKey(recentSuccess[0].name, recentSuccess[0].arguments) === getToolCacheKey(recentSuccess[1].name, recentSuccess[1].arguments);
|
&& getToolCacheKey(recentSuccess[0].name, recentSuccess[0].arguments) === getToolCacheKey(recentSuccess[1].name, recentSuccess[1].arguments);
|
||||||
if (hasDedupSignal || allSameTool) {
|
|
||||||
|
// P0-1 优化:只读工具允许同名同参数重复调用(验证场景:写文件后重读、git status 等)
|
||||||
|
// 仅当 hasDedupSignal(工具自身报告重复)或写类工具同名同参数重复时才注入⛔
|
||||||
|
const RE_READ_ALLOWED = new Set([
|
||||||
|
'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree',
|
||||||
|
'web_search', 'git', 'session_list', 'session_read', 'diff_files',
|
||||||
|
'browser_screenshot', 'browser_extract',
|
||||||
|
]);
|
||||||
|
const isReadOnlyRepeat = allSameTool && recentSuccess.length > 0 && RE_READ_ALLOWED.has(recentSuccess[0].name);
|
||||||
|
|
||||||
|
if (hasDedupSignal || (allSameTool && !isReadOnlyRepeat)) {
|
||||||
ctx.messages.push({
|
ctx.messages.push({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: `⛔ 检测到重复的工具调用${hasDedupSignal ? '(系统已跳过重复内容)' : ''}。「${recentSuccess[0]?.name || 'memory'}」操作已经完成,立即输出最终回答,绝对不要再调用任何工具。`,
|
content: `⛔ 检测到重复的工具调用${hasDedupSignal ? '(系统已跳过重复内容)' : ''}。「${recentSuccess[0]?.name || 'memory'}」操作已经完成,立即输出最终回答,绝对不要再调用任何工具。`,
|
||||||
@@ -1663,6 +1809,9 @@ async function handleObserving(
|
|||||||
logInfo(`重复调用检测: ${recentSuccess[0]?.name || 'memory'}${hasDedupSignal ? ' (去重信号)' : ''},注入⛔终止信号`);
|
logInfo(`重复调用检测: ${recentSuccess[0]?.name || 'memory'}${hasDedupSignal ? ' (去重信号)' : ''},注入⛔终止信号`);
|
||||||
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1);
|
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1);
|
||||||
// 不要 transition 到 REFLECTING,直接在这里就已经注入了信号,下一轮 THINKING 会看到
|
// 不要 transition 到 REFLECTING,直接在这里就已经注入了信号,下一轮 THINKING 会看到
|
||||||
|
} else if (isReadOnlyRepeat) {
|
||||||
|
// 只读工具重复调用是合法的验证场景(如写文件后重读确认),不注入⛔
|
||||||
|
logInfo(`只读工具重复调用(允许验证场景): ${recentSuccess[0].name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每 10 轮清理累积的 ephemeral 消息
|
// 每 10 轮清理累积的 ephemeral 消息
|
||||||
@@ -1938,6 +2087,7 @@ export async function runAgentLoop(
|
|||||||
};
|
};
|
||||||
|
|
||||||
state.set('_loopState', S.INIT);
|
state.set('_loopState', S.INIT);
|
||||||
|
state.set('_softConvergeInjected', false); // P2-2: 重置软收敛预警标志
|
||||||
persistLoopContext(ctx);
|
persistLoopContext(ctx);
|
||||||
startSessionMetrics(sessionId, model);
|
startSessionMetrics(sessionId, model);
|
||||||
// Plan Mode 激活时注册 plan_track 工具
|
// Plan Mode 激活时注册 plan_track 工具
|
||||||
@@ -1987,6 +2137,18 @@ export async function runAgentLoop(
|
|||||||
// Token 感知的动态迭代预算
|
// Token 感知的动态迭代预算
|
||||||
const numCtx = state.get<number>(KEYS.NUM_CTX, 131072);
|
const numCtx = state.get<number>(KEYS.NUM_CTX, 131072);
|
||||||
const usageRatio = estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx;
|
const usageRatio = estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx;
|
||||||
|
|
||||||
|
// P2-2: 上下文健康度软收敛 — 60% 时提前预警,让 AI 主动收敛
|
||||||
|
if (usageRatio > 0.6 && usageRatio <= 0.8 && !state.get<boolean>('_softConvergeInjected', false)) {
|
||||||
|
ctx.messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: '[上下文提醒] 上下文窗口使用率已达60%,建议尽快总结已有信息并给出最终回答,避免上下文压缩导致信息丢失。',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
state.set('_softConvergeInjected', true);
|
||||||
|
logInfo(`软收敛预警: 上下文使用率 ${(usageRatio * 100).toFixed(0)}%`);
|
||||||
|
}
|
||||||
|
|
||||||
if (usageRatio > 0.8 && (ctx.maxLoops - ctx.loopCount) > 3) {
|
if (usageRatio > 0.8 && (ctx.maxLoops - ctx.loopCount) > 3) {
|
||||||
const newMax = ctx.loopCount + 3;
|
const newMax = ctx.loopCount + 3;
|
||||||
logWarn(`上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 限制剩余迭代为 ${newMax - ctx.loopCount} 轮(原 ${ctx.maxLoops - ctx.loopCount} 轮)`);
|
logWarn(`上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 限制剩余迭代为 ${newMax - ctx.loopCount} 轮(原 ${ctx.maxLoops - ctx.loopCount} 轮)`);
|
||||||
|
|||||||
@@ -93,18 +93,32 @@ export async function executeHooks(
|
|||||||
// 内置 Hook
|
// 内置 Hook
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/** 会话级已写入文件路径集合 */
|
/**
|
||||||
const _writtenFilesThisSession = new Set<string>();
|
* 会话级已写入文件路径 → 内容指纹映射
|
||||||
|
* P0-2 优化:从 Set<string> 改为 Map<string, string>,支持内容指纹去重
|
||||||
|
* 只有内容完全相同时才阻断重复写入,允许不同内容的重写(修复→重写场景)
|
||||||
|
*/
|
||||||
|
const _writtenFileFingerprints = new Map<string, string>();
|
||||||
|
|
||||||
|
/** 简单内容指纹(djb2 hash,轻量快速,用于去重判断) */
|
||||||
|
function contentFingerprint(content: string): string {
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash) + content.charCodeAt(i);
|
||||||
|
hash = hash & 0xffffffff; // 保持32位
|
||||||
|
}
|
||||||
|
return hash.toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
/** 暴露写入记录供 agent-engine 清理 */
|
/** 暴露写入记录供 agent-engine 清理 */
|
||||||
export function clearWrittenFiles(): void {
|
export function clearWrittenFiles(): void {
|
||||||
_writtenFilesThisSession.clear();
|
_writtenFileFingerprints.clear();
|
||||||
}
|
}
|
||||||
export function hasWrittenFile(path: string): boolean {
|
export function hasWrittenFile(path: string): boolean {
|
||||||
return _writtenFilesThisSession.has(path);
|
return _writtenFileFingerprints.has(path);
|
||||||
}
|
}
|
||||||
export function addWrittenFile(path: string): void {
|
export function addWrittenFile(path: string, content?: string): void {
|
||||||
_writtenFilesThisSession.add(path);
|
_writtenFileFingerprints.set(path, content ? contentFingerprint(content) : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,8 +163,10 @@ export const securityCheckHook: HarnessHook = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* FileWriteDedupHook — 文件写入去重(pre_tool 阻断级)
|
* FileWriteDedupHook — 文件写入去重(pre_tool 阻断级)
|
||||||
* 同一会话中同一路径写入成功后,后续写入被拦截,防止 AI 反复覆盖同一文件。
|
* P0-2 优化:从「同路径一刀切阻断」改为「内容指纹去重」
|
||||||
* 同时记录 write_file 成功路径。
|
* - 内容完全相同的重复写入 → 阻断(真正的浪费)
|
||||||
|
* - 内容不同的重写 → 允许(修复→重写、迭代开发场景)
|
||||||
|
* 同时记录 write_file 成功路径及内容指纹。
|
||||||
*/
|
*/
|
||||||
export const fileWriteDedupHook: HarnessHook = {
|
export const fileWriteDedupHook: HarnessHook = {
|
||||||
name: 'FileWriteDedup',
|
name: 'FileWriteDedup',
|
||||||
@@ -161,9 +177,16 @@ export const fileWriteDedupHook: HarnessHook = {
|
|||||||
if (data.toolName !== 'write_file') return { passed: true, message: '' };
|
if (data.toolName !== 'write_file') return { passed: true, message: '' };
|
||||||
const filePath = String(data.toolArgs?.path || '');
|
const filePath = String(data.toolArgs?.path || '');
|
||||||
if (!filePath) return { passed: true, message: '' };
|
if (!filePath) return { passed: true, message: '' };
|
||||||
if (_writtenFilesThisSession.has(filePath)) {
|
|
||||||
return { passed: false, message: `文件 "${filePath}" 已在本会话中写入,无需重复覆盖。` };
|
const content = String(data.toolArgs?.content || '');
|
||||||
|
const newFingerprint = contentFingerprint(content);
|
||||||
|
const existingFingerprint = _writtenFileFingerprints.get(filePath);
|
||||||
|
|
||||||
|
if (existingFingerprint !== undefined && existingFingerprint === newFingerprint) {
|
||||||
|
// 内容完全相同 → 真正的重复写入,阻断
|
||||||
|
return { passed: false, message: `文件 "${filePath}" 内容未变化,无需重复写入。` };
|
||||||
}
|
}
|
||||||
|
// 内容不同或首次写入 → 允许
|
||||||
return { passed: true, message: '' };
|
return { passed: true, message: '' };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user