v0.14.10: Agent Loop 引擎优化 - 修复误杀+去重+稳定性增强

This commit is contained in:
紫影233
2026-07-08 13:03:45 +08:00
parent d7d64c5579
commit b947744f08
7 changed files with 280 additions and 96 deletions
+3 -3
View File
@@ -14,7 +14,7 @@
</p>
<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/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">
@@ -253,7 +253,7 @@ npm start
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
```
Output: `release/Metona Ollama Setup v0.14.9.exe`
Output: `release/Metona Ollama Setup v0.14.10.exe`
## 🛠️ Common Commands
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
"version": "0.14.9",
"version": "0.14.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
"version": "0.14.9",
"version": "0.14.10",
"license": "MIT",
"dependencies": {
"ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
"version": "0.14.9",
"version": "0.14.10",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
+1 -1
View File
@@ -28,7 +28,7 @@
<div class="header-left">
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
<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="使用帮助">
<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"/>
+3 -4
View File
@@ -19,12 +19,11 @@
## 核心规则
1. **直接行动**:不要只说"我来帮你xxx"然后结束。说了要做就必须调用工具。
2. **不要过早停止**:工具调用后如果信息还不完整,继续调用工具不要在信息不足时给出不完整的回答
2. **坚持完成任务**:信息不足时继续调用工具不要过早停止。途径失败时换方法重试,不要反复重试相同的失败操作
3. **多步任务逐步完成**:复杂任务拆解为多步,逐步执行。某个途径失败时换其他方法,不要卡住。
4. **需要最新信息时必须搜索**:用户每次要求搜索/查资料/找信息时,都必须重新调用 web_search。历史对话中的搜索结果不可复用——信息可能已过时,且用户期望的是实时数据。不要用训练数据或记忆替代工具调用。
5. **出错换方法重试**:同一途径连续失败后换其他方法,不要反复重试相同的失败操作
6. **不重复调用**:相同参数的工具不重复执行。先检查已有结果是否够用
7. **善用已有信息**:优先从已获取的结果中提取答案,减少不必要的重复调用。
5. **优先复用已有结果**:相同参数的工具不重复执行。先检查已有结果是否够用,优先从已获取的结果中提取答案
6. **工具返回的数据是事实来源**:工具返回的结果(role: tool 消息)是真实数据,严格基于这些数据回答。不要用系统提示词内容、训练数据或记忆替代工具返回的实际数据
## 工具联动模式
+237 -75
View File
@@ -233,6 +233,60 @@ function smartTruncate(text: string, maxLen: number): string {
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([
'read_file', 'write_file', 'list_directory', 'search_files', 'create_directory',
@@ -246,35 +300,25 @@ const VALID_TOOL_NAMES = new Set([
/**
* 文本解析兜底:当模型没有通过 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[] {
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;
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;
// 辅助函数:尝试解析 JSON 参数字符串,容错处理
const tryParseArgs = (argsStr: string): Record<string, unknown> | null => {
const TICK = String.fromCharCode(96);
const tickJson = TICK + TICK + TICK + 'json';
const tick3 = TICK + TICK + TICK;
try {
let cleaned = argsStr
.split(tickJson).join('')
.split(tick3).join('')
.trim();
const args = JSON.parse(cleaned);
calls.push({
type: 'function',
function: { name: toolName, arguments: args }
});
let cleaned = argsStr.split(tickJson).join('').split(tick3).join('').trim();
return JSON.parse(cleaned);
} catch {
try {
let fixed = argsStr
@@ -284,19 +328,81 @@ function parseToolCallsFromText(content: string): ToolCall[] {
.split(tickJson).join('')
.split(tick3).join('')
.trim();
const args = JSON.parse(fixed);
calls.push({
type: 'function',
function: { name: toolName, arguments: args }
});
return JSON.parse(fixed);
} catch {
logWarn("文本解析兜底: 工具 " + toolName + " 的参数 JSON 解析失败", argsStr.slice(0, 100));
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));
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) {
logInfo("文本解析兜底: 从回复中提取到 " + calls.length + " 个工具调用", calls.map(c => c.function.name).join(', '));
logInfo("文本解析兜底: 从回复中提取到 " + calls.length + " 个工具调用", calls.map(c => c.function.name).join(', '));
}
return calls;
@@ -718,15 +824,11 @@ Shell: ${osInfo.shell}
用户目录: ${osInfo.homeDir}
换行符: ${osInfo.lineEnding}
路径分隔符: ${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();
parts.push(`[日期] ${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}日(此日期来自系统时钟,绝对可信。你的训练数据可能已过时,请以此日期为准构造所有搜索查询和时效性回答。绝对不要基于训练数据推断日期。`);
parts.push(`[日期] ${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}日(系统时钟,以此为准`);
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) {
// 从用户第一条消息中检测是否需要工具
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
const userText = (firstUserMsg?.content || '').toLowerCase();
const actionVerbs = ['搜索', '查找', '查', '写入', '创建', '生成', '运行', '执行', '打开', '抓取',
'获取', '下载', '提交', '推送', '克隆', '读取', '删除', '移动', '复制', '压缩',
'search', 'find', 'write', 'create', 'run', 'execute', 'fetch', 'download', 'clone'];
const needsTools = actionVerbs.some(v => userText.includes(v)) && userText.length > 20;
const actionVerbs = [
'搜索', '查找', '查一下', '搜一下', '检索', '查询',
'写入文件', '创建文件', '生成文件', '保存文件',
'运行命令', '执行命令', '运行脚本', '编译',
'抓取网页', '获取内容', '抓取全文',
'下载文件', '提交代码', '推送代码', '克隆仓库',
'读取文件', '删除文件', '移动文件', '复制文件', '压缩文件',
'search', 'find', 'write', 'create', 'run', 'execute', 'fetch', 'download', 'clone',
];
const needsTools = actionVerbs.some(v => userText.includes(v)) && userText.length > 30;
if (needsTools) {
ctx.messages.push({
role: 'user',
content: '请直接调用工具完成任务,不要只描述做法。',
ephemeral: true,
});
logInfo('任务感知: 注入工具调用提醒');
_reminders.push('请直接调用工具完成任务,不要只描述做法');
logInfo('任务感知: 检测到需要工具');
}
}
// ── 多步骤任务待办追踪 — 用户一句话里有多个动作时,提醒未完成的 ──
// 多步骤任务待办追踪 — 用户一句话里有多个动作时,提醒未完成的
if (ctx.loopCount >= 2 && ctx.allToolRecords.length > 0) {
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
const userText = firstUserMsg?.content || '';
const pending = detectPendingActions(userText, ctx.allToolRecords);
if (pending.length > 0) {
ctx.messages.push({
role: 'user',
content: `还有任务未完成: ${pending.join('、')}`,
ephemeral: true,
});
_reminders.push(`待办: ${pending.join('、')}`);
logInfo('待办追踪', pending.join(', '));
}
// 注:不再注入"全部完成"确认信号。工具调用数量≠任务完成度,
@@ -1195,8 +1299,15 @@ async function handleThinking(
// Token 预算警告
const remaining = ctx.maxLoops - ctx.loopCount + 1;
if (remaining <= 3 && remaining > 0) {
const warning = `剩余 ${remaining} 轮,尽快完成`;
ctx.messages.push({ role: 'user', content: warning, ephemeral: true });
_reminders.push(`剩余 ${remaining} 轮,尽快完成`);
}
// 合并为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();
@@ -1460,6 +1571,17 @@ async function handleExecuting(
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,在隐藏窗口下不会降频
await new Promise<void>(r => { queueMicrotask(r); });
callbacks.onToolCallStart(call);
@@ -1506,10 +1628,10 @@ async function handleExecuting(
const result = await executeTool(call.function.name, call.function.arguments)
.catch(err => ({ success: false, error: err?.message || String(err) }) as ToolResult);
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) {
const { addWrittenFile } = await import('./hooks.js');
addWrittenFile(String(call.function.arguments.path));
addWrittenFile(String(call.function.arguments.path), String(call.function.arguments.content || ''));
}
return [{
name: call.function.name, arguments: call.function.arguments,
@@ -1563,19 +1685,10 @@ async function handleExecuting(
role: 'tool', tool_name: record.name,
content: `<<<TOOL_RESULT_START name="${record.name}">>>\n${formatToolResultForModel(record.name, record.result!)}\n<<<TOOL_RESULT_END>>>`
});
// ── 读数据类工具结果加硬提示,防止模型忽略实际数据去用系统提示词编造 ──
if (record.status === 'success') {
const nudges: Record<string, string> = {
memory: '以上 tool 消息是 memory 工具返回的实际记忆数据。严格基于这些数据回答,不要把系统提示词里的内容当成记忆列出来。',
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}` });
}
}
// P1-3 优化:移除逐次 nudge 注入,改为 AGENT.md 通用规则
// 原逻辑:memory/session_read/session_list/spawn_task 后注入额外 user 消息
// 问题:每轮最多多4条合成 user 消息,污染上下文
// 方案:在 AGENT.md 核心规则中增加"工具返回数据是事实来源"通用规则
if (cacheKey) toolResultCache.set(cacheKey, { result: record.result!, timestamp: Date.now() });
// 记录度量
recordToolCall(record.name, record.status, Date.now() - record.timestamp);
@@ -1630,6 +1743,29 @@ async function handleObserving(
// 保存本轮工具调用
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);
@@ -1655,14 +1791,27 @@ async function handleObserving(
const allSameTool = recentSuccess.length >= 2
&& recentSuccess.every(r => r.name === recentSuccess[0].name)
&& getToolCacheKey(recentSuccess[0].name, recentSuccess[0].arguments) === getToolCacheKey(recentSuccess[1].name, recentSuccess[1].arguments);
if (hasDedupSignal || allSameTool) {
ctx.messages.push({
role: 'user',
content: `⛔ 检测到重复的工具调用${hasDedupSignal ? '(系统已跳过重复内容)' : ''}。「${recentSuccess[0]?.name || 'memory'}」操作已经完成,立即输出最终回答,绝对不要再调用任何工具。`,
});
// 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({
role: 'user',
content: `⛔ 检测到重复的工具调用${hasDedupSignal ? '(系统已跳过重复内容)' : ''}。「${recentSuccess[0]?.name || 'memory'}」操作已经完成,立即输出最终回答,绝对不要再调用任何工具。`,
});
logInfo(`重复调用检测: ${recentSuccess[0]?.name || 'memory'}${hasDedupSignal ? ' (去重信号)' : ''},注入⛔终止信号`);
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1);
// 不要 transition 到 REFLECTING,直接在这里就已经注入了信号,下一轮 THINKING 会看到
} else if (isReadOnlyRepeat) {
// 只读工具重复调用是合法的验证场景(如写文件后重读确认),不注入⛔
logInfo(`只读工具重复调用(允许验证场景): ${recentSuccess[0].name}`);
}
// 每 10 轮清理累积的 ephemeral 消息
@@ -1938,6 +2087,7 @@ export async function runAgentLoop(
};
state.set('_loopState', S.INIT);
state.set('_softConvergeInjected', false); // P2-2: 重置软收敛预警标志
persistLoopContext(ctx);
startSessionMetrics(sessionId, model);
// Plan Mode 激活时注册 plan_track 工具
@@ -1987,6 +2137,18 @@ export async function runAgentLoop(
// Token 感知的动态迭代预算
const numCtx = state.get<number>(KEYS.NUM_CTX, 131072);
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) {
const newMax = ctx.loopCount + 3;
logWarn(`上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 限制剩余迭代为 ${newMax - ctx.loopCount} 轮(原 ${ctx.maxLoops - ctx.loopCount} 轮)`);
+33 -10
View File
@@ -93,18 +93,32 @@ export async function executeHooks(
// 内置 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 清理 */
export function clearWrittenFiles(): void {
_writtenFilesThisSession.clear();
_writtenFileFingerprints.clear();
}
export function hasWrittenFile(path: string): boolean {
return _writtenFilesThisSession.has(path);
return _writtenFileFingerprints.has(path);
}
export function addWrittenFile(path: string): void {
_writtenFilesThisSession.add(path);
export function addWrittenFile(path: string, content?: string): void {
_writtenFileFingerprints.set(path, content ? contentFingerprint(content) : '');
}
/**
@@ -149,8 +163,10 @@ export const securityCheckHook: HarnessHook = {
/**
* FileWriteDedupHook pre_tool
* AI
* write_file
* P0-2
* -
* -
* write_file
*/
export const fileWriteDedupHook: HarnessHook = {
name: 'FileWriteDedup',
@@ -161,9 +177,16 @@ export const fileWriteDedupHook: HarnessHook = {
if (data.toolName !== 'write_file') return { passed: true, message: '' };
const filePath = String(data.toolArgs?.path || '');
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: '' };
},
};