feat: v0.10.0 — 全面增强网络搜索、Agent Loop、工具稳定性与性能
网络搜索增强: - web_search: 4引擎并行(Bing+百度+DuckDuckGo+Google), LRU缓存5min, URL可达性预检, 结构化JSON输出 - web_fetch: 自动重试(指数退避2次), 移动端UA切换, SPA页面自动升级浏览器渲染 Agent Loop增强: - 流式调用超时保护(默认180s可配置) - Final Answer多模式检测 + 内容长度验证 - 预算警告改用ephemeral标记(context优先丢弃) - Token感知动态迭代预算(>80%自动缩减到3轮) - 工具缓存TTL(搜索5min/网页10min/文件30min) - 自动子任务拆解(并行关键词检测+spawn子代理) - 增量记忆提取(每20轮触发) - 智能批次划分: 16个只读工具加入ALWAYS_PARALLEL白名单 - 旧工具结果超过10轮自动截断到500字符 工具增强: - read_multiple_files: 串行→并行Promise.all - diff_files: 三级回退(diff -u → git diff → builtin) - search_files: 新增use_regex正则搜索 - list_directory: 新增offset分页 - Git: stash参数修复, clone/push/pull超时保护 - browser: 提取ensureBrowserReady()消除重复, browser_open切换URL自动重建 文档更新: - 帮助面板新增Agent Loop v0.10.0增强章节 - README中英文同步更新(四引擎搜索/工具描述) - SOUL.md/AGENT.md更新(v0.10.0能力描述/SPA规则翻转)
This commit is contained in:
@@ -28,7 +28,7 @@
|
||||
<div class="header-left">
|
||||
<span class="logo">🦙</span>
|
||||
<span class="app-title">Metona Ollama</span>
|
||||
<span class="app-version">v0.9.1</span>
|
||||
<span class="app-version">v0.10.0</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"/>
|
||||
@@ -394,8 +394,9 @@
|
||||
<div class="modal-body">
|
||||
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools)</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
|
||||
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong> — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置</li><li><strong>多模态</strong> — 上传图片,模型需支持 Vision 能力。图片自动压缩至合适分辨率,节省上下文</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
|
||||
<div class="help-section"><h4>🔧 Tool Calling(AI 自主操作)</h4><ul><li>设置中开启后,AI 可以在对话中<strong>自主调用本地工具</strong>,像一个本地 Agent</li><li><strong>38 个工具</strong>,分为 8 类:<ul><li><strong>文件系统</strong>(16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / append_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files</li><li><strong>命令执行</strong>(1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式)</li><li><strong>联网搜索</strong>(2 个):web_search(联网搜索,Bing + 百度 + Google 三引擎)/ web_fetch(网页抓取)</li><li><strong>Git</strong>(1 个):git(status / log / diff / add / commit / push / pull / branch / checkout / stash / reset / merge / clone / init / remote / tag,共 16 个子操作)</li><li><strong>浏览器控制</strong>(8 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_close</li><li><strong>压缩</strong>(1 个):compress(创建/解压 zip/tar.gz)</li><li><strong>记忆 & 会话 & Skill</strong>(8 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read / skill_list / skill_view</li><li><strong>子代理</strong>(1 个):spawn_task</li></ul></li><li>仅 <code>run_command</code>(命令执行)支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code>、<code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code>、<code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,可在设置中调整,也可随时中断</li><li>无超时限制,宁可等待也不中断,由用户控制生命周期</li><li>独立工具自动并行执行,有依赖关系的工具串行执行</li><li>⚠️ 需要模型支持 Tool Calling(推荐 Qwen3、Llama 3.1+、Mistral)</li></ul></div>
|
||||
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li>对话满 <strong>6 条消息</strong>后自动触发记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>AI 可通过 <code>memory_search</code> / <code>memory_add</code> / <code>memory_replace</code> / <code>memory_remove</code> 工具主动管理记忆</li><li>AI 可通过 <code>session_list</code> / <code>session_read</code> 查阅历史会话</li><li>记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>记忆存储在本地 SQLite,不会上传到任何服务器</li></ul></div>
|
||||
<div class="help-section"><h4>🔧 Tool Calling(AI 自主操作)</h4><ul><li>设置中开启后,AI 可以在对话中<strong>自主调用本地工具</strong>,像一个本地 Agent</li><li><strong>38 个工具</strong>,分为 8 类:<ul><li><strong>文件系统</strong>(17 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / append_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress</li><li><strong>命令执行</strong>(1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>(2 个):web_search(4引擎并行:Bing+百度+DuckDuckGo+Google,结果缓存5分钟,URL可达性预检)/ web_fetch(自动重试+移动端UA切换+SPA页面自动升级到浏览器渲染)</li><li><strong>Git</strong>(1 个):git(16 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>(8 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_close</li><li><strong>记忆 & 会话 & Skill</strong>(8 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read / skill_list / skill_view</li><li><strong>子代理</strong>(1 个):spawn_task</li></ul></li><li>read_multiple_files 支持<strong>并行读取</strong>,diff_files 优先调用系统 diff/git diff 命令</li><li>search_files 支持<strong>正则表达式搜索</strong>(use_regex=true)</li><li>list_directory 支持 <strong>offset 分页</strong>,大目录可分页读取</li><li>仅 <code>run_command</code>(命令执行)支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code>、<code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code>、<code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>(16个只读工具加入并行白名单),有依赖关系的工具串行执行</li><li>⚠️ 需要模型支持 Tool Calling(推荐 Qwen3、Llama 3.1+、Mistral)</li></ul></div>
|
||||
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li><strong>增量提取</strong>:长对话中每 20 轮自动触发轻量级记忆提取,不等到对话结束</li><li>对话结束时自动触发完整记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>AI 可通过 memory 工具主动管理记忆</li><li>记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>记忆存储在本地 SQLite,不会上传到任何服务器</li></ul></div>
|
||||
<div class="help-section"><h4>🤖 Agent Loop v0.10.0 增强</h4><ul><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认180s),Ollama 卡死不再永久阻塞</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div>
|
||||
<div class="help-section"><h4>🔌 MCP(Model Context Protocol)</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}_{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div>
|
||||
<div class="help-section"><h4>📋 自定义文件(SOUL.md / AGENT.md / USER.md)</h4><ul><li>在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效</li><li><strong>SOUL.md</strong> — AI 身份、性格、行为准则(<strong>永远不可被压缩</strong>,注入为最高优先级系统提示词)</li><li><strong>AGENT.md</strong> — 工具调用规则、链式调用模式、核心约束(内置精简默认版,可通过工作空间覆盖)</li><li><strong>USER.md</strong> — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考</li><li>可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文</li><li>删除工作空间中的文件即可恢复为内置默认版本</li></ul></div>
|
||||
<div class="help-section"><h4>🎯 技能自动生成</h4><ul><li>AI 完成包含 <strong>2+ 工具调用</strong>的复杂任务后,自动从执行轨迹中提取可复用技能</li><li><strong>语义匹配</strong>:配置嵌入模型后,根据用户消息的语义自动匹配最相关技能</li><li><strong>参数自优化</strong>:同一技能链多次执行后,自动合并高频参数提示,越用越精准</li><li><strong>技能衰减</strong>:30 天未使用的技能自动降权,90 天几乎忽略</li><li><strong>链合并</strong>:相同前缀(前 2 步相同)的多个技能自动合并为抽象技能</li><li>AI 可通过 <code>skill_list</code>(渐进式摘要)和 <code>skill_view</code>(完整工具链)查看技能</li><li>技能存储在本地 SQLite,不会上传到任何服务器</li></ul></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENT.md — Agent 行为准则
|
||||
|
||||
你是具备工具调用能力的 AI 助手。你有文件操作、命令执行、联网搜索、浏览器控制、Git、记忆管理等工具可用。
|
||||
你是具备工具调用能力的 AI 助手。你有文件操作、命令执行、四引擎联网搜索(Bing+百度+DuckDuckGo+Google 并行)、网页抓取(支持自动重试+移动端UA+SPA浏览器渲染回退)、浏览器控制、Git、记忆管理等工具可用。
|
||||
|
||||
## 核心规则
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
|
||||
- **严禁猜 URL**:所有 URL 必须来自搜索结果。
|
||||
- **上传文件已在对话中**:用户上传的文件内容已在消息中,无需再用 read_file 读取。
|
||||
- **不要抓取 SPA 页面**:GitHub releases、npm 等直接搜结果即可。
|
||||
- **SPA 页面自动处理**:web_fetch 遇到内容过短的 SPA 页面会自动升级到浏览器渲染,无需手动用 browser_open。GitHub releases、npm 等页面可直接通过 web_fetch 获取。
|
||||
- 永远不要只输出"我将执行xxx"然后结束。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
- **名称**: Metona
|
||||
- **角色**: Metona Ollama 桌面 AI 助手
|
||||
- **核心能力**: ReAct Agent Loop + 38 个内置工具 + 记忆系统 + 技能自动生成
|
||||
- **核心能力**: ReAct Agent Loop v0.10.0 + 38 个增强工具 + 记忆系统 + 技能自动生成 + 智能上下文压缩
|
||||
|
||||
## 性格与语气
|
||||
|
||||
|
||||
@@ -49,6 +49,14 @@ const TOOL_MAX_RESULT_SIZE: Record<string, number> = {
|
||||
/** v4.1: 工具并行执行 — 依赖检测用 */
|
||||
const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch', 'edit_file', 'append_file', 'move_file', 'delete_file']);
|
||||
|
||||
/** 始终可并行的只读/独立工具(无数据依赖,永远可以同批执行) */
|
||||
const ALWAYS_PARALLEL = new Set([
|
||||
'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree',
|
||||
'web_search', 'browser_screenshot', 'browser_extract', 'browser_evaluate',
|
||||
'memory_search', 'session_list', 'session_read', 'skill_list', 'skill_view',
|
||||
'diff_files', 'git',
|
||||
]);
|
||||
|
||||
/** 工具名白名单:用于文本解析兜底时过滤非法工具名 */
|
||||
const VALID_TOOL_NAMES = new Set([
|
||||
'read_file', 'write_file', 'list_directory', 'search_files', 'create_directory',
|
||||
@@ -125,7 +133,27 @@ function parseToolCallsFromText(content: string): ToolCall[] {
|
||||
return calls;
|
||||
}
|
||||
|
||||
const toolResultCache = new Map<string, ToolResult>();
|
||||
const toolResultCache = new Map<string, { result: ToolResult; timestamp: number }>();
|
||||
|
||||
/** 工具缓存 TTL(毫秒),按工具类型设定 */
|
||||
const CACHE_TTL_MAP: Record<string, number> = {
|
||||
web_search: 5 * 60_000, // 搜索: 5分钟
|
||||
web_fetch: 10 * 60_000, // 网页: 10分钟
|
||||
read_file: 30 * 60_000, // 文件: 30分钟
|
||||
list_directory: 60_000, // 目录: 1分钟
|
||||
search_files: 60_000, // 文件搜索: 1分钟
|
||||
browser_screenshot: 60_000, // 截图: 1分钟
|
||||
browser_extract: 5 * 60_000,// 浏览器内容: 5分钟
|
||||
// 其他工具默认无限期
|
||||
default: Infinity,
|
||||
};
|
||||
|
||||
/** 检查缓存是否过期 */
|
||||
function isCacheValid(toolName: string, timestamp: number): boolean {
|
||||
const ttl = CACHE_TTL_MAP[toolName] ?? CACHE_TTL_MAP.default;
|
||||
if (!isFinite(ttl)) return true;
|
||||
return Date.now() - timestamp < ttl;
|
||||
}
|
||||
|
||||
/** 生成工具调用缓存 key */
|
||||
function getToolCacheKey(name: string, args: Record<string, unknown>): string {
|
||||
@@ -482,6 +510,47 @@ export async function runAgentLoop(
|
||||
};
|
||||
messages.push(userMsg);
|
||||
|
||||
// P2-1: 自动子任务拆解 — 检测用户消息中的并行任务关键词
|
||||
const PARALLEL_PATTERNS = /同时|分别|以及|另外|此外|并且|也|also|and\s+also|separately|in\s+addition|meanwhile/i;
|
||||
const SUBTASK_SEPARATORS = /(?:^|\n)\s*(?:[1-9][.、)]|[-*•]\s+)/;
|
||||
const userText = userContent || '';
|
||||
const hasParallel = PARALLEL_PATTERNS.test(userText) && userText.length > 100;
|
||||
|
||||
if (hasParallel && useTools) {
|
||||
// 尝试按数字列表拆分子任务
|
||||
const parts = userText.split(SUBTASK_SEPARATORS).filter(p => p.trim().length > 20);
|
||||
if (parts.length >= 3) {
|
||||
logInfo(`自动子任务拆解: 检测到 ${parts.length} 个并行子任务`);
|
||||
const subtaskResults: string[] = [];
|
||||
const { executeSubAgent } = await import('./sub-agent.js');
|
||||
const subTasks = parts.slice(0, Math.min(parts.length, 3)); // 最多3个
|
||||
// 并行 spawn
|
||||
const subResults = await Promise.allSettled(
|
||||
subTasks.map((subTask, idx) =>
|
||||
executeSubAgent(subTask.trim(), `这是父任务的第 ${idx + 1}/${subTasks.length} 个子任务`, {
|
||||
maxLoops: 8,
|
||||
timeout: 120_000,
|
||||
})
|
||||
)
|
||||
);
|
||||
for (let i = 0; i < subResults.length; i++) {
|
||||
const r = subResults[i];
|
||||
if (r.status === 'fulfilled' && r.value.success) {
|
||||
const content = (r.value as any).content || JSON.stringify(r.value);
|
||||
subtaskResults.push(`[子任务 ${i + 1}] ${subTasks[i].trim().slice(0, 80)}...\n结果: ${content.slice(0, 1000)}`);
|
||||
}
|
||||
}
|
||||
if (subtaskResults.length > 0) {
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: `以下是通过并行子代理预先完成的子任务结果,你可以直接引用这些结果来加速回答:\n\n${subtaskResults.join('\n\n')}`,
|
||||
ephemeral: true,
|
||||
});
|
||||
logInfo(`子任务拆解完成: ${subtaskResults.length}/${subTasks.length} 个成功`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上下文窗口管理:滑动窗口 + 摘要压缩 + token 裁剪
|
||||
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
|
||||
const contextResult = buildContext(messages, { maxTokens: numCtx, windowSize: 20 });
|
||||
@@ -507,8 +576,8 @@ export async function runAgentLoop(
|
||||
|
||||
logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))}`);
|
||||
|
||||
// 迭代预算:从 state 读取,默认 85
|
||||
const maxLoops = state.get<number>('maxTurns', 85);
|
||||
// 迭代预算:从 state 读取,默认 85(后续可能被 Token 感知策略动态缩减)
|
||||
let maxLoops = state.get<number>('maxTurns', 85);
|
||||
|
||||
let loopCount = 0;
|
||||
const allToolRecords: ToolCallRecord[] = [];
|
||||
@@ -545,6 +614,14 @@ export async function runAgentLoop(
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
// P1-4: Token 感知的动态迭代预算 — context window 使用率 > 80% 时强制缩减剩余轮次
|
||||
const usageRatio = estimateTokens(messages.map(m => m.content || '').join('')) / numCtx;
|
||||
if (usageRatio > 0.8 && (maxLoops - loopCount) > 3) {
|
||||
const newMax = loopCount + 3;
|
||||
logWarn(`上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 限制剩余迭代为 ${newMax - loopCount} 轮(原 ${maxLoops - loopCount} 轮)`);
|
||||
maxLoops = newMax;
|
||||
}
|
||||
|
||||
// 非首轮迭代:通知 UI 创建新的消息气泡(防止每轮内容互相覆盖)
|
||||
if (loopCount > 1 && callbacks.onNewIteration) {
|
||||
callbacks.onNewIteration(prevToolCalls.length > 0 ? prevToolCalls : undefined);
|
||||
@@ -556,22 +633,30 @@ export async function runAgentLoop(
|
||||
content = '';
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
// v5.1.2 预算警告:接近迭代上限时注入临时提示
|
||||
let budgetWarningIdx = -1;
|
||||
// v5.1.2 预算警告:接近迭代上限时注入临时提示(ephemeral 标记,上下文裁剪时优先丢弃)
|
||||
const remaining = maxLoops - loopCount + 1;
|
||||
if (remaining <= 5 && remaining > 0) {
|
||||
const warning = remaining <= 2
|
||||
? `\n⚠️ CRITICAL: You have only ${remaining} iteration(s) left. Stop using tools and provide your final answer NOW.`
|
||||
: `\n⚠️ WARNING: You have approximately ${remaining} iterations remaining. Start wrapping up and prepare your final answer.`;
|
||||
messages.push({ role: 'system', content: warning });
|
||||
budgetWarningIdx = messages.length - 1;
|
||||
messages.push({ role: 'system', content: warning, ephemeral: true });
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
state.set(KEYS.ABORT_CONTROLLER, abortController);
|
||||
|
||||
// 流式调用超时保护(可配置,0 = 禁用超时)
|
||||
const STREAM_TIMEOUT_MS = state.get<number>('streamTimeout', 180_000); // 默认 180s
|
||||
let streamTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
if (STREAM_TIMEOUT_MS > 0) {
|
||||
streamTimer = setTimeout(() => {
|
||||
logWarn(`流式调用超时 (${STREAM_TIMEOUT_MS / 1000}s),中止本轮`);
|
||||
abortController.abort();
|
||||
}, STREAM_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
try {
|
||||
// 流式调用(无超时限制)
|
||||
// 流式调用
|
||||
await api.chatStream(
|
||||
{
|
||||
model,
|
||||
@@ -642,7 +727,12 @@ export async function runAgentLoop(
|
||||
}
|
||||
} catch { /* 校准失败不阻塞主流程 */ }
|
||||
|
||||
// 流式调用成功完成,清除超时定时器
|
||||
if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; }
|
||||
|
||||
} catch (err) {
|
||||
// 清除超时定时器
|
||||
if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; }
|
||||
if (abortController.signal.aborted) {
|
||||
logInfo('流式调用已中止');
|
||||
// 保存工具记录到 state,供消费方的 catch 处理中止消息
|
||||
@@ -652,10 +742,6 @@ export async function runAgentLoop(
|
||||
throw err; // 抛出 AbortError,由消费方统一处理
|
||||
}
|
||||
logError('流式调用异常', (err as Error).message);
|
||||
// 清理预算警告临时消息
|
||||
if (budgetWarningIdx >= 0) {
|
||||
messages.splice(budgetWarningIdx, 1);
|
||||
}
|
||||
if (content || thinking) {
|
||||
messages.push({ role: 'assistant', content: content || '(模型响应异常)', ...(thinking && { thinking }) });
|
||||
}
|
||||
@@ -663,11 +749,6 @@ export async function runAgentLoop(
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理预算警告临时消息
|
||||
if (budgetWarningIdx >= 0) {
|
||||
messages.splice(budgetWarningIdx, 1);
|
||||
}
|
||||
|
||||
// 提取 ReAct 思考过程
|
||||
const thoughtMatch = content.match(/\*\*Thought:\*\*\s*([\s\S]*?)(?=\*\*Action:\*\*|\*\*Final Answer:\*\*|$)/i);
|
||||
const thought = thoughtMatch ? thoughtMatch[1].trim() : '';
|
||||
@@ -693,8 +774,17 @@ export async function runAgentLoop(
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是 Final Answer(没有工具调用且包含 Final Answer)
|
||||
const isFinalAnswer = content.includes('Final Answer:') || content.includes('最终答案:');
|
||||
// 检查是否是 Final Answer(多模式匹配 + 内容长度验证)
|
||||
// 只有当无工具调用且有实际内容时才可能是最终回答
|
||||
const FINAL_PATTERNS: RegExp[] = [
|
||||
/Final\s*Answer\s*:/i,
|
||||
/最终答案[::]/,
|
||||
/最终回答[::]/,
|
||||
/总结[::]/,
|
||||
];
|
||||
const isFinalAnswer = toolCalls.length === 0
|
||||
&& content.length > 50
|
||||
&& FINAL_PATTERNS.some(p => p.test(content));
|
||||
|
||||
// 处理空响应:如果之前有工具调用但模型返回空内容,不立即结束
|
||||
if (toolCalls.length === 0 && !content.trim() && loopCount > 1 && allToolRecords.length > 0) {
|
||||
@@ -774,13 +864,15 @@ export async function runAgentLoop(
|
||||
for (const call of toolCalls) {
|
||||
if (currentBatch.length === 0) {
|
||||
currentBatch.push(call);
|
||||
} else if (ALWAYS_PARALLEL.has(call.function.name)) {
|
||||
// 只读/独立工具 → 永远可以并行
|
||||
currentBatch.push(call);
|
||||
} else {
|
||||
// 检查当前工具是否依赖当前批次中任何工具的结果
|
||||
const needsPrevResult = currentBatch.some(prev =>
|
||||
TOOLS_WITH_DATA_DEPS.has(call.function.name) && prev.function.name !== call.function.name
|
||||
);
|
||||
if (needsPrevResult) {
|
||||
// 有依赖,当前批次结束,开启新批次
|
||||
batches.push(currentBatch);
|
||||
batchDeps.set(call, currentBatch[currentBatch.length - 1].function.name);
|
||||
currentBatch = [call];
|
||||
@@ -806,20 +898,24 @@ export async function runAgentLoop(
|
||||
if (cached) {
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: cached, status: 'success' as const, timestamp: Date.now()
|
||||
result: cached.result, status: 'success' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
}
|
||||
|
||||
// 跨轮次缓存命中
|
||||
const cachedResult = toolResultCache.get(cacheKey);
|
||||
if (cachedResult) {
|
||||
// 跨轮次缓存命中(含 TTL 过期检查)
|
||||
const cachedEntry = toolResultCache.get(cacheKey);
|
||||
if (cachedEntry && isCacheValid(call.function.name, cachedEntry.timestamp)) {
|
||||
logInfo(`工具缓存命中: ${call.function.name}`);
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: cachedResult, status: 'success' as const, timestamp: Date.now()
|
||||
result: cachedEntry.result, status: 'success' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
if (cachedEntry) {
|
||||
// 过期缓存,删除
|
||||
toolResultCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
callbacks.onToolCallStart(call);
|
||||
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
|
||||
@@ -885,7 +981,7 @@ export async function runAgentLoop(
|
||||
role: 'tool', tool_name: record.name,
|
||||
content: formatToolResultForModel(record.name, record.result!)
|
||||
});
|
||||
if (cacheKey) toolResultCache.set(cacheKey, record.result!);
|
||||
if (cacheKey) toolResultCache.set(cacheKey, { result: record.result!, timestamp: Date.now() });
|
||||
if (record.status === 'success') {
|
||||
callbacks.onToolCallResult(record.name, record.result!, batch.find(c => c.function.name === record.name)!);
|
||||
} else if (record.status === 'cancelled') {
|
||||
@@ -907,8 +1003,32 @@ export async function runAgentLoop(
|
||||
).join('; ');
|
||||
saveTrace(traceStep);
|
||||
|
||||
// P2-2: 增量记忆提取 — 每 20 轮自动触发轻量级记忆提取
|
||||
if (isMemoryEnabled() && loopCount > 1 && loopCount % 20 === 0 && messages.length >= 10) {
|
||||
try {
|
||||
const { extractMemoriesFromConversation } = await import('./memory-manager.js');
|
||||
const recentMsgs = messages.slice(-30).filter(m => m.role === 'user' || m.role === 'assistant');
|
||||
await extractMemoriesFromConversation(
|
||||
recentMsgs.map(m => ({ role: m.role, content: m.content })),
|
||||
currentSession?.title
|
||||
);
|
||||
logInfo('增量记忆提取完成', `第 ${loopCount} 轮`);
|
||||
} catch { /* 不阻塞 */ }
|
||||
}
|
||||
|
||||
// 保存本轮工具调用,供下一轮 onNewIteration 使用
|
||||
prevToolCalls = toolCalls;
|
||||
|
||||
// P1-3: 增量工具结果截断 — 超过 10 轮的旧工具结果自动截断到 500 字符
|
||||
if (loopCount > 10 && loopCount % 5 === 0) {
|
||||
const truncateBefore = messages.length - 15;
|
||||
for (let i = 0; i < messages.length && i < truncateBefore; i++) {
|
||||
const m = messages[i];
|
||||
if (m.role === 'tool' && m.content && m.content.length > 500) {
|
||||
messages[i] = { ...m, content: m.content.slice(0, 500) + '\n... (已截断旧工具结果)' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logWarn('ReAct Agent Loop 达到最大工具调用次数限制');
|
||||
|
||||
@@ -74,6 +74,9 @@ export function scoreMessageImportance(msg: OllamaMessage): number {
|
||||
if (msg.role === 'user') score += 2; // 用户消息最重要
|
||||
if (msg.role === 'system') score -= 2; // 系统消息通常可压缩
|
||||
|
||||
// ephemeral 临时消息 → 最低权重,优先丢弃
|
||||
if (msg.ephemeral) return 0;
|
||||
|
||||
// 已压缩标记 → 低权重(避免压缩产物堆积)
|
||||
if (msg.compressed) score = 1;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* SubAgent - 子代理委派系统 (v0.9.1 增强版)
|
||||
* SubAgent - 子代理委派系统 (v0.10.0 增强版)
|
||||
* 子代理拥有受限工具集(只读),可独立完成调研/搜索/分析类任务
|
||||
* 主 Agent 通过 spawn_task 工具并行委派多个子代理
|
||||
*/
|
||||
|
||||
@@ -45,7 +45,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_directory',
|
||||
description: 'List directory contents. Returns file names, types, sizes, and modification times.',
|
||||
description: 'List directory contents. Returns file names, types, sizes, and modification times. Supports offset-based pagination for directories with many entries.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['path'],
|
||||
@@ -53,7 +53,8 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
path: { type: 'string', description: 'Directory path.' },
|
||||
recursive: { type: 'boolean', description: 'List recursively. Default: false.' },
|
||||
max_depth: { type: 'integer', description: 'Max recursion depth. Default: 3.' },
|
||||
include_hidden: { type: 'boolean', description: 'Include hidden files. Default: false.' }
|
||||
include_hidden: { type: 'boolean', description: 'Include hidden files. Default: false.' },
|
||||
offset: { type: 'integer', description: 'Skip first N entries for pagination. Default: 0.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,17 +63,18 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_files',
|
||||
description: 'Search files by name pattern or text content within files.',
|
||||
description: 'Search files by name pattern or text content within files. Supports regular expressions with use_regex=true.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['path', 'query'],
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Root directory to search.' },
|
||||
query: { type: 'string', description: 'Search query (glob or text).' },
|
||||
query: { type: 'string', description: 'Search query (glob, text, or regex).' },
|
||||
search_type: { type: 'string', enum: ['filename', 'content', 'both'], description: 'Search target. Default: both.' },
|
||||
case_sensitive: { type: 'boolean', description: 'Case sensitive. Default: false.' },
|
||||
max_results: { type: 'integer', description: 'Max results. Default: 50.' },
|
||||
file_extensions: { type: 'array', items: { type: 'string' }, description: 'Filter extensions, e.g. [".ts", ".js"]' }
|
||||
file_extensions: { type: 'array', items: { type: 'string' }, description: 'Filter extensions, e.g. [".ts", ".js"]' },
|
||||
use_regex: { type: 'boolean', description: 'Treat query as regular expression. Default: false.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,14 +158,16 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'web_fetch',
|
||||
description: 'Fetch content from a URL. Returns the page text content (HTML stripped). Supports HTTP/HTTPS.',
|
||||
description: 'Fetch content from a URL. Returns the page text content (HTML stripped). Supports HTTP/HTTPS. Automatically retries on failure (up to 2 times with exponential backoff). Will auto-upgrade to browser rendering for JavaScript-heavy pages that return empty shells.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['url'],
|
||||
properties: {
|
||||
url: { type: 'string', description: 'URL to fetch (http/https).' },
|
||||
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.' },
|
||||
mobile_ua: { type: 'boolean', description: 'Use mobile User-Agent. Some sites return simpler content for mobile. Default: false.' },
|
||||
retry: { type: 'boolean', description: 'Enable auto-retry on failure (5xx/network errors, up to 2 times). Default: true.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,13 +346,13 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'web_search',
|
||||
description: 'Search the web and return relevant results. Use this when you need to find current information, news, facts, or answer questions that require up-to-date knowledge beyond your training data.',
|
||||
description: 'Search the web using multiple engines in parallel (Bing + Baidu + DuckDuckGo + Google) and return merged, deduplicated results. Results are cached for 5 minutes. Includes reachability checks for top results. Returns both formatted text and structured JSON. Use this when you need current information, news, facts, or answers requiring up-to-date knowledge beyond your training data.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['query'],
|
||||
properties: {
|
||||
query: { type: 'string', description: 'The search query. Be specific and concise for best results.' },
|
||||
max_results: { type: 'integer', description: 'Maximum number of results to return. Default: 15, max: 15.' }
|
||||
max_results: { type: 'integer', description: 'Maximum number of results to return. Default: 15, max: 20.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -10,6 +10,8 @@ export interface OllamaMessage {
|
||||
tool_name?: string;
|
||||
/** 标记此消息为 LLM 压缩摘要生成 */
|
||||
compressed?: boolean;
|
||||
/** 标记此消息为临时性消息(预算警告等),上下文裁剪时优先丢弃 */
|
||||
ephemeral?: boolean;
|
||||
}
|
||||
|
||||
export interface OllamaChatParams {
|
||||
|
||||
Reference in New Issue
Block a user