diff --git a/.gitignore b/.gitignore index 91f13e2..39c3109 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ build-resources/ Thumbs.db *.log window-state.json +nul # Build output (release for desktop) !release/*.exe diff --git a/README.md b/README.md index ba0a31a..156a8dd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -34,8 +34,10 @@ | | 功能 | 说明 | |:---:|:---|:---| -| 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。Thought → Action → Observation → Reflection 完整循环,最大 85 轮(可配置),自动重试 2 次,工具去重,并行/链式执行 | -| 🔧 | **42 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 | +| 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。8 状态机(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置),自动重试 2 次,工具去重,并行/链式执行,看门狗超时保护 | +| 🛡️ | **5 层抗幻觉系统** | 提示词加固 → 任务感知 → 中途检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入/文件/工具 4 类关键阻断) | +| 📋 | **Plan Mode** | 开关切换,AI 首先生成执行计划(Markdown 渲染确认弹窗),批准后按步骤追踪执行,plan_track 工具标记完成状态 | +| 🔧 | **42 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 · Plan Mode 追踪 | | 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),FTS5 全文搜索 + 向量语义搜索,写入前安全扫描,容量 500 条,90 天衰减 | | 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则)+ USER.md(用户画像),工作空间优先,内置默认 fallback | | 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 | @@ -44,6 +46,8 @@ | 🖥️ | **工作空间面板** | 终端(实时流式输出)+ 文件浏览器,命令安全检查 | | 🔢 | **上下文长度自动检测** | 切换模型时自动从 model_info 获取实际支持的上下文长度,无需手动配置 | | 🗜️ | **智能上下文管理** | 滑动窗口 + Token 自动校准 + 消息重要性评分 + LLM 结构化 JSON 压缩 | +| ⏱️ | **看门狗超时** | Agent Loop 全局超时保护(可配,默认 30min),防止模型卡死无限循环 | +| 🪝 | **Hook 系统** | 4 阶段生命周期钩子(pre_tool / post_tool / post_iteration / pre_completion),内置文件变更审计、工具结果校验 | | 👥 | **子代理委派** | spawn_task 工具,独立上下文 + 超时保护 | | 📈 | **Token 仪表盘** | 全局 + 会话统计,消耗趋势柱状图,2 秒刷新,输入/输出分色 | | 📋 | **系统提示词卡片** | 每条 AI 回复顶部折叠卡片,点击查看实际发送给模型的完整上下文 | @@ -161,20 +165,31 @@ +

+📋 Plan Mode(1 个,仅 Plan 模式激活) + +| 工具 | 功能 | +|------|------| +| `plan_track` | Plan 模式执行进度追踪,标记步骤完成,自动统计剩余 | + +
+ ## 🏗️ 架构 ``` 用户消息 → 扫描工作空间 SOUL.md(不可压缩层)→ AGENT.md → USER.md ↓ - Agent Engine (ReAct Loop, ≤85轮) - ↓ 记忆检索 (FTS5 + 向量语义搜索) → 上下文注入 ↓ + Agent Engine (8 状态机 ReAct Loop, ≤85 轮, 看门狗 30min) + ↓ + 5 层抗幻觉系统 (提示词 → 中途检测 → 进度锚 → 完成闸门 → 校验) + ↓ Ollama API (流式响应,num_ctx 自动检测) ↓ - Tool Registry (42 内置 + MCP 动态) + Tool Registry (42 内置 + MCP 动态 + Plan Mode plan_track) ↓ - 观察结果 → 反思 → 循环 / 最终回答 + Hook 系统 (pre/post tool/iteration/completion) → 观察结果 → 反思 → 循环 / 最终回答 ``` ### 🧩 UI 组件(原生 DOM) @@ -184,15 +199,15 @@ main.ts (入口) ├── header.ts # 顶部导航栏 ├── model-bar.ts # 模型选择栏 ├── chat-area.ts # 聊天消息区域 - ├── input-area.ts # 输入框 + 文件上传 - ├── workspace-panel.ts # 终端 + 文件浏览器 - ├── settings-modal.ts # 设置面板 + ├── input-area.ts # 输入框 + 文件上传 + Plan Mode 开关 + ├── workspace-panel.ts # 终端 + 文件浏览器 + 工具卡片 + ├── settings-modal.ts # 设置面板(含看门狗超时配置) ├── history-modal.ts # 会话历史 ├── memory-modal.ts # 记忆管理 ├── tools-modal.ts # 工具列表 ├── token-dashboard.ts # Token 消耗仪表盘 ├── tool-confirm-modal.ts # 工具执行确认 - ├── prompt-modal.ts # 系统提示词查看 + ├── prompt-modal.ts # 系统提示词查看 + Plan 确认弹窗 ├── toast.ts # Toast 通知 └── lightbox.ts # 图片灯箱 ``` @@ -214,13 +229,15 @@ SQLite (sql.js WASM),6 张表,WAL 模式 + FTS5 全文搜索: | 层级 | 措施 | |------|------| -| 📁 文件系统 | `checkPathAllowed()` — 路径黑名单 | -| ⚡ 命令执行 | `checkCommandAllowed()` — 命令黑名单 + 三种模式 | +| 📁 文件系统 | `checkPathAllowed()` — 路径黑名单(33 个系统/敏感目录,含 Linux + Windows) | +| ⚡ 命令执行 | `checkCommandAllowed()` — 命令黑名单(30 条危险命令,含 POSIX + Windows)+ 三种模式 | | 🖥️ 前端渲染 | HTML 净化器(白名单标签 + URI 协议检查) | -| 🔒 Electron | `contextIsolation: true` + IPC 白名单 | +| 🔒 Electron | `contextIsolation: true` + IPC 白名单 + IPC fs 路径验证 | | 🔐 数据加密 | AES-256-GCM | | 🧠 记忆安全 | 写入前 Prompt Injection / 敏感信息检测 | -| 🌐 MCP 安全 | Shadowing 防护 | +| 🌐 MCP 安全 | Shadowing 防护 + 双下划线分隔防歧义 | +| 🌐 网络安全 | `web_fetch` 流式体积限制(10MB)+ 无 content-length 时防 OOM | +| ⚡ 进程安全 | Windows `taskkill` 强制终止 + 工作空间路径大小写不敏感校验 | ## 🚀 快速开始 @@ -239,7 +256,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.11.11.exe` +产出:`release/Metona Ollama Setup v0.12.3.exe` ## 🛠️ 常用命令 @@ -269,8 +286,10 @@ npm run dist # 构建 Windows 安装包 | | Feature | Description | |:---:|:---|:---| -| 🤖 | **ReAct Agent Loop** | Always-on, only chat mode. Full Thought → Action → Observation → Reflection cycle, up to 85 iterations (configurable), auto-retry 2 times, tool dedup, parallel/chain execution | -| 🔧 | **42 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Sessions · Sub-agent · System | +| 🤖 | **ReAct Agent Loop** | Always-on, only chat mode. 8-state machine (INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED), up to 85 iterations (configurable), auto-retry 2 times, tool dedup, parallel/chain execution, watchdog timeout protection | +| 🛡️ | **5-Layer Anti-Hallucination** | Prompt hardening → task awareness → mid-task detection (16 rules covering all tool categories) → progress anchors → completion gate (6 checks: hallucination, injection, file, tool — 4 critical blocks) | +| 📋 | **Plan Mode** | Toggle switch. AI first generates an execution plan (Markdown-rendered confirmation dialog), then tracks step-by-step execution, with plan_track tool marking completion | +| 🔧 | **42 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Sessions · Sub-agent · System · Plan Mode tracking | | 🧠 | **Smart Memory System** | Three types (fact / preference / rule), FTS5 + vector semantic search, pre-write security scan, 500 capacity, 90-day decay | | 📋 | **Custom Files** | SOUL.md (persona, never compressed) + AGENT.md (behavior rules) + USER.md (user profile), workspace-first with built-in defaults | | 🌐 | **MCP Protocol Extension** | JSON-RPC 2.0 over stdio, dynamic tool discovery, Shadowing protection | @@ -279,6 +298,8 @@ npm run dist # 构建 Windows 安装包 | 🖥️ | **Workspace Panel** | Terminal (real-time streaming) + file browser, command security checks | | 🔢 | **Auto Context Length Detection** | Reads actual context_length from model metadata on model switch — no manual config needed | | 🗜️ | **Smart Context Manager** | Sliding window + Token auto-calibration + message importance scoring + LLM structured JSON compression | +| ⏱️ | **Watchdog Timeout** | Global Agent Loop timeout protection (configurable, default 30min), prevents infinite loops from model stalls | +| 🪝 | **Hook System** | 4-phase lifecycle hooks (pre_tool / post_tool / post_iteration / pre_completion), built-in file change audit and tool result verification | | 👥 | **Sub-agent Delegation** | spawn_task tool, isolated context + timeout protection | | 📈 | **Token Dashboard** | Global + session stats, consumption trend bar chart, 2s refresh, input/output color-coded | | 📋 | **System Prompt Card** | Collapsible card atop each AI reply — click to inspect the full context sent to the model | @@ -395,6 +416,15 @@ npm run dist # 构建 Windows 安装包 +
+📋 Plan Mode (1, only active in Plan Mode) + +| Tool | Function | +|------|------| +| `plan_track` | Plan Mode execution progress tracking, mark steps complete, auto tally remaining | + +
+ ## 🏗️ Architecture ``` @@ -402,11 +432,15 @@ User message → workspace SOUL.md (never compressed) → AGENT.md → USER.md ↓ Memory Retrieval (FTS5 + Vector Semantic Search) → Context Injection ↓ + Agent Engine (8-state ReAct Loop, ≤85 iter, watchdog 30min) + ↓ + 5-Layer Anti-Hallucination (prompt → mid-task detection → anchors → gate → verification) + ↓ Ollama API (Streaming Response) ↓ - Tool Registry (42 Built-in + MCP Dynamic) + Tool Registry (42 Built-in + MCP Dynamic + Plan Mode plan_track) ↓ - Observation → Reflection → Loop / Final Answer + Hook System (pre/post tool/iteration/completion) → Observation → Reflection → Loop / Final Answer ``` ### 🧩 UI Components (Native DOM) @@ -416,15 +450,15 @@ main.ts (Entry) ├── header.ts # Top navigation bar ├── model-bar.ts # Model selector ├── chat-area.ts # Chat message area - ├── input-area.ts # Input box + file upload - ├── workspace-panel.ts # Terminal + file browser - ├── settings-modal.ts # Settings panel + ├── input-area.ts # Input box + file upload + Plan Mode toggle + ├── workspace-panel.ts # Terminal + file browser + tool cards + ├── settings-modal.ts # Settings panel (with watchdog timeout config) ├── history-modal.ts # Session history ├── memory-modal.ts # Memory management ├── tools-modal.ts # Tool list ├── token-dashboard.ts # Token consumption dashboard ├── tool-confirm-modal.ts # Tool execution confirmation - ├── prompt-modal.ts # System prompt viewer + ├── prompt-modal.ts # System prompt viewer + Plan confirm dialog ├── toast.ts # Toast notifications └── lightbox.ts # Image lightbox ``` @@ -446,13 +480,15 @@ SQLite (sql.js WASM), 6 tables, WAL mode + FTS5 full-text search: | Layer | Measure | |------|------| -| 📁 File System | `checkPathAllowed()` — path blacklist | -| ⚡ Command Execution | `checkCommandAllowed()` — command blacklist + three modes | +| 📁 File System | `checkPathAllowed()` — path blacklist (33 system/sensitive dirs, Linux + Windows) | +| ⚡ Command Execution | `checkCommandAllowed()` — command blacklist (30 dangerous commands, POSIX + Windows) + three modes | | 🖥️ Frontend Rendering | HTML sanitizer (whitelist tags + URI protocol check) | -| 🔒 Electron | `contextIsolation: true` + IPC whitelist | +| 🔒 Electron | `contextIsolation: true` + IPC whitelist + IPC fs path validation | | 🔐 Data Encryption | AES-256-GCM | | 🧠 Memory Security | Pre-write prompt injection / sensitive info detection | -| 🌐 MCP Security | Shadowing protection | +| 🌐 MCP Security | Shadowing protection + double-underscore delimiter disambiguation | +| 🌐 Network Security | `web_fetch` streaming size limit (10MB) + OOM prevention without content-length | +| ⚡ Process Security | Windows `taskkill` forced termination + case-insensitive workspace path validation | ## 🚀 Quick Start @@ -471,7 +507,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.11.11.exe` +Output: `release/Metona Ollama Setup v0.12.3.exe` ## 🛠️ Common Commands diff --git a/docs/AI_Agent_ReAct_Harness_Engineering.md b/docs/AI_Agent_ReAct_Harness_Engineering.md new file mode 100644 index 0000000..fdd0431 --- /dev/null +++ b/docs/AI_Agent_ReAct_Harness_Engineering.md @@ -0,0 +1,439 @@ +# AI Agent 工程化完整文档:ReAct Loop 与 Harness Engineering + +> 本文围绕当下 AI Agent 工程化的两大核心命题展开:作为智能体"思考与执行内核"的 **ReAct Loop(推理-行动-观察循环)**,以及作为"运行环境与管控体系"的 **Harness Engineering(驾驭工程)**。两者共同构成生产级 AI Agent 的工程底座。 + +--- + +## 一、背景:从"调教模型"到"建造系统" + +2026 年,AI Agent 的叙事重心发生了根本性转移:从追求单个 Agent 的"智力上限",转向构建整个系统的"可靠性下限"。大模型早已不是 AI 落地的唯一瓶颈,行业已达成共识: + +``` +Agent = Model + Harness +``` + +模型是引擎,而 Harness(驾驭系统)才是决定智能体能否稳定跑完复杂长任务、从演示级走向生产级的关键。 + +AI 工程范式经历了三个阶段的演进: + +| 阶段 | 时间范围 | 核心关注点 | 解决的问题 | 典型技术 | +|---|---|---|---|---| +| Prompt Engineering | 2022-2024 | 如何让模型理解你的意图 | 单次输出的质量 | 提示词模板、Few-shot 示例 | +| Context Engineering | 2025 | 如何给模型正确的知识边界 | 给模型看什么信息 | RAG、上下文窗口管理 | +| Harness Engineering | 2026- | 如何让 Agent 可靠、持续、不失控 | 多步骤、长周期任务的可靠性 | 状态机、沙箱、权限系统 | + +这个演进路径反映了 AI 工程从"单点优化"到"系统构建"的转变:早期关注如何让模型"听懂人话",中期关注如何给模型"正确的参考资料",而现在关注如何让整个系统"持续稳定运行"。 + +--- + +## 二、ReAct Loop:Agent 的思考与执行内核 + +### 2.1 ReAct 的起源与定义 + +ReAct 源自普林斯顿大学与 Google Brain 于 2022 年联合发表的经典论文《ReAct: Synergizing Reasoning and Acting in Language Models》。在 ReAct 出现之前,大模型只有单纯的推理能力(Reasoning),存在严重的幻觉、知识滞后、无法实操的问题;而单纯的工具调用只有行动能力(Acting),缺乏逻辑推理,无法自主判断何时调用工具、调用什么工具。 + +ReAct 的核心颠覆式创新在于:**将大模型的推理思考(Thought)与外部工具行动(Action)进行闭环融合**。 + +``` +ReAct = Reasoning(推理思考) + Acting(工具行动) +``` + +它是一套迭代式循环执行范式:Agent 不再一次性输出答案,而是通过「思考→行动→观察→再思考」的无限循环,逐步拆解复杂任务、调用外部工具、修正推理偏差,直到任务完成。ReAct 是目前 90% 以上开源 Agent(LangChain、LlamaIndex、Meta Agent)的底层核心执行逻辑,也是工业界公认的 AI Agent 标准思考框架。 + +### 2.2 T-A-O 三步循环机制 + +所有基于 ReAct 的 Agent,底层都是统一的 **T-A-O 循环闭环**,这是必须掌握的核心底层逻辑。 + +``` +用户提问 → Thought 思考 → Action 行动 → Observation 观察 → 任务完成? → 否 → 再次 Thought → 是 → 输出最终答案 +``` + +**1. Thought(推理思考)** + +大模型基于当前用户问题、历史上下文、已有工具列表,进行自主推理判断: +- 当前任务是否需要调用工具? +- 需要调用哪一个工具? +- 工具入参应该如何构造? +- 当前任务是否已经完成,可以直接输出答案? + +这一步是 Agent 的智能核心,完全依靠大模型的理解与推理能力。 + +**2. Action(工具行动)** + +Agent 根据 Thought 的推理结果,通过 Harness 工具编排层,执行具体外部操作: +- 调用计算器、搜索引擎、数据库查询、接口请求、代码解释器等 +- 严格按照 Harness 约束规则执行,受超时、重试、权限管控 +- 单次仅执行单一工具任务,保证流程可控 + +**3. Observation(结果观察)** + +获取 Action 工具执行的返回结果,将结果作为新的上下文信息喂给大模型,进入下一轮循环。Observation 是修正模型幻觉、补充真实信息的关键,让模型不再依赖陈旧参数知识。 + +完整闭环逻辑为:用户提问 → Thought 思考 → Action 执行工具 → Observation 获取结果 → 再次 Thought 迭代 → 任务完成 → 输出最终答案。 + +### 2.3 ReAct 范式与传统一次性 Prompt 的对比 + +很多新手分不清普通问答和 Agent 的区别,本质就是「是否具备 ReAct 循环能力」: + +| 对比维度 | 传统一次性 Prompt 问答 | ReAct 智能体范式 | +|---|---|---| +| 执行方式 | 单次推理、一次性输出结果 | 迭代式循环、多轮思考执行 | +| 信息来源 | 仅依赖模型训练参数知识 | 模型知识 + 实时外部工具数据 | +| 复杂任务能力 | 无法拆解,复杂问题直接答错 | 自动拆解分步解决,适配复杂业务 | +| 幻觉概率 | 极高,知识滞后严重 | 大幅降低,以工具真实结果为准 | +| 工程依赖 | 无需 Harness,纯 Prompt 即可 | 强依赖 Harness 流程与工具管控 | +| 落地场景 | 简单问答、文案生成 | 企业自动化、数据查询、任务调度 | + +### 2.4 ReAct 核心约束 Prompt 模板 + +ReAct 之所以能自动完成 T-A-O 循环,核心靠固定格式的系统 Prompt 约束,这也是 Harness 规则约束层的核心体现。原生 ReAct 标准 Prompt 核心结构如下: + +``` +你是一个可以自主思考和调用工具的智能体。 +你需要遵循【Thought → Action → Observation】循环逻辑解决问题。 +可用工具列表:{tools} +严格遵循输出格式: +1. 思考(Thought): 分析当前问题,判断是否需要调用工具 +2. 行动(Action): 需要调用工具时,输出工具名称和参数 +3. 观察(Observation): 接收工具返回结果 +如果已经获取足够信息,无需继续调用工具,直接输出最终答案。 +问题:{input} +历史记录:{agent_scratchpad} +``` + +其中 `agent_scratchpad` 是 ReAct 的核心缓存,记录每一轮的 Thought、Action、Observation,保存迭代全过程状态,属于 Harness 上下文记忆层能力;`tools` 是 Harness 注册的全部可调用工具列表;`input` 是用户原始任务指令。 + +--- + +## 三、Harness Engineering:Agent 的运行环境与管控体系 + +### 3.1 核心概念与思想 + +Harness Engineering,也叫"驾驭工程"或 Agent Harness,是围绕 AI Agent 构建工作环境的一套工程方法。它的目标不是让模型"回答得更好看",而是让 Agent 在真实工程系统里: +- 能理解任务 +- 能读取必要上下文 +- 能调用合适工具 +- 能安全修改代码 +- 能运行测试验证 +- 能观察日志和失败原因 +- 能根据反馈继续修复 +- 能在边界内完成交付 + +简而言之:**Prompt 是你怎么跟模型说话,Harness 是你怎么给 Agent 搭工作台**。如果说大模型本身提供的是推理和生成能力,那么 Harness 提供的就是工程环境、工具系统、反馈机制和安全边界。 + +Harness 的原意是"马具"——套在马身上用于控制方向、承受重负、连接马车的那套皮革与金属装置。这个比喻非常精准:大模型就像一匹充满力量但难以预测的野马,而 Harness 就是那套让它变得可控、有用的装置。更精确的公式是: + +``` +生产级 Agent = 模型潜能 - 模型熵增 + Harness 约束 +``` + +其中"模型熵增"指大模型基于概率生成的不确定性,输入微小的 Prompt 变化可能导致巨大的行为漂移;而"Harness 约束"则是用确定性的代码逻辑去框住不确定的模型输出。Harness Engineering 的核心思想是:**每当 Agent 犯错,就将其工程化为一个永久性的系统修复,确保它不会再犯同样的错误**。 + +### 3.2 Harness 与 Prompt 工程的本质区别 + +| 维度 | Prompt Engineering | Harness Engineering | +|---|---|---| +| 核心问题 | 如何措辞指令 | 如何构建可靠系统 | +| 作用范围 | 单次推理 | 全任务生命周期 | +| 控制手段 | 文本指令 | 工具 + 约束 + 反馈 + 基建 | +| 失败模式 | 误解意图 | 缺乏纠错机制 | +| 可复现性 | 依赖模型一致性 | 依赖工程化保障 | +| 类比 | 写指令邮件 | 建项目管理体系 | + +一个具体例子可以说明这种区别。你在 prompt 里写"请遵守项目架构,不要跨层调用"——这是 Prompt 工程;但如果你把架构边界写进 custom linter,每次 Agent 改完代码都会被自动检查,违反规则就失败——这就是 Harness 工程。Prompt 可以是 Harness 的一部分,但 Harness 远远不止 Prompt。 + +### 3.3 Harness 七层内核 + +一个生产级 Harness 由七大协同组件构成,共同约束与增强智能体行为: + +1. **System Prompts(系统提示)**:行为宪法,定义身份、边界、硬约束。 +2. **Tools and Capabilities(工具与能力)**:精准能力接口,命名自解释、参数精确、错误可修复。 +3. **Infrastructure(基础设施)**:沙箱、执行引擎、文件系统等安全运行环境。 +4. **Orchestration Logic(编排逻辑)**:子智能体调度、任务分发与路由。 +5. **Hooks and Middleware(钩子与中间件)**:确定性检查点,安全门控、质量回路、完成门控、可观测性。 +6. **Memory and State(记忆与状态)**:进度与记忆持久化,避免长任务"失忆"。 +7. **Verification Systems(验证系统)**:Linter、测试、审查 Agent,最后质量防线。 + +它们联动形成闭环:验证触发 Hook,记忆动态组装 Prompt,编排决定工具调用。 + +### 3.4 引导系统与反馈系统:双控机制 + +为方便理解,可以把 Harness 工程拆成两大子系统: + +**引导系统(前馈控制 Guide)**——Agent 执行前,怎么知道该怎么做。核心是把项目里的隐性规则显性化,常见内容包括 AGENTS.md、CLAUDE.md、README、架构文档、编码规范、目录结构说明、项目启动脚本、测试命令说明、API 文档、领域知识、任务拆解模板、团队 review checklist 等。它的作用是行动前设路标与护栏,从源头减少错误。 + +**反馈系统(反馈控制 Sensor)**——Agent 执行后,怎么知道有没有做对。常见内容包括单元测试、接口测试、端到端测试、类型检查、linter、静态扫描、架构测试、安全扫描、浏览器自动化、运行日志、metrics、traces、错误堆栈、代码评审 Agent、LLM Judge、人工 review。反馈内部再分为: +- **计算性反馈**:规则驱动、毫秒级、100% 可靠,优先用 +- **推理性反馈**:AI 判断、秒级、非确定,作为补充 + +--- + +## 四、ReAct 与 Harness 的层级关系 + +结合 AI Agent 完整工程化体系,三者层级关系清晰可见: + +``` +1. LLM 模型:提供基础推理智能,是 Agent 的大脑基础 +2. ReAct 范式:定义大脑的思考方式(T-A-O 循环),是 Agent 的执行内核逻辑 +3. Harness 工程:为 ReAct 循环提供约束、容错、记忆、监控、工具调度的整套运行环境 +``` + +终极公式为: + +``` +企业级 Agent = LLM + ReAct 执行逻辑 + Harness 工程管控 +``` + +如果大模型是 Agent 的大脑,那 ReAct 就是 Agent 的"思考与行动规则",是让 AI 从"只会说话"变成"会干活"的关键转折点;而 Harness 则是为这套规则提供可靠运行环境的"操作系统"。ReAct 是执行内核,Harness 是运行环境,二者缺一不可。 + +--- + +## 五、生产级 Agent Harness 四层架构 + +深入分析 Claude Code、OpenCode、OpenClaw、Hermess 等代表性生产级 Agent 项目,可以发现一个生产级的 Agent Harness 通常分为四层,每一层都有明确的职责和边界。 + +### 5.1 架构全景 + +这四层围绕"感知→决策→行动→反馈"闭环紧密协作: + +1. **推理与编排层**:Agent 的"大脑与调度中心",负责核心决策逻辑。 +2. **上下文与记忆层**:Agent 的"工作记忆与长期记忆",管理输入和进化。 +3. **工具与安全执行层**:Agent 的"双手与安全护栏",封装外部调用。 +4. **支撑与基础架构层**:Agent 的"神经系统与循环系统",提供底层支撑。 + +以"帮我在项目里添加用户登录功能"为例,完整流程为:支撑层接收请求并分配会话 ID → 上下文层组装输入(从 CLAUDE.md 读取技术栈、从记忆系统加载用户偏好)→ 推理层启动 Plan Mode 生成任务清单 → 用户批准后进入 Execute Mode → 安全层拦截工具调用做 AST 分析与风险评估 → 执行结果返回推理层 → 循环继续。 + +### 5.2 推理与编排层:Agent Loop 的状态机化 + +最基础的 Agent Loop 就是一个 while 循环: + +```javascript +while (!done) { + const response = await callLLM(messages); + if (response.toolCalls.length > 0) { + const results = await executeTools(response.toolCalls); + messages.push(...results); + } else { + done = true; + return response.content; + } +} +``` + +但在生产环境中这远远不够,需要处理流式响应、并行执行、错误恢复、用户中断、状态持久化等问题。因此生产级系统普遍采用状态机管理循环,例如 Claude Code 内部定义了精细的状态: + +```javascript +enum LoopState { + INIT = 'INIT', // 初始化,准备上下文 + THINKING = 'THINKING', // 正在调用 LLM + PARSING = 'PARSING', // 解析 LLM 输出 + EXECUTING = 'EXECUTING', // 执行工具(可能并行) + OBSERVING = 'OBSERVING', // 收集工具结果 + REFLECTING = 'REFLECTING', // (可选)反思结果 + COMPRESSING = 'COMPRESSING', // 触发上下文压缩 + TERMINATED = 'TERMINATED' // 终止 +} +``` + +状态机的优势在于明确的阶段划分、易于调试、支持暂停/恢复、错误隔离。实现要点包括:状态转换要原子化、状态数据要隔离、超时控制要精细、可观测性要内置。每个状态对应一个独立的处理器(Handler),状态机只负责调度。 + +对于复杂任务,还支持多智能体编排,常见两种模式: +- **父子委派模式**(主 Agent 通过 Task 工具委派子任务给 SubAgent,具备上下文隔离、递归深度限制、结果聚合) +- **对等协作模式**(多个 Agent 组成团队,通过消息总线异步通信) + +### 5.3 上下文与记忆层:System Prompt 的结构化组装 + +生产级系统的 System Prompt 分为静态区和动态区。静态区(角色定义、输出格式、安全规范)放在前面以利用 LLM 缓存,减少 Token 消耗;动态区(项目名、技术栈、当前任务、历史摘要)放在后面每次更新。 + +项目级上下文通过智能加载机制实现:Agent 启动时自动扫描项目根目录,按优先级寻找 CLAUDE.md、AGENTS.md、.claude/context.md 等配置文件,并对内容做智能截断(只取前 N 个 Token)。 + +### 5.4 上下文工程:渐进式披露 + +上下文是稀缺资源,上下文腐烂和描述膨胀会让准确率暴跌。核心策略是**渐进式披露**,分三层管理: + +- **索引层**:始终保留项目结构、入口地图。 +- **接口层**:操作模块时加载 API 与约束。 +- **实现层**:修改文件时才加载源码。 + +用目录式索引告诉智能体"去哪找",而非"全记住",上下文可从数万 Token 压至几千。 + +--- + +## 六、实战:从零实现标准 ReAct Agent + +下面给出一份基于 LangChain 的标准原生 ReAct 智能体完整可运行代码,完整保留 T-A-O 循环、格式约束、容错机制与 Harness 管控能力,适配国内开源大模型。 + +### 6.1 环境依赖 + +```bash +pip install langchain langchain-openai python-dotenv +``` + +### 6.2 完整可运行代码 + +```python +from dotenv import load_dotenv +import os +from langchain_openai import ChatOpenAI +from langchain.agents import AgentExecutor, create_react_agent +from langchain.tools import CalculatorTool +from langchain_community.utilities import WikipediaAPIWrapper +from langchain_community.tools import WikipediaQueryRun +from langchain.prompts import PromptTemplate +from langchain.globals import set_debug + +# 加载环境变量 +load_dotenv() +# 开启全链路日志(Harness 可观测能力) +set_debug(True) + +# ===================== 1. 初始化模型(适配国内任意 OpenAI 格式接口) ===================== +llm = ChatOpenAI( + model="qwen-turbo", + temperature=0.0, # 零随机性,保证 ReAct 思考逻辑稳定 + openai_api_key=os.getenv("OPENAI_API_KEY"), + openai_api_base=os.getenv("OPENAI_API_BASE") +) + +# ===================== 2. 注册工具(Harness 工具层) ===================== +calc_tool = CalculatorTool() +wiki_api = WikipediaAPIWrapper(top_k_results=1, doc_content_chars_max=500) +wiki_tool = WikipediaQueryRun(api_wrapper=wiki_api) +tools = [calc_tool, wiki_tool] + +# ===================== 3. 标准 ReAct 约束 Prompt(核心) ===================== +react_prompt = PromptTemplate.from_template(""" +你是严格遵循 ReAct 范式的智能体,必须按照 Thought → Action → Observation 循环执行任务。 +可用工具:{tools} +执行规则: +1. 遇到需要计算、外部知识查询的问题,必须调用工具,禁止自行编造答案 +2. 每一轮只能做一次思考 + 一次工具调用 +3. 信息足够后,停止循环,输出简洁完整的最终答案 +用户问题:{input} +执行过程记录:{agent_scratchpad} +""") + +# ===================== 4. 创建 ReAct Agent + Harness 管控 ===================== +agent = create_react_agent(llm, tools, react_prompt) + +# Harness 容错、限流、防死循环配置 +agent_executor = AgentExecutor( + agent=agent, + tools=tools, + verbose=True, + max_iterations=5, # 最大循环次数,防止 ReAct 死循环 + handle_parsing_errors=True, # 解析异常兜底 + timeout=15, # 超时熔断 + return_intermediate_steps=True # 返回完整 ReAct 步骤 +) + +# ===================== 5. 测试运行 ===================== +if __name__ == "__main__": + query = "请查询圆周率的近似定义,并计算 3.14159 * 128 的结果" + result = agent_executor.invoke({"input": query}) + print("=" * 50) + print("最终答案:", result["output"]) + print("=" * 50) + print("完整 ReAct 迭代步骤:") + for step in result["intermediate_steps"]: + print(f"步骤详情:{step}") +``` + +### 6.3 运行逻辑解析 + +执行过程遵循标准 ReAct 多轮迭代闭环: +1. 第一轮 Thought 识别任务需要先查询圆周率定义,调用维基百科工具 +2. 第一轮 Action 执行百科查询 +3. 第一轮 Observation 拿到文本信息,判断还需要数学计算 +4. 第二轮 Thought 决定调用计算器工具执行乘法 +5. 第二轮 Action 执行计算 +6. 信息充足,终止循环输出最终答案 + +--- + +## 七、ReAct 工程落地常见踩坑与优化 + +在 Harness 工程落地中,ReAct 是故障高发点,核心问题全部来自循环机制本身。 + +| 问题 | 现象 | 根因 | 解决方案 | +|---|---|---|---| +| 无限循环 | Agent 反复调用同一工具,无法结束任务 | 模型无法判断任务是否完成、工具返回信息重复 | Harness 层配置 `max_iterations` 最大迭代限制,强制熔断 | +| 格式解析失败 | 模型输出不遵循 Thought/Action 格式,任务中断 | Prompt 约束不严格 | 开启 `handle_parsing_errors` 异常兜底,优化 Prompt 格式约束 | +| 过度调用工具 | 简单问题也强行调用工具,浪费 Token | 缺乏常识判断规则 | 在 Prompt 中增加规则:简单常识问题可直接回答 | +| 上下文溢出 | 多轮迭代后 `agent_scratchpad` 过长触发超限 | 迭代日志累积 | 依托 Harness 记忆层,定时精简迭代日志、截断无效历史 | + +### 企业级 ReAct 工程优化方案(结合 Harness 架构) + +原生 ReAct 仅能实现基础能力,企业落地必须结合 Harness 架构做优化: + +1. **约束层优化**:分级规则管控,简单任务弱约束、复杂任务强约束,平衡稳定性与灵活性。 +2. **容错层优化**:智能重试 + 失败降级,工具调用失败时自动重试 2 次,重试失败后触发兜底答案,不中断业务流程。 +3. **记忆层优化**:迭代过程轻量化存储,区分"有效迭代步骤"和"冗余日志",长期只保存关键 ReAct 决策过程。 +4. **可观测层优化**:步骤级监控,统计每轮 ReAct 迭代耗时、失败率、工具调用命中率,数据驱动优化。 + +--- + +## 八、Harness 落地实践与治理 + +### 8.1 真实项目落地清单 + +在一个前后端分离的业务项目里,要让 Coding Agent 帮忙修 Bug、改接口、补测试,可以这样设计 Harness: + +1. 用 AGENTS.md 或 CLAUDE.md 做统一入口,告知 Agent 项目结构、常用命令、关键约束和禁止事项。 +2. 把详细架构文档放到 docs 目录,让 Agent 按需读取,而不是每次都塞进 prompt。 +3. 用 linter、类型检查和架构测试限制跨层调用,避免 Agent 改出"能跑但不符合架构"的代码。 +4. 提供标准化启动脚本,如 `start-backend`、`start-frontend`、`run-unit-test`、`run-api-test`。 +5. 对接口任务接入 Swagger、OpenAPI、Postman Collection 或自动化接口测试。 +6. 对前端任务接入 Playwright,让 Agent 不只是看代码,而是真的打开页面验证。 +7. 暴露运行日志、metrics 和 traces,让 Agent 失败后能看到原因,而不是凭空猜。 +8. 在任务结束前加入 checklist,要求确认需求点、测试结果、修改范围和风险点。 +9. 对复杂任务引入独立 Review Agent 或人工 review,避免单 Agent 自说自话。 +10. 关键仓库必须配置权限和沙箱,限制 Agent 能访问什么、能修改什么、能执行什么命令。 + +### 8.2 治理三维度与落地四阶段 + +**治理三维度(从易到难)**: + +1. **可维护性**:代码规范、圈复杂度,工具成熟、自动化高。 +2. **架构适应性**:性能、安全、依赖审计,需复杂基建。 +3. **行为正确性**:业务需求匹配,最难、自动化最低。 + +**落地四阶段**: + +1. **基础验证**:部署 Lint 与测试,打底质量底线。 +2. **前馈增强**:把失败转为 AGENTS.md 规则,显性化隐性知识。 +3. **闭环优化**:高频错误变 Hook,形成自纠错。 +4. **度量驱动**:用指标仪表盘数据定向优化。 + +### 8.3 转向循环:让错误只犯一次 + +Harness 的终极价值是**复利效应**:观察失败 → 诊断根因 → 工程化修复 → 编码进 Harness → 验证部署。把单次人工修正,变成永久规则。比如智能体总提交超大代码,加一条"单次提交≤200 行",所有会话永久遵守,同类错误彻底消失。 + +Terminal-Bench 2.0 基准显示:同一模型仅换 Harness,排名可偏移超 25 位;精良 Harness 的中等模型,能打败粗糙 Harness 的顶级模型——**Harness 质量,才是性能决定性因素**。 + +--- + +## 九、总结 + +ReAct 不是一个框架、不是一个工具,而是 AI Agent 的标准思考与执行范式,是所有智能体实现"自主解决复杂任务"的核心底层。它通过 T-A-O 循环让 AI 从"只会说话"变成"会干活"。 + +Harness Engineering 则让 AI 工程范式从"调教模型"转向"建造系统",把不可控的概率输出变成可控、可复现、可持续优化的生产级能力。它的核心是搭建可验证、可约束的运行体系,让 AI 能可靠完成长链路任务。 + +二者的关系是:**ReAct 是执行内核,Harness 是运行环境**。只有吃透 ReAct 的迭代闭环、踩坑痛点与工程优化,同时构建完善的 Harness 约束、容错、记忆、监控体系,才能开发出稳定、可落地、可迭代的企业级 AI Agent,而不是只能跑 Demo 的玩具智能体。 + +AI Agent 的竞争早已不是模型军备竞赛,而是系统工程能力的比拼——未来决定 AI 落地上限的,不是模型有多强,而是你的 Harness 有多稳。 + +--- + +## 参考来源 + +- 《ReAct: Synergizing Reasoning and Acting in Language Models》— Yao et al., Princeton & Google Brain, 2022 +- Anthropic Claude Agent SDK 工程博客 — "Agent Harness" +- Mitchell Hashimoto — "Harness Engineering" 概念提出 +- Terminal-Bench 2.0 基准测试数据 +- Claude Code / OpenCode / OpenClaw / Hermess 生产级 Agent 项目源码分析 +- CSDN 博客:《AI Agent 驾驭工程:从理论到生产级系统架构实战》 +- CSDN 博客:《AI Agent 核心范式 ReAct 深度详解》 +- CSDN 博客:《Harness Engineering:AI Agent 从"能用"到"可靠"的工程革命》 +- 博客园:《面试官问:什么是 Harness 工程?》 +- 腾讯新闻:《AI 大模型实战篇:AI Agent 设计模式 ReAct》 diff --git a/docs/BUILD.md b/docs/BUILD.md index cfbba20..2b544f4 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -91,7 +91,7 @@ npm run dist # NSIS 安装包 | 文件 | 说明 | |------|------| -| `release/Metona Ollama Setup v0.11.11.exe` | NSIS 安装包(可选目录、创建快捷方式) | +| `release/Metona Ollama Setup v0.12.2.exe` | NSIS 安装包(可选目录、创建快捷方式) | > 仅提供 NSIS 安装包。 @@ -103,7 +103,7 @@ npm run dist # NSIS 安装包 TOKEN="your_access_token" curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \ -H "Content-Type: application/json" \ - -d '{"tag_name":"v0.11.11","name":"v0.11.11","body":"Release notes","target_commitish":"master"}' + -d '{"tag_name":"v0.12.2","name":"v0.12.2","body":"Release notes","target_commitish":"master"}' ``` ### 上传附件 @@ -112,7 +112,7 @@ curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/release TOKEN="your_access_token" RELEASE_ID="" curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \ - -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.11.exe" + -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.12.2.exe" ``` ## 故障排查 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6fc6075..6230af3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Metona Ollama Desktop — 开发规范 -> 版本: v0.11.11 | 更新: 2026-06-05 | 维护: 项目团队 +> 版本: v0.12.2 | 更新: 2026-06-05 | 维护: 项目团队 --- diff --git a/package-lock.json b/package-lock.json index 02235d5..87238df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.11.11", + "version": "0.12.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.11.11", + "version": "0.12.3", "license": "MIT", "dependencies": { "ffmpeg-static": "^5.2.0", diff --git a/package.json b/package.json index a5448b2..3ea865d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.11.11", + "version": "0.12.3", "description": "Metona Ollama - TypeScript + Electron \u684c\u9762 AI \u804a\u5929\u5ba2\u6237\u7aef", "main": "dist/main/main.js", "author": "thzxx", diff --git a/src/main/browser.ts b/src/main/browser.ts index a72e2d3..5297887 100644 --- a/src/main/browser.ts +++ b/src/main/browser.ts @@ -149,37 +149,23 @@ export async function browserScreenshot(params: { full_page?: boolean; selector? } if (params.full_page) { - // 全页面截图:先滚动获取完整高度,截完恢复 + // 全页面截图:一次性捕获整个页面矩形区域 const pageSize = await win.webContents.executeJavaScript(` (() => { const w = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, window.innerWidth); const h = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, window.innerHeight); - const origScroll = { x: window.scrollX, y: window.scrollY }; - window.scrollTo(0, 0); - return { width: w, height: h, origScroll }; + return { width: w, height: h }; })() - `, true) as { width: number; height: number; origScroll: { x: number; y: number } }; + `, true) as { width: number; height: number }; - // 分块截图拼接(超过窗口高度的页面) - const viewHeight = 800; - const chunks: Buffer[] = []; - for (let y = 0; y < pageSize.height; y += viewHeight) { - await win.webContents.executeJavaScript(`window.scrollTo(0, ${y})`, true); - await new Promise(r => setTimeout(r, 200)); - const image = await win.webContents.capturePage({ - x: 0, y: 0, - width: pageSize.width, - height: Math.min(viewHeight, pageSize.height - y) - }); - chunks.push(image.toPNG()); - } - // 恢复滚动位置 - await win.webContents.executeJavaScript(`window.scrollTo(${pageSize.origScroll.x}, ${pageSize.origScroll.y})`, true); - - // 拼接所有截图(简单合并 base64) - const fullPng = Buffer.concat(chunks).toString('base64'); - sendLog('success', `🌐 browser 截图(全页)`, `${pageSize.width}x${pageSize.height}, ${fullPng.length} chars`); - return { success: true, png: fullPng, width: pageSize.width, height: pageSize.height }; + const image = await win.webContents.capturePage({ + x: 0, y: 0, + width: pageSize.width, + height: pageSize.height + }); + const buffer = image.toPNG(); + sendLog('success', `🌐 browser 截图(全页)`, `${pageSize.width}x${pageSize.height}, ${buffer.length} bytes`); + return { success: true, png: buffer.toString('base64'), width: pageSize.width, height: pageSize.height }; } // 视口截图(默认) diff --git a/src/main/db/sqlite.ts b/src/main/db/sqlite.ts index 80a80d2..037bd23 100644 --- a/src/main/db/sqlite.ts +++ b/src/main/db/sqlite.ts @@ -87,8 +87,14 @@ function persist(): void { try { const data = db.export(); const buf = Buffer.from(data); - fs.writeFileSync(dbPath, buf); - } catch { /* 静默失败,不阻断主流程 */ } + // 先写临时文件再 rename,避免写一半崩溃导致数据库损坏 + const tmpPath = dbPath + '.tmp'; + fs.writeFileSync(tmpPath, buf); + fs.renameSync(tmpPath, dbPath); + } catch (err) { + // 记录到启动日志文件(console.error 会被 main.ts 的 uncaughtException 捕获) + console.error(`[SQLite persist] 写入失败: ${(err as Error).message}`); + } } /** 初始化数据库(异步,需加载 WASM) */ @@ -200,6 +206,7 @@ export async function initDatabase(): Promise { action_input TEXT, observation TEXT, loop_count INTEGER, + error_pattern TEXT, created_at INTEGER NOT NULL, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); @@ -210,7 +217,8 @@ export async function initDatabase(): Promise { // 兼容迁移:为已有 messages 表补充 attachments 列(文件/视频等附件 JSON) try { db.run('ALTER TABLE messages ADD COLUMN attachments TEXT'); } catch { /* 列已存在,忽略 */ } - // 兼容迁移:为已有 messages 表补充 prompt_eval_count 列 + // 兼容迁移:v0.12.0 — 为已有 traces 表补充 error_pattern 列 + try { db.run('ALTER TABLE traces ADD COLUMN error_pattern TEXT'); } catch { /* 列已存在,忽略 */ } // 尝试创建 FTS5 全文搜索(可选,sql.js 默认 WASM 可能不包含 FTS5) @@ -305,6 +313,7 @@ export interface TraceRow { action_input: string | null; observation: string | null; loop_count: number | null; + error_pattern: string | null; created_at: number; } @@ -481,9 +490,9 @@ export function getToolCallsBySession(sessionId: string): ToolCallRow[] { // ─── Traces CRUD ─── export function saveTrace(trace: TraceRow): string { - runExec(getDb(), `INSERT OR REPLACE INTO traces (id, session_id, step_index, thought, action, action_input, observation, loop_count, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [trace.id, trace.session_id, trace.step_index, trace.thought, trace.action, trace.action_input, trace.observation, trace.loop_count, trace.created_at] + runExec(getDb(), `INSERT OR REPLACE INTO traces (id, session_id, step_index, thought, action, action_input, observation, loop_count, error_pattern, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [trace.id, trace.session_id, trace.step_index, trace.thought, trace.action, trace.action_input, trace.observation, trace.loop_count, trace.error_pattern, trace.created_at] ); persist(); return trace.id; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a758531..68cb9cd 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -55,7 +55,7 @@ import { } from './tool-handlers.js'; import { browserOpen, browserScreenshot, browserEvaluate, browserExtract, browserClick, browserType, browserScroll, browserClose, browserWait } from './browser.js'; import { startServer, stopServer, stopAllServers, callTool, getAllTools, getServerStatuses, refreshTools, setMCPTimeout } from './mcp-manager.js'; -import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js'; +import { getAllowedDirs, getBlockedDirs, setAllowedDirs, checkPathAllowed } from './tool-security.js'; import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js'; import { setHTTPTimeout } from './tool-handlers-system.js'; import { execFile } from 'child_process'; @@ -132,6 +132,8 @@ export async function setupIPC(): Promise { ipcMain.handle('fs:readFile', async (_, filePath: string) => { try { + const check = checkPathAllowed(path.resolve(filePath), 'read'); + if (!check.ok) return { success: false, error: check.reason }; const content = await fs.promises.readFile(filePath, 'utf-8'); return { success: true, content, name: path.basename(filePath) }; } catch (err) { @@ -141,6 +143,8 @@ export async function setupIPC(): Promise { ipcMain.handle('fs:readFileBase64', async (_, filePath: string) => { try { + const check = checkPathAllowed(path.resolve(filePath), 'read'); + if (!check.ok) return { success: false, error: check.reason }; const buf = await fs.promises.readFile(filePath); return { success: true, content: buf.toString('base64'), size: buf.length, name: path.basename(filePath) }; } catch (err) { @@ -150,6 +154,8 @@ export async function setupIPC(): Promise { ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string, encoding?: string) => { try { + const check = checkPathAllowed(path.resolve(filePath), 'write'); + if (!check.ok) return { success: false, error: check.reason }; await fs.promises.writeFile(filePath, content, (encoding as BufferEncoding) || 'utf-8'); return { success: true }; } catch (err) { diff --git a/src/main/mcp-manager.ts b/src/main/mcp-manager.ts index 22bea03..c0cc884 100644 --- a/src/main/mcp-manager.ts +++ b/src/main/mcp-manager.ts @@ -277,7 +277,7 @@ export function getAllTools(): MCPToolInfo[] { tools.push({ serverName, name: tool.name, - fullName: `mcp_${serverName}_${tool.name}`, + fullName: `mcp_${serverName}__${tool.name}`, description: `[MCP:${serverName}] ${tool.description || ''}`, inputSchema: tool.inputSchema }); diff --git a/src/main/menu.ts b/src/main/menu.ts index 46838a7..cca202e 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -101,7 +101,7 @@ export function createMenu(): void { dialog.showMessageBox(mainWindow!, { type: 'info', title: '关于 Metona Ollama', - message: 'Metona Ollama Desktop v0.11.11', + message: 'Metona Ollama Desktop v0.12.3', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', icon: getIconPath() }); diff --git a/src/main/tool-handlers-fs.ts b/src/main/tool-handlers-fs.ts index 85725a6..3d5c2bb 100644 --- a/src/main/tool-handlers-fs.ts +++ b/src/main/tool-handlers-fs.ts @@ -357,7 +357,8 @@ export async function handleSearchFiles(params: { path: string; query: string; s if (searchType === 'filename' || searchType === 'both') { const fileName = path.basename(filePath); const nameToCheck = caseSensitive ? fileName : fileName.toLowerCase(); - if (nameToCheck.includes(query)) { + const queryToCheck = caseSensitive ? query : query.toLowerCase(); + if (nameToCheck.includes(queryToCheck)) { results.push({ path: filePath, matches: [{ line: 0, text: `[文件名匹配] ${fileName}` }] }); totalMatches++; continue; @@ -471,6 +472,9 @@ export async function handleEditFile(params: { path: string; old_text: string; n if (!allowed.ok) return { success: false, error: allowed.reason }; const content = await fs.readFile(filePath, 'utf-8'); + if (content.length > 5 * 1024 * 1024) { + return { success: false, error: `文件过大 (${(content.length / 1024 / 1024).toFixed(1)}MB),最大支持 5MB` }; + } let replaceCount: number; let newContent: string; diff --git a/src/main/tool-handlers-system.ts b/src/main/tool-handlers-system.ts index 23c1913..b08e1a2 100644 --- a/src/main/tool-handlers-system.ts +++ b/src/main/tool-handlers-system.ts @@ -375,7 +375,34 @@ export async function handleWebFetch(params: { url: string; max_chars?: number; return { success: false, error: `页面过大 (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB),超过 10MB 限制` }; } - let text = await resp.text(); + // 无 content-length 头时,流式读取并限制最大体积(防 OOM) + const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB + let text: string; + if (!contentLength && resp.body) { + const reader = resp.body.getReader(); + const chunks: Uint8Array[] = []; + let totalSize = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalSize += value.length; + if (totalSize > MAX_BODY_SIZE) { + reader.cancel(); + return { success: false, error: `页面过大 (超过 10MB),已中止下载` }; + } + chunks.push(value); + } + const decoder = new TextDecoder(); + text = decoder.decode(Buffer.concat(chunks)); + } else if (!contentLength) { + // 无 body stream 回退,但仍需限制 + text = await resp.text(); + if (text.length > MAX_BODY_SIZE) { + return { success: false, error: `页面过大 (${(text.length / 1024 / 1024).toFixed(1)}MB),超过 10MB 限制` }; + } + } else { + text = await resp.text(); + } const isHTML = contentType.includes('html'); if (isHTML) { text = htmlToText(text); @@ -1163,6 +1190,7 @@ export async function handleDownloadFile(params: { url: string; destination: str export async function handleCompress(params: { action: string; path: string; destination?: string; format?: string }): Promise { try { const format = params.format || 'tar.gz'; + const isWin = process.platform === 'win32'; if (params.action === 'create') { const srcPath = resolvePath(params.path); @@ -1176,13 +1204,28 @@ export async function handleCompress(params: { action: string; path: string; des if (!destCheck.ok) return { success: false, error: destCheck.reason }; return new Promise((resolve) => { - const cmd = format === 'zip' - ? `zip -r "${destPath}" "${path.basename(srcPath)}"` - : `tar czf "${destPath}" "${path.basename(srcPath)}"`; - const proc = spawn('bash', ['-c', cmd], { cwd: path.dirname(srcPath), stdio: ['pipe', 'pipe', 'pipe'] }); + let child; + const srcName = path.basename(srcPath); + const cwd = path.dirname(srcPath); + + if (format === 'zip') { + if (isWin) { + // Windows: PowerShell Compress-Archive + child = spawn('powershell', [ + '-NoProfile', '-Command', + `Compress-Archive -Path '${srcName}' -DestinationPath '${destPath}' -Force` + ], { cwd, stdio: ['pipe', 'pipe', 'pipe'] }); + } else { + child = spawn('zip', ['-r', destPath, srcName], { cwd, stdio: ['pipe', 'pipe', 'pipe'] }); + } + } else { + // tar.gz: Windows 10+ has tar, Unix has tar + child = spawn('tar', ['czf', destPath, srcName], { cwd, stdio: ['pipe', 'pipe', 'pipe'] }); + } + let stderr = ''; - proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); - proc.on('close', (code) => { + child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + child.on('close', (code) => { if (code === 0) { fs.stat(destPath).then(s => { resolve({ success: true, action: 'create', archive: destPath, size: s.size }); @@ -1191,7 +1234,7 @@ export async function handleCompress(params: { action: string; path: string; des resolve({ success: false, error: stderr || `压缩失败 (exit ${code})` }); } }); - proc.on('error', (err) => resolve({ success: false, error: err.message })); + child.on('error', (err) => resolve({ success: false, error: err.message })); }); } else { // extract @@ -1206,20 +1249,31 @@ export async function handleCompress(params: { action: string; path: string; des await fs.mkdir(destDir, { recursive: true }); return new Promise((resolve) => { + let child; const isZip = archivePath.endsWith('.zip'); - const cmd = isZip - ? `unzip -o "${archivePath}" -d "${destDir}"` - : `tar xzf "${archivePath}" -C "${destDir}"`; - const proc = spawn('bash', ['-c', cmd], { stdio: ['pipe', 'pipe', 'pipe'] }); + + if (isZip) { + if (isWin) { + child = spawn('powershell', [ + '-NoProfile', '-Command', + `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force` + ], { stdio: ['pipe', 'pipe', 'pipe'] }); + } else { + child = spawn('unzip', ['-o', archivePath, '-d', destDir], { stdio: ['pipe', 'pipe', 'pipe'] }); + } + } else { + child = spawn('tar', ['xzf', archivePath, '-C', destDir], { stdio: ['pipe', 'pipe', 'pipe'] }); + } + let stderr = ''; - proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); - proc.on('close', (code) => { + child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + child.on('close', (code) => { resolve(code === 0 ? { success: true, action: 'extract', destination: destDir } : { success: false, error: stderr || `解压失败 (exit ${code})` } ); }); - proc.on('error', (err) => resolve({ success: false, error: err.message })); + child.on('error', (err) => resolve({ success: false, error: err.message })); }); } } catch (err) { diff --git a/src/main/tool-security.ts b/src/main/tool-security.ts index 18e11de..a802200 100644 --- a/src/main/tool-security.ts +++ b/src/main/tool-security.ts @@ -21,15 +21,28 @@ let allowedDirs: string[] = [ /** 永久禁止的目录 */ const BLOCKED_DIRS: string[] = [ + // Linux/macOS 系统目录 '/etc', '/sys', '/proc', '/dev', '/boot', '/root', - 'C:\\Windows', 'C:\\Program Files', 'C:\\ProgramData', + '/bin', '/sbin', '/usr/bin', '/usr/sbin', '/usr/lib', + '/var/log', '/var/run', '/var/spool', '/var/mail', + '/tmp/.X11-unix', '/tmp/.font-unix', + // Windows 系统目录 + 'C:\\Windows', 'C:\\Windows\\System32', 'C:\\Windows\\SysWOW64', + 'C:\\Windows\\System', 'C:\\Windows\\WinSxS', + 'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\ProgramData', + // 用户敏感目录 path.join(HOME, '.ssh'), path.join(HOME, '.gnupg'), path.join(HOME, '.aws'), + path.join(HOME, '.azure'), + path.join(HOME, '.config'), + path.join(HOME, '.kube'), + path.join(HOME, 'AppData'), ]; /** 命令黑名单 */ const BLOCKED_COMMANDS: string[] = [ + // POSIX 危险命令 'rm -rf /', 'rm -rf /*', ':(){ :|:& };:', 'mkfs', 'dd if=', 'wipefs', 'shred', 'shutdown', 'reboot', 'poweroff', 'halt', @@ -39,6 +52,15 @@ const BLOCKED_COMMANDS: string[] = [ 'systemctl enable', 'systemctl disable', 'curl | sh', 'wget | sh', 'curl | bash', 'wget | bash', 'eval', 'exec >', + // Windows 危险命令 + 'rmdir /s', 'del /f', 'del /s', 'del /q', + 'format', 'diskpart', + 'reg add', 'reg delete', 'reg import', 'reg export', + 'sc stop', 'sc delete', 'sc config', + 'net user', 'net localgroup', 'net share', + 'icacls', 'takeown', 'cacls', + 'bcdedit', 'wmic', 'rundll32', + 'schtasks /create', 'schtasks /delete', ]; export interface CheckResult { diff --git a/src/main/workspace.ts b/src/main/workspace.ts index 1853e9e..241e5d2 100644 --- a/src/main/workspace.ts +++ b/src/main/workspace.ts @@ -142,7 +142,12 @@ export function killProcess(id: string): boolean { return false; } try { - proc.kill('SIGTERM'); + // Windows 不支持 POSIX 信号,需用 taskkill 强制终止进程树 + if (process.platform === 'win32' && proc.pid) { + spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F']); + } else { + proc.kill('SIGTERM'); + } activeProcesses.delete(id); sendLog('info', `🖥️ 进程已终止`, `ID: ${id}`); return true; @@ -156,7 +161,13 @@ export function killProcess(id: string): boolean { /** 停止所有进程(窗口关闭时调用) */ export function killAllProcesses(): void { for (const [id, proc] of activeProcesses) { - try { proc.kill('SIGTERM'); } catch { /* ignore */ } + try { + if (process.platform === 'win32' && proc.pid) { + spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F']); + } else { + proc.kill('SIGTERM'); + } + } catch { /* ignore */ } } activeProcesses.clear(); } @@ -166,8 +177,12 @@ export function listWorkspaceDir(dirPath?: string): { success: boolean; entries? try { const targetDir = dirPath ? path.resolve(dirPath) : _workspaceDir; - // 安全检查:必须在 workspace 范围内 - if (!targetDir.startsWith(_workspaceDir) && !targetDir.startsWith(DEFAULT_WORKSPACE_DIR)) { + // 安全检查:必须在 workspace 范围内(Windows 路径大小写不敏感) + const isWin = process.platform === 'win32'; + const targetCheck = isWin ? targetDir.toLowerCase() : targetDir; + const wsCheck = isWin ? _workspaceDir.toLowerCase() : _workspaceDir; + const defaultCheck = isWin ? DEFAULT_WORKSPACE_DIR.toLowerCase() : DEFAULT_WORKSPACE_DIR; + if (!targetCheck.startsWith(wsCheck) && !targetCheck.startsWith(defaultCheck)) { return { success: false, error: '只能浏览工作空间目录内的文件' }; } diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts index 8048e76..43bdd2c 100644 --- a/src/renderer/components/chat-area.ts +++ b/src/renderer/components/chat-area.ts @@ -254,7 +254,7 @@ function renderToolCallCard(tc: ToolCallRecord): string { edit_file: '✂️', get_file_info: 'ℹ️', tree: '🌳', download_file: '⬇️', diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚', git: '🔖', compress: '🗜️', web_search: '🔍', - memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖', + memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋', browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰', browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌' }; @@ -265,7 +265,7 @@ function renderToolCallCard(tc: ToolCallRecord): string { edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件', diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取', git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索', - memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', + memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪', browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容', browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器' }; diff --git a/src/renderer/components/history-modal.ts b/src/renderer/components/history-modal.ts index 04a5730..bc811a8 100644 --- a/src/renderer/components/history-modal.ts +++ b/src/renderer/components/history-modal.ts @@ -55,11 +55,12 @@ export function initHistoryModal(): void { const target = (e.target as HTMLElement).closest('[data-id]') as HTMLElement; if (!target) return; const sessionId = target.dataset.id!; - const db = state.get(KEYS.DB); + const db = state.get(KEYS.DB); + if (!db) return; if (target.classList.contains('btn-delete-session')) { if (await showConfirm('确定删除此会话?', '删除会话')) { - await db!.deleteSession(sessionId); + await db.deleteSession(sessionId); logSession('删除', sessionId); const currentSession = state.get(KEYS.CURRENT_SESSION); if (currentSession && currentSession.id === sessionId) { @@ -68,13 +69,13 @@ export function initHistoryModal(): void { loadHistory(); } } else if (target.classList.contains('btn-export-md')) { - const session = await db!.getSession(sessionId); + const session = await db.getSession(sessionId); if (session) exportAsMarkdown(session); } else if (target.classList.contains('btn-export-html')) { - const session = await db!.getSession(sessionId); + const session = await db.getSession(sessionId); if (session) exportAsHtml(session); } else if (target.classList.contains('btn-export-txt')) { - const session = await db!.getSession(sessionId); + const session = await db.getSession(sessionId); if (session) exportAsTxt(session); } else if (target.classList.contains('history-info') || target.classList.contains('history-item')) { loadHistorySession(sessionId); diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index 90aecf9..25e9821 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -20,13 +20,14 @@ import { estimateTokens } from '../services/context-manager.js'; import { searchMemories, buildMemoryContext, markMemoryUsed, extractMemoriesFromConversation, isMemoryEnabled } from '../services/memory-manager.js'; import { showToolConfirm } from './tool-confirm-modal.js'; import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js'; -import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord } from '../types.js'; +import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js'; let chatInputEl: HTMLTextAreaElement; let btnSendEl: HTMLButtonElement; let imagePreviewEl: HTMLElement; let filePreviewEl: HTMLElement; let videoPreviewEl: HTMLElement; +let planToggleCheckbox: HTMLInputElement; let pendingImages: Array<{ name: string; base64: string }> = []; let pendingFiles: Array<{ name: string; content: string; language: string; size: number }> = []; let pendingVideoFrames: Array<{ name: string; base64: string; timestampSeconds: number }> = []; @@ -150,6 +151,21 @@ export function initInputArea(): void { renderFilePreviews(); } }); + + // ── v0.12.0 Plan Mode 切换 ── + const planToggleEl = document.querySelector('#togglePlan') as HTMLInputElement; + if (planToggleEl) { + planToggleCheckbox = planToggleEl; + planToggleEl.addEventListener('change', () => { + const mode: AgentMode = planToggleEl.checked ? 'plan' : 'auto'; + state.set('agentMode', mode); + // Plan Mode 切换时注册/移除 plan_track 工具 + import('../services/tool-registry.js').then(({ setPlanModeActive }) => { + setPlanModeActive(mode === 'plan'); + }); + logInfo(`Agent 模式切换: ${mode === 'plan' ? 'Plan(先规划后执行)' : 'Auto(直接执行)'}`); + }); + } } function autoResizeTextarea(): void { @@ -211,13 +227,14 @@ function compressImage(base64: string): Promise<{ compressed: string; origBytes: async function handleImagePaths(paths: string[]): Promise { const bridge = getBridge(); + if (!bridge) return; const maxSize = 20 * 1024 * 1024; // 并行读取所有图片 const results = await Promise.all(paths.map(async (filePath) => { const name = filePath.split(/[/\\]/).pop() || filePath; try { - const result = await bridge!.fs.readFileBase64(filePath); + const result = await bridge.fs.readFileBase64(filePath); if (!result.success) return { name, error: result.error }; const size = result.size ?? 0; if (size > maxSize) return { name, error: `超过 20MB 限制` }; @@ -372,13 +389,14 @@ function renderImagePreviews(): void { async function handleTextFilePaths(paths: string[]): Promise { const bridge = getBridge(); + if (!bridge) return; // 并行读取所有文件 const results = await Promise.all(paths.map(async (filePath) => { const name = filePath.split(/[/\\]/).pop() || filePath; if (!isTextFile(name)) return { name, error: `不是支持的文本/代码文件` }; try { - const result = await bridge!.fs.readFile(filePath); + const result = await bridge.fs.readFile(filePath); if (!result.success) return { name, error: result.error }; const content = result.content as string; const size = new TextEncoder().encode(content).length; @@ -561,6 +579,19 @@ async function handleRetry(): Promise { updateMessageToolRecord(name, 'error', { success: false, error }); }, onConfirmTool: async (call) => showToolConfirm(call), + onPlanReady: async (plan: string, steps: string[]) => { + try { + const { showHtmlConfirm } = await import('./prompt-modal.js'); + const stepsHtml = steps.length > 0 + ? '

📋 执行步骤:
' + steps.map((s: string, i: number) => `  ${i + 1}. ${s}`).join('
') + : ''; + const html = `
${safeMarkdown(plan.slice(0, 1500))}${plan.length > 1500 ? '
…' : ''}${stepsHtml}
`; + return showHtmlConfirm(html, '📋 Plan Mode — 确认执行计划', true); + } catch (err) { + logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message); + return true; // 加载失败时自动批准,不阻断执行 + } + }, onDone: async (finalContent, toolRecords, loopStats) => { retryContent = finalContent; if (retryIterations > 0) { @@ -1237,6 +1268,24 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio onConfirmTool: async (call) => { return showToolConfirm(call); }, + /** v0.12.1: Plan Mode — 计划生成后等待用户确认(Markdown 渲染) */ + onPlanReady: async (plan: string, steps: string[]) => { + try { + const { showHtmlConfirm } = await import('./prompt-modal.js'); + const stepsHtml = steps.length > 0 + ? '

📋 执行步骤:
' + steps.map((s, i) => `  ${i + 1}. ${s}`).join('
') + : ''; + const html = `
${safeMarkdown(plan.slice(0, 1500))}${plan.length > 1500 ? '
…' : ''}${stepsHtml}
`; + const approved = await showHtmlConfirm(html, '📋 Plan Mode — 确认执行计划', true); + if (approved) { + logInfo('Plan Mode: 用户批准计划'); + } + return approved; + } catch (err) { + logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message); + return true; // 加载失败时自动批准,不阻断执行 + } + }, onDone: async (finalContent, toolRecords, loopStats) => { assistantContent = finalContent; diff --git a/src/renderer/components/prompt-modal.ts b/src/renderer/components/prompt-modal.ts index 96a1b10..d1d1ae8 100644 --- a/src/renderer/components/prompt-modal.ts +++ b/src/renderer/components/prompt-modal.ts @@ -124,6 +124,23 @@ export function showPrompt(options: { }); } +/** 显示自定义 confirm(支持 HTML),返回 true/false */ +export function showHtmlConfirm(html: string, title = '确认', wide = false): Promise { + ensureModal(); + + return new Promise((resolve) => { + resolveFn = (val) => resolve(val !== null); + titleEl!.textContent = title; + bodyEl!.innerHTML = `
${html}
`; + inputEl = null; + overlayEl!.style.display = ''; + // 宽屏模式:Plan Mode 确认框需要更大空间 + const modalEl = overlayEl!.querySelector('.modal') as HTMLElement; + if (modalEl) modalEl.style.maxWidth = wide ? '700px' : ''; + okBtn!.focus(); + }); +} + /** 显示自定义 confirm,返回 true/false */ export function showConfirm(message: string, title = '确认'): Promise { ensureModal(); diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index f1e5687..c55bb31 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -182,11 +182,13 @@ export function initSettingsModal(): void { }); document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => { - const api = state.get(KEYS.API); - const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value; + const api = state.get(KEYS.API, null); + if (!api) { showToast('Ollama API 未连接', 'warning'); return; } + const sel = document.querySelector('#modelSelect') as HTMLSelectElement | null; + const model = sel?.value || ''; logInfo('释放显存', model || '所有模型'); try { - await api!.chat({ model: model || 'any', messages: [], keep_alive: 0 }); + await api.chat({ model: model || 'any', messages: [], keep_alive: 0 }); showToast('显存已释放', 'success'); logSuccess('显存已释放'); updateRunningModels(); @@ -198,9 +200,10 @@ export function initSettingsModal(): void { document.querySelector('#btnClearAllHistory')!.addEventListener('click', async () => { const db = state.get(KEYS.DB); + if (!db) { showToast('数据库未就绪', 'warning'); return; } if (await showConfirm('确定清空所有历史记录?此操作不可恢复!', '清空历史')) { logWarn('清空所有历史记录'); - await db!.clearAll(); + await db.clearAll(); (document.querySelector('#btnNewChat') as HTMLElement).click(); showToast('已清空所有历史记录', 'success'); logSuccess('历史记录已清空'); @@ -305,6 +308,19 @@ export function openSettingsModal(): void { populateSubAgentModels(); updateMemoryVectorStatus(); loadTimeoutSettings(); + loadWatchdogSetting(); +} + +/** 加载看门狗设置到输入框 */ +async function loadWatchdogSetting(): Promise { + const db = state.get(KEYS.DB); + if (!db) return; + const ms = await db.getSetting('loopWatchdogMs', 1_800_000); + const el = document.querySelector('#inputLoopWatchdog') as HTMLInputElement; + if (!el) return; + if (ms === 0) el.value = '0'; + else if (ms > 0) el.value = String(Math.round(ms / 60_000)); + else el.value = ''; } export function closeSettingsModal(): void { @@ -494,6 +510,18 @@ const saveSubAgentTimeout = debounce(async () => { }, 500); document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout); +// ── v0.12.1 看门狗超时(分钟,0=禁用)── +const saveLoopWatchdog = debounce(async () => { + const db = state.get(KEYS.DB); + const val = (document.querySelector('#inputLoopWatchdog') as HTMLInputElement).value.trim(); + const minutes = val ? parseInt(val) : -1; + const ms = minutes > 0 ? minutes * 60_000 : (minutes === 0 ? 0 : 1_800_000); // 默认30分钟 + state.set('loopWatchdogMs', ms); + if (db) await db.saveSetting('loopWatchdogMs', ms); + logSetting('看门狗超时', minutes >= 0 ? (minutes === 0 ? '已禁用' : `${minutes}分钟`) : '默认(30分钟)'); +}, 500); +document.querySelector('#inputLoopWatchdog')!.addEventListener('input', saveLoopWatchdog); + function updateMemoryVectorStatus(): void { const statusEl = document.querySelector('#memoryVectorStatus'); if (!statusEl) return; @@ -520,7 +548,8 @@ async function importSessions(filePath: string): Promise { try { const bridge = window.metonaDesktop; - const result = await bridge!.fs.readFileBase64(filePath); + if (!bridge) { showToast('仅桌面端支持导入', 'warning'); return; } + const result = await bridge.fs.readFileBase64(filePath); if (!result.success) { showToast(`读取文件失败: ${result.error}`, 'error'); return; diff --git a/src/renderer/components/workspace-panel.ts b/src/renderer/components/workspace-panel.ts index d4ea4c4..4916a4c 100644 --- a/src/renderer/components/workspace-panel.ts +++ b/src/renderer/components/workspace-panel.ts @@ -965,7 +965,7 @@ function getToolDisplayName(name: string): string { git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索', memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆', - session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', + session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪', browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容', browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器' }; @@ -982,7 +982,7 @@ function getToolIcon(name: string): string { git: '🔖', compress: '🗜️', web_search: '🔍', memory_search: '🧠', memory_add: '💾', memory_replace: '✏️', memory_remove: '🗑️', - session_list: '📋', session_read: '📖', spawn_task: '🤖', + session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋', browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰', browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌' }; diff --git a/src/renderer/db/chat-db.ts b/src/renderer/db/chat-db.ts index e2e52f0..e61d880 100644 --- a/src/renderer/db/chat-db.ts +++ b/src/renderer/db/chat-db.ts @@ -12,7 +12,11 @@ function isDesktop(): boolean { /** 桌面端 DB 桥接(isDesktop 已检查,安全断言非空) */ function dbBridge() { - return window.metonaDesktop!.db!; + const bridge = window.metonaDesktop; + if (!bridge?.db) { + throw new Error('DB bridge 不可用:非桌面环境或 SQLite 未初始化'); + } + return bridge.db; } export class ChatDB { diff --git a/src/renderer/index.html b/src/renderer/index.html index c722b4c..a58e099 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@
Metona Ollama - v0.11.11 + v0.12.3
+
+ 看门狗(分) + + 0=禁用,默认30分钟 +
@@ -435,10 +448,12 @@ diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 5cfd566..5ac47a3 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -31,6 +31,8 @@ import { initWorkspacePanel, clearToolCardsExternal, clearTerminalExternal, swit import { initLogPanel, addLog } from './services/log-service.js'; import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js'; import { initSearxngModal, closeSearxngModal, loadSearxngConfig } from './components/searxng-modal.js'; +import { initHarnessHooks } from './services/hooks.js'; +import { initVerificationSystem } from './services/verification.js'; import type { ChatSession } from './types.js'; // ─── v4.0 数据迁移:IndexedDB → SQLite ─── @@ -346,7 +348,14 @@ async function init(): Promise { await loadModels(); await initMemoryManager(); await loadSearxngConfig(db); - logInit('所有组件已就绪'); + + // ── v0.12.0: Harness Engineering 系统初始化 ── + initHarnessHooks(); + initVerificationSystem(); + // Agent Metrics 无需显式初始化(按需启动) + // Context Indexer 在 Agent INIT 状态按需构建 + + logInit('所有组件已就绪(含 Harness v0.12.0)'); const savedModel = await db.getSetting('selectedModel', ''); if (savedModel) { @@ -409,6 +418,13 @@ async function init(): Promise { (document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value = String(subAgentMaxLoops); (document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value = subAgentTimeout >= 0 ? String(subAgentTimeout) : ''; + // ── v0.12.1 看门狗超时 ── + let loopWatchdogMs = await db.getSetting('loopWatchdogMs', 1_800_000); + state.set('loopWatchdogMs', loopWatchdogMs); + const watchdogMin = loopWatchdogMs > 0 ? Math.round(loopWatchdogMs / 60_000) : loopWatchdogMs; + const elWatchdog = document.querySelector('#inputLoopWatchdog') as HTMLInputElement; + if (elWatchdog) elWatchdog.value = loopWatchdogMs > 0 ? String(watchdogMin) : (loopWatchdogMs === 0 ? '0' : ''); + // ── Tool Calling 设置(永久开启,不可关闭)── state.set('toolCallingEnabled', true); const runCommandMode = await db.getSetting('runCommandMode', 'confirm') as 'auto' | 'confirm' | 'disabled'; @@ -453,6 +469,8 @@ async function init(): Promise { initTokenDashboard(); initWorkspacePanel(); setupDesktopIntegration(); + initHarnessHooks(); + initVerificationSystem(); checkConnection().then(loadModels); } } diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts index eb57941..6f01788 100644 --- a/src/renderer/services/agent-engine.ts +++ b/src/renderer/services/agent-engine.ts @@ -1,16 +1,19 @@ /** - * Agent Engine - ReAct Agent Loop 核心引擎 (v4.0) + * Agent Engine - ReAct Agent Loop 核心引擎 (v0.12.0 Harness Edition) * ReAct 模式: Thought → Action → Observation → Reflection - * 支持任务复杂度评估、自动规划、错误重试、执行轨迹记录 + * v0.12.0: 状态机化架构 + Hook 系统 + Completion Gate + Plan Mode */ import { OllamaAPI } from '../api/ollama.js'; -import { ChatDB } from '../db/chat-db.js'; import { state, KEYS } from '../state/state.js'; import { executeTool, getEnabledToolDefinitions, - needsConfirmation + needsConfirmation, + initPlanTracker, + getPlanTracker, + formatPlanStatus, + clearPlanTracker, } from './tool-registry.js'; import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js'; import { showToast } from '../components/toast.js'; @@ -18,17 +21,50 @@ import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, lo import { getWorkspaceDirPath } from '../components/workspace-panel.js'; import { generateId } from '../utils/utils.js'; import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js'; +import { runCompletionGate } from './completion-gate.js'; +import { executeHooks } from './hooks.js'; +import { recordIteration, recordToolCall, recordCompletionGate, startSessionMetrics, endSessionMetrics } from './agent-metrics.js'; +import { buildProjectIndex, buildIndexContext } from './context-indexer.js'; import type { OllamaMessage, OllamaStreamChunk, ToolCall, ToolResult, ToolCallRecord, - TraceEntry, - ChatSession + ChatSession, + LoopState, + LoopContext, + AgentMode, } from '../types.js'; const MAX_RETRIES = 2; // 工具错误自动重试次数 +const MAX_MESSAGES = 500; // v0.12.1: 上下文硬上限,超过则强制压缩 + +/** ── LoopState 常量 ── */ +const S = { + INIT: 'INIT' as LoopState, + THINKING: 'THINKING' as LoopState, + PARSING: 'PARSING' as LoopState, + EXECUTING: 'EXECUTING' as LoopState, + OBSERVING: 'OBSERVING' as LoopState, + REFLECTING: 'REFLECTING' as LoopState, + COMPRESSING: 'COMPRESSING' as LoopState, + TERMINATED: 'TERMINATED' as LoopState, +} as const; + +/** ── 状态转换表 ── */ +function getValidTransitions(state: LoopState): LoopState[] { + switch (state) { + case 'INIT': return ['THINKING', 'TERMINATED']; + case 'THINKING': return ['PARSING', 'TERMINATED']; + case 'PARSING': return ['EXECUTING', 'REFLECTING', 'THINKING', 'TERMINATED']; + case 'EXECUTING': return ['OBSERVING', 'THINKING', 'TERMINATED']; + case 'OBSERVING': return ['COMPRESSING', 'REFLECTING', 'THINKING', 'TERMINATED']; + case 'REFLECTING': return ['THINKING', 'COMPRESSING', 'TERMINATED']; + case 'COMPRESSING': return ['THINKING', 'REFLECTING', 'TERMINATED']; + case 'TERMINATED': return []; + } +} /** 获取当前操作系统环境信息(用于系统提示词注入) */ function getOSEnvironment() { @@ -38,7 +74,6 @@ function getOSEnvironment() { const bridge = (window as any).metonaDesktop; const isDesktop = bridge?.isDesktop || false; - // 通过 preload 暴露的真实系统信息(同步,无 IPC 开销) const sys = bridge?.sys; const realHomeDir = sys?.homeDir || ''; const realShell = sys?.shell || ''; @@ -59,13 +94,13 @@ function getOSEnvironment() { /** 每个工具返回给模型的最大字符数 */ const TOOL_MAX_RESULT_SIZE: Record = { - web_fetch: 20000, // 网页内容通常较长 - web_search: 3000, // 搜索结果已精简 - read_file: 15000, // 文件内容 + web_fetch: 20000, + web_search: 3000, + read_file: 15000, read_multiple_files: 10000, list_directory: 5000, search_files: 5000, - run_command: 10000, // 命令输出 + run_command: 10000, git: 5000, session_read: 15000, browser_extract: 10000, @@ -73,9 +108,9 @@ const TOOL_MAX_RESULT_SIZE: Record = { }; /** v4.1: 工具并行执行 — 依赖检测。只有确实依赖前序工具输出结果的才串行 */ -const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch']); // web_fetch 可能依赖 web_search 的结果 URL +const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch']); -/** 始终可并行的只读/独立工具(无数据依赖,永远可以同批执行) */ +/** 始终可并行的只读/独立工具 */ const ALWAYS_PARALLEL = new Set([ 'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree', 'web_search', 'browser_screenshot', 'browser_extract', 'browser_evaluate', @@ -104,12 +139,6 @@ const VALID_TOOL_NAMES = new Set([ function parseToolCallsFromText(content: string): ToolCall[] { const calls: ToolCall[] = []; - // 匹配:Action: tool_name(可选加粗标记) - // 然后紧跟 Action Input: {json} - // 支持格式: - // Action: write_file Action: write_file - // Action Input: {...} **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; @@ -124,7 +153,6 @@ function parseToolCallsFromText(content: string): ToolCall[] { const tick3 = TICK + TICK + TICK; try { - // 清理 JSON:去除可能的 markdown 代码块包裹 let cleaned = argsStr .split(tickJson).join('') .split(tick3).join('') @@ -135,7 +163,6 @@ function parseToolCallsFromText(content: string): ToolCall[] { function: { name: toolName, arguments: args } }); } catch { - // JSON 解析失败,尝试修复常见问题 try { let fixed = argsStr .replace(/'/g, '"') @@ -166,14 +193,13 @@ const toolResultCache = new Map = { - 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分钟 - // 其他工具默认无限期 + web_search: 5 * 60_000, + web_fetch: 10 * 60_000, + read_file: 30 * 60_000, + list_directory: 60_000, + search_files: 60_000, + browser_screenshot: 60_000, + browser_extract: 5 * 60_000, default: Infinity, }; @@ -204,8 +230,6 @@ function isDuplicateCall(call: ToolCall, allCalls: ToolCall[]): boolean { /** * 格式化工具结果,生成模型友好的简洁表示 - * 原始 ToolResult 对象可能包含大量冗余字段(results数组、formatted字符串等), - * 直接 JSON.stringify 会生成臃肿的 JSON,干扰模型理解和后续工具调用。 */ function formatToolResultForModel(toolName: string, result: ToolResult): string { if (!result.success) { @@ -307,7 +331,6 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string } default: { - // 通用清理:移除内部元数据字段 const clean: Record = {}; for (const [k, v] of Object.entries(result)) { if (k === 'success' || k === 'formatted' || k === 'content_type' || @@ -315,7 +338,6 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string clean[k] = v; } let json = JSON.stringify(clean); - // 按工具配置截断 const maxLen = TOOL_MAX_RESULT_SIZE[toolName] || 15000; if (json.length > maxLen) json = json.slice(0, maxLen) + '\n... (已截断)'; return json; @@ -326,16 +348,15 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string export interface AgentCallbacks { onThinking: (text: string) => void; onContent: (text: string) => void; - /** 模型正在流式生成 tool_call,工具名已知但参数还没生成完 */ onToolCallPrepare?: (call: ToolCall) => void; - /** 模型已完成 tool_call 生成,即将执行工具 */ onToolCallStart: (call: ToolCall) => void; onToolCallResult: (name: string, result: ToolResult, call: ToolCall) => void; onToolCallError: (name: string, error: string, call: ToolCall) => void; onDone: (finalContent: string, toolRecords?: ToolCallRecord[], stats?: { eval_count?: number; prompt_eval_count?: number; total_duration?: number }) => void; onConfirmTool: (call: ToolCall) => Promise; - /** Agent Loop 新迭代开始(前一轮工具执行完毕,下一轮流式输出即将开始) */ onNewIteration?: (toolCalls?: ToolCall[]) => void; + /** Plan Mode: 计划已生成,等待用户确认 */ + onPlanReady?: (plan: string, steps: string[]) => Promise; } /** 保存执行轨迹到 SQLite */ @@ -352,73 +373,100 @@ async function saveTrace(trace: Record): Promise { action_input: trace.actionInput, observation: trace.observation, loop_count: trace.loopCount, + error_pattern: trace.errorPattern || null, created_at: trace.createdAt }; await bridge.db.saveTrace(entry); } catch { /* 不阻塞主流程 */ } } -export async function runAgentLoop( +// ═══════════════════════════════════════════════════════════════ +// Harness: 状态转换函数 +// ═══════════════════════════════════════════════════════════════ + +function transition(ctx: LoopContext, newState: LoopState): void { + const allowed = getValidTransitions(ctx.state); + if (!allowed.includes(newState)) { + logWarn(`非法的状态转换: ${ctx.state} → ${newState}(允许: ${allowed.join(', ')})`); + // 容错:允许紧急终止 + if (newState === S.TERMINATED) { + ctx.state = S.TERMINATED; + state.set('_loopState', ctx.state); + return; + } + return; + } + ctx.state = newState; + state.set('_loopState', ctx.state); +} + +/** 持久化 LoopContext 到 state(用于暂停/恢复) */ +function persistLoopContext(ctx: LoopContext): void { + state.set('_loopContext', { + sessionId: ctx.sessionId, + loopCount: ctx.loopCount, + maxLoops: ctx.maxLoops, + totalEvalCount: ctx.totalEvalCount, + totalPromptEvalCount: ctx.totalPromptEvalCount, + allToolRecords: ctx.allToolRecords, + startTime: ctx.startTime, + }); +} + +/** 从 state 恢复 LoopContext(暂未实现完整恢复,预留接口) */ +function restoreLoopContext(_sessionId: string): Partial | null { + return state.get | null>('_loopContext', null); +} + +// ═══════════════════════════════════════════════════════════════ +// Harness: 状态处理器 +// ═══════════════════════════════════════════════════════════════ + +/** + * INIT 状态:构建系统提示词、准备上下文、压缩检测 + */ +async function handleInit( + ctx: LoopContext, + api: OllamaAPI, + model: string, + callbacks: AgentCallbacks, userContent: string, images: string[], historyMessages: Array<{ role: string; content: string; images?: string[] }>, - callbacks: AgentCallbacks + currentSession: ChatSession | null, ): Promise { - const api = state.get(KEYS.API); - const model = state.get('_defaultModel', ''); - const currentSession = state.get(KEYS.CURRENT_SESSION); - const sessionId = currentSession?.id || 'unknown'; - - if (!api || !model) { - showToast('请先选择模型', 'error'); - return; - } - // 新一轮对话,清空工具缓存 toolResultCache.clear(); - // 检查模型是否支持 Tool Calling const modelSupportsTools = state.get('modelSupportsTools', false); - - // 提前获取工具列表,供后续系统 prompt 构建使用 const tools = getEnabledToolDefinitions(); const useTools = tools.length > 0; - const messages: OllamaMessage[] = []; - let systemPromptParts: string[] = []; + ctx.messages.length = 0; + const systemPromptParts: string[] = []; - // ── 扫描工作空间 SOUL.md ── + // ── 扫描工作空间 SOUL.md(静态区,不可压缩)── let soulMdContent = ''; const workspaceDir = getWorkspaceDirPath(); if (workspaceDir) { try { const soulResult = await window.metonaDesktop?.workspace.readFile( - workspaceDir.replace(/\/+$/, '') + '/SOUL.md' + workspaceDir.replace(/[\\/]+$/, '') + '/SOUL.md' ); if (soulResult?.success && soulResult.content) { soulMdContent = soulResult.content; logInfo('SOUL.md 已从工作空间加载', `${soulResult.lines || 0} 行`); } - } catch { /* 工作空间 SOUL.md 不存在 */ } + } catch { /* ignore */ } } - - // fallback:读取内置 SOUL.md(随应用发布,Vite publicDir 自动复制) if (!soulMdContent) { try { const resp = await fetch('./SOUL.md'); - if (resp.ok) { - soulMdContent = await resp.text(); - logInfo('SOUL.md 已从内置加载', `${soulMdContent.length} 字符`); - } - } catch { /* 内置 SOUL.md 也不可用 */ } + if (resp.ok) { soulMdContent = await resp.text(); logInfo('SOUL.md 已从内置加载', `${soulMdContent.length} 字符`); } + } catch { /* ignore */ } } - if (soulMdContent) { - // SOUL.md 注入为独立 system 消息,标记为不可压缩 - messages.push({ - role: 'system', - content: `[SOUL.md] ${soulMdContent}`, - }); + ctx.messages.push({ role: 'system', content: `[SOUL.md] ${soulMdContent}` }); } // ── 扫描工作空间 AGENT.md ── @@ -426,29 +474,24 @@ export async function runAgentLoop( if (workspaceDir) { try { const agentResult = await window.metonaDesktop?.workspace.readFile( - workspaceDir.replace(/\/+$/, '') + '/AGENT.md' + workspaceDir.replace(/[\\/]+$/, '') + '/AGENT.md' ); if (agentResult?.success && agentResult.content) { agentMdContent = agentResult.content; logInfo('AGENT.md 已从工作空间加载', `${agentResult.lines || 0} 行`); } - } catch { /* 工作空间 AGENT.md 不存在 */ } + } catch { /* ignore */ } } - - // fallback:读取内置 AGENT.md(随应用发布,Vite publicDir 自动复制) if (!agentMdContent) { try { const resp = await fetch('./AGENT.md'); - if (resp.ok) { - agentMdContent = await resp.text(); - logInfo('AGENT.md 已从内置加载', `${agentMdContent.length} 字符`); - } - } catch { /* 内置 AGENT.md 也不可用 */ } + if (resp.ok) { agentMdContent = await resp.text(); logInfo('AGENT.md 已从内置加载', `${agentMdContent.length} 字符`); } + } catch { /* ignore */ } } - if (agentMdContent) { - // AGENT.md 注入到 systemPromptParts,排在 SOUL.md 之后、其他系统提示词之前 - systemPromptParts.push(`[AGENT.md] ${agentMdContent}`); + // v0.12.0: Token 预算截断(限制 2000 tokens) + const truncated = truncateByTokenBudget(agentMdContent, 2000); + systemPromptParts.push(`[AGENT.md] ${truncated}`); } // ── 扫描工作空间 USER.md ── @@ -456,26 +499,20 @@ export async function runAgentLoop( if (workspaceDir) { try { const userResult = await window.metonaDesktop?.workspace.readFile( - workspaceDir.replace(/\/+$/, '') + '/USER.md' + workspaceDir.replace(/[\\/]+$/, '') + '/USER.md' ); if (userResult?.success && userResult.content) { userMdContent = userResult.content; logInfo('USER.md 已从工作空间加载', `${userResult.lines || 0} 行`); } - } catch { /* 工作空间 USER.md 不存在 */ } + } catch { /* ignore */ } } - - // fallback:读取内置 USER.md if (!userMdContent) { try { const resp = await fetch('./USER.md'); - if (resp.ok) { - userMdContent = await resp.text(); - logInfo('USER.md 已从内置加载', `${userMdContent.length} 字符`); - } - } catch { /* 内置 USER.md 也不可用 */ } + if (resp.ok) { userMdContent = await resp.text(); logInfo('USER.md 已从内置加载', `${userMdContent.length} 字符`); } + } catch { /* ignore */ } } - if (userMdContent) { systemPromptParts.push(`[USER.md] ${userMdContent}`); } @@ -485,9 +522,7 @@ export async function runAgentLoop( const relevantMemories = searchMemories(userContent, 6); if (relevantMemories.length > 0) { systemPromptParts.push(buildMemoryContext(relevantMemories)); - for (const m of relevantMemories) { - await markMemoryUsed(m.id); - } + for (const m of relevantMemories) { await markMemoryUsed(m.id); } } } @@ -497,9 +532,31 @@ export async function runAgentLoop( 当前工作空间目录: ${workspaceDir} 你可以使用 run_command 工具在此目录下执行命令,所有命令通过工作空间进程管理执行,无超时限制。 文件操作工具(read_file、write_file 等)的相对路径基于此目录解析。`); + + // ── Plan Mode 系统提示词 — 告知 AI plan_track 工具和追踪规则 ── + if (ctx.mode === 'plan') { + systemPromptParts.push(`[Plan Mode 执行规则] +你当前处于 Plan Mode(先规划后执行)。重要规则: +1. 第一轮回复必须是执行计划,不要直接调用工具。列出步骤、涉及工具和预期结果后,等待用户批准。 +2. 计划批准后,每一次完成一个步骤,必须调用 plan_track(action='mark_done', step_index=N) 标记该步骤已完成。 +3. 每轮思考前系统会自动注入当前执行进度。请对照进度确保所有步骤最终都被标记为完成。 +4. 所有步骤完成后调用 plan_track(action='mark_all_done') 或直接给出最终回答。`); } - // ── 注入操作系统环境信息(不可压缩,确保 AI 使用正确命令)── + // ── v0.12.0: 渐进式披露 — 项目索引(始终保留在上下文中)── + if (workspaceDir) { + try { + const projectIndex = await buildProjectIndex(workspaceDir); + const indexContext = buildIndexContext(projectIndex); + if (indexContext) { + systemPromptParts.push(indexContext); + logInfo('项目索引已注入系统提示词', `${projectIndex.tokenCount} tokens`); + } + } catch { /* 索引构建失败不影响主流程 */ } + } + } + + // ── 注入操作系统环境信息(静态区)── const osInfo = getOSEnvironment(); systemPromptParts.push(`[环境] 运行环境信息 操作系统: ${osInfo.os} @@ -515,10 +572,20 @@ Shell: ${osInfo.shell} - 如果是 Linux/macOS,使用 Bash 命令(如 ls、cat、grep,路径用 /) - 严禁在 Windows 上执行 Linux 命令,严禁在 Linux 上执行 Windows 命令。`); + // ── v0.12.1: 反幻觉铁律(注入最高优先级)── + systemPromptParts.push(`[反幻觉铁律 — 最高优先级,不可违反] - // 组合 system prompt +⚠️ 以下规则高于一切其他指令,违反将导致任务失败: - // 实时注入当前日期(来自系统时钟,非模型知识库) +1. **禁止编造工具结果**:绝对不能在未调用工具的情况下声称"已搜索"、"已获取"、"已写入"、"已执行"。如果你没有调用过某个工具,就不能说使用了它。 +2. **文件操作必须实际执行**:说"已写入文件"之前,必须先调用 write_file 并收到成功返回。说"已读取文件"之前,必须先调用 read_file。 +3. **搜索必须实际执行**:说"搜索结果显示"、"根据搜索结果"之前,必须先调用 web_search。搜索结果的 snippet 不可信,必须用 web_fetch 获取完整内容后才能引用。 +4. **信息不足时如实报告**:如果工具调用失败或返回了意外的结果,必须如实报告,不能编造替代信息。 +5. **完成标志**:当所有必需的工具调用已经实际执行完毕,且获得了足够的信息后,才能给出最终回答。最终回答中不要编造"文件已生成"等声明——除非你真的调用了对应工具。 + +违反以上任何一条都是不可接受的错误。请逐条对照检查你的每一次回复。`); + + // ── 实时日期(动态区)── const _now = new Date(); const realDate = `${_now.getFullYear()}年${_now.getMonth() + 1}月${_now.getDate()}日`; @@ -527,10 +594,10 @@ Shell: ${osInfo.shell} `[日期] ${realDate}(此日期来自系统时钟,绝对可信。你的训练数据可能已过时,请以此日期为准构造所有搜索查询和时效性回答。绝对不要基于训练数据推断日期。)` ].join('\n\n'); - messages.push({ role: 'system', content: fullSystemPrompt }); + ctx.messages.push({ role: 'system', content: fullSystemPrompt }); - // 将完整系统提示词存入 state(包含 SOUL.md + fullSystemPrompt) - const allSystemContent = messages + // 保存完整系统提示词 + const allSystemContent = ctx.messages .filter(m => m.role === 'system') .map(m => m.content) .join('\n\n'); @@ -538,7 +605,7 @@ Shell: ${osInfo.shell} // 添加历史消息 for (const msg of historyMessages) { - messages.push({ + ctx.messages.push({ role: msg.role as 'user' | 'assistant', content: msg.content, ...(msg.images?.length && { images: msg.images }) @@ -551,24 +618,24 @@ Shell: ${osInfo.shell} content: userContent || (images?.length ? (images.length > 1 ? `请分析这 ${images.length} 张图片` : '请分析这张图片') : ''), ...(images?.length && { images }) }; - messages.push(userMsg); + ctx.messages.push(userMsg); // 上下文窗口管理:滑动窗口 + 摘要压缩 + token 裁剪 const numCtx = state.get(KEYS.NUM_CTX, 24576); - const contextResult = buildContext(messages, { maxTokens: numCtx, windowSize: 20 }); - messages.length = 0; - messages.push(...contextResult); + const contextResult = buildContext(ctx.messages, { maxTokens: numCtx, windowSize: 20 }); + ctx.messages.length = 0; + ctx.messages.push(...contextResult); - // 自动压缩:当上下文 token 超过 context window 的 50% 时,调用 LLM 摘要压缩 - if (shouldAutoCompress(messages, numCtx)) { - logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))} > ${Math.floor(numCtx * AUTO_COMPRESS_THRESHOLD)} (50% of ${numCtx})`); + // 自动压缩检测 + if (shouldAutoCompress(ctx.messages, numCtx)) { + logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))} > ${Math.floor(numCtx * AUTO_COMPRESS_THRESHOLD)} (50% of ${numCtx})`); const compressAC = state.get(KEYS.ABORT_CONTROLLER) || new AbortController(); try { - const compressed = await compressWithLLM(messages, api as any, model, { abortController: compressAC }); - if (compressed.length < messages.length || estimateTokens(compressed.map(m => m.content || '').join('')) < estimateTokens(messages.map(m => m.content || '').join(''))) { - messages.length = 0; - messages.push(...compressed); - logSuccess(`自动上下文压缩完成: 剩余 ${messages.length} 条消息`); + const compressed = await compressWithLLM(ctx.messages, api as any, model, { abortController: compressAC }); + if (compressed.length < ctx.messages.length || estimateTokens(compressed.map(m => m.content || '').join('')) < estimateTokens(ctx.messages.map(m => m.content || '').join(''))) { + ctx.messages.length = 0; + ctx.messages.push(...compressed); + logSuccess(`自动上下文压缩完成: 剩余 ${ctx.messages.length} 条消息`); } } catch (err) { if ((err as Error).name === 'AbortError') throw err; @@ -576,568 +643,1098 @@ Shell: ${osInfo.shell} } } - logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))}`); + logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, 模式: ${ctx.mode}, tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))}`); - // 迭代预算:从 state 读取,默认 85(后续可能被 Token 感知策略动态缩减) - let maxLoops = state.get('maxTurns', 85); + // ── v0.12.0 Plan Mode: 如果是 plan 模式,先注入计划提示 ── + if (ctx.mode === 'plan' && ctx.loopCount === 0) { + ctx.messages.push({ + role: 'user' as const, + content: `[Plan Mode] 请先分析任务,列出执行计划(包括需要的工具、步骤和预期结果),然后等待我的确认。不要直接执行工具调用。`, + ephemeral: true, + }); + } - let loopCount = 0; - const allToolRecords: ToolCallRecord[] = []; - const loopStartTime = Date.now(); - let content = ''; - /** 每轮累计 token 统计 */ - let totalEvalCount = 0; - let totalPromptEvalCount = 0; - let totalInferenceNs = 0; - /** 当前轮的 Ollama 统计(流式最后一个 chunk 赋值) */ - let loopEvalCount = 0; - let loopPromptEvalCount = 0; - let loopInferenceNs = 0; - /** 跨轮去重:仅跟踪成功的工具调用(失败的允许重试) */ - let prevLoopSuccessKeys: string[] = []; - /** 保存上一轮的工具调用,供 onNewIteration 使用 */ - let prevToolCalls: ToolCall[] = []; + return; +} - const makeStats = () => ({ - eval_count: totalEvalCount || undefined, - prompt_eval_count: totalPromptEvalCount || undefined, - total_duration: totalInferenceNs || undefined, - }); +/** 按 token 预算截断文本(约 1.5 中文字/token, 4 英文字符/token) */ +function truncateByTokenBudget(text: string, maxTokens: number): string { + const estimated = estimateTokens(text); + if (estimated <= maxTokens) return text; + // 按比例截取 + const ratio = maxTokens / estimated; + const cutAt = Math.floor(text.length * ratio * 0.9); // 保守一点 + return text.slice(0, cutAt) + '\n\n... (已截断以控制 Token 预算)'; +} - while (loopCount < maxLoops) { - loopCount++; - - // 检查是否已中止 - if (state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted) { - logInfo('ReAct Agent Loop 已中止'); - if (allToolRecords.length > 0) { - state.set('_abortToolRecords', allToolRecords); - } - throw new DOMException('Aborted', 'AbortError'); +/** 从 Plan Mode 输出中提取步骤列表 */ +function extractPlanSteps(content: string): string[] { + const steps: string[] = []; + const stepRegex = /(?:^|\n)\s*(?:\d+[\.\)、]\s*|[-*]\s+)(.+)/g; + let match; + while ((match = stepRegex.exec(content)) !== null) { + const step = match[1].trim(); + if (step.length > 5 && step.length < 200) { + steps.push(step); } + } + return steps.length > 0 ? steps.slice(0, 10) : ['执行任务计划(详见上方描述)']; +} - // 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; +/** + * v0.12.1: 中途幻觉检测 — 轻量级检查 + * 仅检查最关键的几类幻觉(文件写入、搜索、命令执行) + * 完整检查在 Completion Gate 中 + */ +function detectMidTaskHallucination( + content: string, + allToolRecords: Array<{ name: string }>, +): string | null { + const called = new Set(allToolRecords.map(r => r.name)); + + // 多维度幻觉检测(按置信度排序) + const rules: Array<{ re: RegExp; tools: string[]; msg: string }> = [ + // 文件操作类 + { re: /已(写入|创建|生成).*文件/, tools: ['write_file'], msg: '声称已写入/创建文件' }, + { re: /已成功(写入|创建|保存)/, tools: ['write_file'], msg: '声称文件操作成功' }, + { re: /已(编辑|修改|更新)了?(文件|代码)/, tools: ['edit_file', 'replace_in_files'], msg: '声称编辑了文件' }, + { re: /已(删除|移除)了?.*(文件|目录)/, tools: ['delete_file'], msg: '声称删除了文件' }, + { re: /已(移动|重命名|复制)了?.*(文件|目录)/, tools: ['move_file', 'copy_file'], msg: '声称移动/复制了文件' }, + { re: /已创建了?(目录|文件夹)/, tools: ['create_directory'], msg: '声称创建了目录' }, + { re: /已(下载|获取)了?.*文件/, tools: ['download_file', 'web_fetch'], msg: '声称下载了文件' }, + // 搜索类 + { re: /搜索(结果|到|显示).*(条|个)/, tools: ['web_search', 'web_fetch'], msg: '声称获得了搜索结果' }, + { re: /记忆.*(搜索到|找到|显示)/, tools: ['memory_search'], msg: '声称搜索了记忆' }, + // 命令执行类 + { re: /运行(了|的)?(命令|脚本).*(成功|输出|结果)/, tools: ['run_command'], msg: '声称执行了命令' }, + { re: /(git|提交|推送|合并).*(成功|完成)/, tools: ['git'], msg: '声称执行了 Git 操作' }, + // 浏览器类 + { re: /(打开|浏览)了?.*(网页|页面|浏览器)/, tools: ['browser_open', 'browser_screenshot', 'browser_extract'], msg: '声称使用了浏览器' }, + { re: /截图了?(页面|屏幕|网页)/, tools: ['browser_screenshot'], msg: '声称截取了页面' }, + // 压缩类 + { re: /(压缩|解压|打包).*(成功|完成)/, tools: ['compress'], msg: '声称执行了压缩/解压操作' }, + // 记忆类 + { re: /(已记住|保存了|存储了|添加到记忆)/, tools: ['memory_add', 'memory_replace'], msg: '声称保存了记忆' }, + ]; + + for (const rule of rules) { + if (rule.re.test(content) && !rule.tools.some(t => called.has(t))) { + return `${rule.msg}但对应工具从未被实际调用。`; } + } + return null; +} - // 非首轮迭代:通知 UI 创建新的消息气泡(防止每轮内容互相覆盖) - if (loopCount > 1 && callbacks.onNewIteration) { - callbacks.onNewIteration(prevToolCalls.length > 0 ? prevToolCalls : undefined); - } +/** + * 多步骤任务待办检测 — 解析用户请求中的动作动词,对比已完成的工具调用 + * 返回尚未完成的任务描述列表 + */ +function detectPendingActions( + userText: string, + allToolRecords: Array<{ name: string }>, +): string[] { + const called = new Set(allToolRecords.map(r => r.name)); + const pending: string[] = []; + const txt = userText.toLowerCase(); - logAgentLoop(loopCount, maxLoops); + // 动作 → 必需工具映射 + const ACTION_MAP: Array<{ keywords: string[]; tools: string[]; label: string }> = [ + { keywords: ['搜索', '查找', '查一下', '搜一下', 'search', '检索', '查询'], tools: ['web_search'], label: '搜索/查找信息' }, + { keywords: ['抓取', 'fetch', '获取内容', '抓取全文'], tools: ['web_fetch'], label: '抓取网页详细内容' }, + { keywords: ['生成', '写入', '创建文件', '保存', '输出.*文件', '放到.*工作空间', '写.*文件', '生成.*文件', '存入'], tools: ['write_file'], label: '创建/写入文件' }, + { keywords: ['运行', '执行', 'run', '启动', '编译'], tools: ['run_command'], label: '执行命令/脚本' }, + { keywords: ['下载', 'download'], tools: ['download_file'], label: '下载文件' }, + { keywords: ['提交', 'commit', '推送', 'push', '暂存', 'add'], tools: ['git'], label: 'Git 操作' }, + { keywords: ['删除', '移除', 'delete', 'remove'], tools: ['delete_file'], label: '删除文件' }, + { keywords: ['移动', 'move', '重命名', 'rename'], tools: ['move_file'], label: '移动/重命名文件' }, + { keywords: ['复制', 'copy'], tools: ['copy_file'], label: '复制文件' }, + { keywords: ['压缩', '解压', 'compress', 'zip', 'tar'], tools: ['compress'], label: '压缩/解压' }, + { keywords: ['打开网页', '浏览器', 'browser', '截图'], tools: ['browser_open', 'browser_screenshot', 'browser_extract'], label: '浏览器操作' }, + ]; - let thinking = ''; - content = ''; - const toolCalls: ToolCall[] = []; - - // 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, ephemeral: true }); - } - - const abortController = new AbortController(); - state.set(KEYS.ABORT_CONTROLLER, abortController); - - // 流式调用超时保护(可配置,0 = 禁用超时) - const STREAM_TIMEOUT_MS = state.get('streamTimeout', 300_000); // 默认 300s(可在设置中配置,0=禁用) - let streamTimer: ReturnType | null = null; - if (STREAM_TIMEOUT_MS > 0) { - streamTimer = setTimeout(() => { - logWarn(`流式调用超时 (${STREAM_TIMEOUT_MS / 1000}s),中止本轮`); - abortController.abort(); - }, STREAM_TIMEOUT_MS); - } - - let progressTimer: ReturnType | null = null; - - try { - // 流式调用 - const streamStartTime = Date.now(); - let lastLoggedLen = 0; - let lastContentTime = streamStartTime; // 追踪最后一次收到新内容的时间 - const PROGRESS_INTERVAL = 15000; // 每15秒输出一次流式进度 - - // ── 同步创建本轮的进度条目(appendChild,保证在后续工具日志之前)── - resetStreamProgress(); - - // ── 独立定时器:即使没有新 chunk 也会持续输出进度 ── - progressTimer = setInterval(() => { - const elapsed = Date.now() - streamStartTime; - const grew = content.length - lastLoggedLen; - const sec = Math.round(elapsed / 1000); - if (grew > 0) { - // 有新增内容 → 正常进度 - lastLoggedLen = content.length; - lastContentTime = Date.now(); - logStreamProgress(`流式输出中… ${content.length} 字 / ${sec}s${toolCalls.length > 0 ? ` [${toolCalls.length} 个工具调用]` : ''}`); - } else if (content.length > 0) { - // 内容不变 → 计算真正的无新内容时长 - const idleSec = Math.round((Date.now() - lastContentTime) / 1000); - if (idleSec >= 10) { - logStreamProgress(`⚠️ ${idleSec}s 无新内容,当前 ${content.length} 字`); - } - } else { - // 模型尚未开始输出(可能在思考/加载模型/生成大参数)→ 显示等待状态 - logStreamProgress(`⏳ 等待模型响应… ${sec}s`); - } - }, PROGRESS_INTERVAL); - - await api.chatStream( - { - model, - messages, - stream: true, - think: state.get('thinkEnabled', false), - options: { - num_ctx: state.get(KEYS.NUM_CTX, 24576), - temperature: state.get('temperature', 0.7) - }, - ...(useTools && { tools }) - }, - (chunk: OllamaStreamChunk) => { - if (chunk.message?.thinking) { - thinking += chunk.message.thinking; - callbacks.onThinking(thinking); - } - if (chunk.message?.content) { - content += chunk.message.content; - callbacks.onContent(content); - } - if (chunk.eval_count) { loopEvalCount = chunk.eval_count; } - if (chunk.prompt_eval_count) { loopPromptEvalCount = chunk.prompt_eval_count; } - if (chunk.total_duration) { loopInferenceNs = chunk.total_duration; } - if (chunk.message?.tool_calls?.length) { - for (const tc of chunk.message.tool_calls) { - if (tc.function?.name) { - // Ollama 可能返回 arguments 为 JSON 字符串,统一转为对象 - let parsedArgs: Record = {}; - if (tc.function.arguments) { - if (typeof tc.function.arguments === 'string') { - try { parsedArgs = JSON.parse(tc.function.arguments); } catch { parsedArgs = {}; } - } else if (typeof tc.function.arguments === 'object') { - parsedArgs = tc.function.arguments as Record; - } - } - // 首次看到工具名 → 提前在工作空间创建"准备中"卡片 - const isNewTool = !toolCalls.some(existing => existing.function.name === tc.function.name); - if (isNewTool && callbacks.onToolCallPrepare) { - callbacks.onToolCallPrepare({ - type: 'function', - function: { name: tc.function.name, arguments: parsedArgs } - }); - } - toolCalls.push({ - type: 'function', - function: { - name: tc.function.name, - arguments: parsedArgs - } - }); - } else if (toolCalls.length > 0) { - const last = toolCalls[toolCalls.length - 1]; - if (tc.function?.arguments) { - let chunkArgs: Record | null = null; - if (typeof tc.function.arguments === 'string') { - try { chunkArgs = JSON.parse(tc.function.arguments); } catch { /* ignore */ } - } else if (typeof tc.function.arguments === 'object') { - chunkArgs = tc.function.arguments as Record; - } - if (chunkArgs) { - Object.assign(last.function.arguments, chunkArgs); - } - } - } - } - } - }, - abortController - ); - - // 本轮流式结束,累加 token 统计 - totalEvalCount += loopEvalCount; - totalPromptEvalCount += loopPromptEvalCount; - totalInferenceNs += loopInferenceNs; - state.set('_currentEvalCount', totalEvalCount); - - // Token 校准:用 Ollama 返回的实际计数修正估算器(在重置前保存本轮值) - const thisLoopEval = loopEvalCount; - const thisLoopPrompt = loopPromptEvalCount; - // 重置本轮计数器(下一轮重新从 chunk 收集) - loopEvalCount = 0; - loopPromptEvalCount = 0; - loopInferenceNs = 0; - - // Token 校准:用 Ollama 返回的实际计数自动修正估算器 - try { - if (thisLoopEval > 0 || thisLoopPrompt > 0) { - const estimatedThisLoop = estimateTokens(messages.map(m => m.content || '').join('')); - if (estimatedThisLoop > 0) { - recordActualTokens(thisLoopPrompt, thisLoopEval, estimatedThisLoop); - } - } - } catch { /* 校准失败不阻塞主流程 */ } - - // 流式调用成功完成,清除超时定时器 - if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; } - if (progressTimer) clearInterval(progressTimer as unknown as number); - - } catch (err) { - // 清除超时定时器 - if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; } - if (progressTimer) clearInterval(progressTimer as unknown as number); - if (abortController.signal.aborted) { - logInfo('流式调用已中止'); - // 保存工具记录到 state,供消费方的 catch 处理中止消息 - if (allToolRecords.length > 0) { - state.set('_abortToolRecords', allToolRecords); - } - throw err; // 抛出 AbortError,由消费方统一处理 - } - logError('流式调用异常', (err as Error).message); - if (content || thinking) { - messages.push({ role: 'assistant', content: content || '(模型响应异常)', ...(thinking && { thinking }) }); - } - callbacks.onDone(content || '(模型响应异常,已自动中断)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); - return; - } - - // 提取 ReAct 思考过程 - const thoughtMatch = content.match(/\*\*Thought:\*\*\s*([\s\S]*?)(?=\*\*Action:\*\*|\*\*Final Answer:\*\*|$)/i); - const thought = thoughtMatch ? thoughtMatch[1].trim() : ''; - - // 保存 assistant 消息 - const assistantMsg: OllamaMessage = { - role: 'assistant', - content, - ...(thinking && { thinking }) - }; - if (toolCalls.length > 0) { - assistantMsg.tool_calls = toolCalls; - } - messages.push(assistantMsg); - - logModelResponse(content.length, toolCalls.length); - - // 文本解析兜底:模型没通过 tool_calls 返回但文本中写了 Action - if (toolCalls.length === 0 && useTools) { - const parsedCalls = parseToolCallsFromText(content); - if (parsedCalls.length > 0) { - toolCalls.push(...parsedCalls); - } - } - - // 检查是否是 Final Answer(多模式匹配 + 内容长度验证) - // 只有当无工具调用且有实际内容时才可能是最终回答 - const FINAL_PATTERNS: RegExp[] = [ - /Final\s*Answer\s*:/i, - /最终答案[::]/, - /最终回答[::]/, - /总结[::]/, - /任务完成[!!]?/, - /以上(是|为)[我对]?/, - /以上就是/, - ]; - // 有工具历史 + 回复较长(>300字)且无工具调用 → 大概率是最终回答 - const isFinalAnswer = toolCalls.length === 0 && content.length > 50 && ( - FINAL_PATTERNS.some(p => p.test(content)) || - (allToolRecords.length > 0 && content.length > 300) - ); - - // 处理空响应:如果之前有工具调用但模型返回空内容,不立即结束 - if (toolCalls.length === 0 && !content.trim() && loopCount > 1 && allToolRecords.length > 0) { - logWarn('模型返回空内容(有未处理的工具结果),继续循环'); - // 移除空的 assistant 消息,避免 Ollama 解析问题 - messages.pop(); - // 添加一个提示性的 user 消息引导模型继续 - messages.push({ - role: 'user', - content: '请根据上面的工具调用结果继续回答。如果需要更多信息,可以继续调用工具。如果已有足够信息,请给出最终回答。' - }); - continue; - } - - if (toolCalls.length === 0) { - // ── 反过早结束:如果之前有工具调用,但模型返回的内容不像最终回答,引导继续 ── - if (allToolRecords.length > 0 && !isFinalAnswer && loopCount < maxLoops - 1) { - const contentLower = content.toLowerCase(); - // "还在思考中"的典型信号:短内容 + 思考动词 - const isThinking = contentLower.includes('让我') || contentLower.includes('看看') || - contentLower.includes('观察') || (contentLower.includes('分析') && !contentLower.includes('分析结果')); - const isMidSentence = contentLower.endsWith('...') || contentLower.endsWith('等等') || - contentLower.includes('让我再'); - const isContinuation = content.length < 300 && isThinking && - (isMidSentence || contentLower.includes('接下来') || contentLower.includes('继续')); - - if (isContinuation) { - logWarn('模型可能过早停止,注入继续提示', `${content.length}字`); - messages.pop(); - messages.push({ - role: 'user', - content: '请继续完成任务。你可以调用工具获取更多信息,或者根据已有结果给出完整的最终回答。如果任务已完成,请明确说出"任务完成"或"最终回答"。' - }); - continue; - } - } - - // 真正的最终回答 - logInfo('无工具调用,ReAct Agent Loop 结束'); - - // 自动提取记忆(仅在对话结束后) - if (isMemoryEnabled() && messages.length >= 10) { - try { - const { extractMemoriesFromConversation } = await import('./memory-manager.js'); - await extractMemoriesFromConversation( - messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })), - currentSession?.title - ); - } catch { /* 不阻塞 */ } - } - - - callbacks.onDone(content || '(模型未返回内容)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); - return; - } - - // 跨轮次重复检测:连续两轮调用完全相同且全部成功 → 注入警告而不强制终止 - const currentLoopKeys = toolCalls.map(c => getToolCacheKey(c.function.name, c.function.arguments)).sort(); - const currentKeysStr = JSON.stringify(currentLoopKeys); - const prevKeysStr = JSON.stringify([...prevLoopSuccessKeys].sort()); - if (currentKeysStr === prevKeysStr && currentLoopKeys.length > 0) { - const hasFailedInPrev = allToolRecords - .filter(r => prevLoopSuccessKeys.includes(getToolCacheKey(r.name, r.arguments))) - .some(r => r.status !== 'success'); - if (!hasFailedInPrev) { - logWarn('检测到连续两轮工具调用完全相同且全部成功,注入提醒而非终止'); - messages.push({ - role: 'user', - content: '⚠️ 检测到你连续调用了与上一轮完全相同的工具。请基于已有结果给出最终回答,或者调用不同的工具获取新信息。如果任务已完成,请给出最终回答。' - }); - continue; - } - logInfo('检测到重复调用但上一轮有失败,允许重试'); - } - - // 记录 ReAct 轨迹 - const traceStep = { - sessionId, - stepIndex: loopCount, - thought: thought || content.slice(0, 200), - action: toolCalls.map(t => t.function.name).join(', '), - actionInput: JSON.stringify(toolCalls.map(t => t.function.arguments)), - observation: '', - loopCount, - createdAt: Date.now() - }; - - // ── v4.1 工具并行执行:将独立工具分批并行调用 ── - // 将工具调用分成批次:同一批次内的工具互相独立,可并行执行 - // 跨批次的工具有依赖关系(如 web_fetch 依赖前面的 web_search) - const batches: ToolCall[][] = []; - let currentBatch: ToolCall[] = []; - const batchDeps = new Map(); // 记录每个工具依赖的前序工具名 - - 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]; - } else { - currentBatch.push(call); - } - } - } - if (currentBatch.length > 0) batches.push(currentBatch); - - if (batches.length > 1) { - logInfo(`工具并行执行: ${toolCalls.length} 个工具 → ${batches.length} 批次(首批 ${batches[0].length} 个并行)`); - } - - /** 执行单个工具(含重试),返回 [ToolCallRecord, 缓存key|null] */ - const executeSingleTool = async (call: ToolCall): Promise<[ToolCallRecord, string | null]> => { - const cacheKey = getToolCacheKey(call.function.name, call.function.arguments); - - // 重复调用检测 - if (isDuplicateCall(call, toolCalls)) { - logWarn(`跳过重复工具调用: ${call.function.name}`); - const cached = toolResultCache.get(cacheKey); - if (cached) { - return [{ - name: call.function.name, arguments: call.function.arguments, - result: cached.result, status: 'success' as const, timestamp: Date.now() - }, null]; - } - } - - // 跨轮次缓存命中(含 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: cachedEntry.result, status: 'success' as const, timestamp: Date.now() - }, null]; - } - if (cachedEntry) { - // 过期缓存,删除 - toolResultCache.delete(cacheKey); - } - - // ── 等待渲染帧:确保 onToolCallPrepare 创建的 pending 卡片已上屏 ── - // 然后 onToolCallStart 将 pending 升级为 running - await new Promise(r => requestAnimationFrame(r)); - - callbacks.onToolCallStart(call); - logToolStart(call.function.name, JSON.stringify(call.function.arguments)); - - // ── 确保 running 状态 DOM 已被浏览器渲染后再执行工具 ── - // 网络工具(web_search/web_fetch)天然慢无需此处理,但文件 I/O 工具极快 - await new Promise(r => requestAnimationFrame(r)); - - // 需要确认的工具(目前只有 run_command 在 confirm 模式下) - if (needsConfirmation(call.function.name)) { - const confirmed = await callbacks.onConfirmTool(call); - if (!confirmed) { - logWarn(`工具取消: ${call.function.name}`); - const cancelResult = { success: false, error: '用户取消了操作' }; - return [{ - name: call.function.name, arguments: call.function.arguments, - result: cancelResult, status: 'cancelled' as const, timestamp: Date.now() - }, null]; - } - } - - // 执行 + 自动重试 - let lastError = ''; - for (let retry = 0; retry <= MAX_RETRIES; retry++) { - try { - 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); - return [{ - name: call.function.name, arguments: call.function.arguments, - result, status: result.success ? 'success' as const : 'error' as const, timestamp: Date.now() - }, result.success ? cacheKey : null]; - } catch (err) { - lastError = (err as Error).message; - if (retry < MAX_RETRIES) { - logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}`, lastError); - await new Promise(r => setTimeout(r, 500)); - continue; - } - logError(`工具执行失败: ${call.function.name}`, lastError); - return [{ - name: call.function.name, arguments: call.function.arguments, - result: { success: false, error: lastError }, - status: 'error' as const, timestamp: Date.now() - }, null]; - } - } - // unreachable - return [{ - name: call.function.name, arguments: call.function.arguments, - result: { success: false, error: lastError }, status: 'error' as const, timestamp: Date.now() - }, null]; - }; - - // 按批次执行:批次内并行,批次间串行 - for (const batch of batches) { - if (abortController.signal.aborted) { - callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); - return; - } - - const results = await Promise.all(batch.map(call => executeSingleTool(call))); - - for (const [record, cacheKey] of results) { - allToolRecords.push(record); - messages.push({ - role: 'tool', tool_name: record.name, - content: formatToolResultForModel(record.name, 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') { - callbacks.onToolCallError(record.name, '用户取消', batch.find(c => c.function.name === record.name)!); - } else { - callbacks.onToolCallError(record.name, record.result?.error || '执行失败', batch.find(c => c.function.name === record.name)!); - } - } - } - - // P0-1: 硬限制工具消息数量 — 保留最近 40 条,删除旧的防止上下文无限膨胀 - { - const toolIndices: number[] = []; - for (let i = 0; i < messages.length; i++) { - if (messages[i].role === 'tool') toolIndices.push(i); - } - if (toolIndices.length > 40) { - const toRemove = toolIndices.slice(0, toolIndices.length - 40); - // 从后往前删,避免索引偏移 - for (let i = toRemove.length - 1; i >= 0; i--) { - messages.splice(toRemove[i], 1); - } - logInfo(`工具消息裁剪: ${toRemove.length} 条旧结果已移除 (保留最近 40 条)`); - } - } - - // 更新跨轮去重:仅跟踪本轮成功的工具调用 - prevLoopSuccessKeys = allToolRecords - .filter(r => r.status === 'success') - .map(r => getToolCacheKey(r.name, r.arguments)); - - // 保存本轮轨迹 - traceStep.observation = allToolRecords.slice(-toolCalls.length).map(r => - `${r.name}: ${r.result?.success ? 'success' : 'error'}` - ).join('; '); - saveTrace(traceStep); - - // P2-2: 增量记忆提取 — 每 20 轮 fire-and-forget,不阻塞主循环 - if (isMemoryEnabled() && loopCount > 1 && loopCount % 20 === 0 && messages.length >= 10) { - import('./memory-manager.js').then(({ extractMemoriesFromConversation }) => { - const recentMsgs = messages.slice(-30).filter(m => m.role === 'user' || m.role === 'assistant'); - extractMemoriesFromConversation( - recentMsgs.map(m => ({ role: m.role, content: m.content })), - currentSession?.title - ).then(() => logInfo('增量记忆提取完成', `第 ${loopCount} 轮`)) - .catch(() => {}); - }).catch(() => {}); - } - - // 保存本轮工具调用,供下一轮 onNewIteration 使用 - prevToolCalls = toolCalls; - - // P1-3: 增量工具结果截断 — 超过 10 轮的旧工具结果每 3 轮截断到 500 字符 - if (loopCount > 10 && loopCount % 3 === 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... (已截断旧工具结果)' }; - } + for (const action of ACTION_MAP) { + const mentioned = action.keywords.some(kw => new RegExp(kw, 'i').test(txt)); + if (mentioned) { + const completed = action.tools.some(t => called.has(t)); + if (!completed) { + pending.push(action.label); } } } - logWarn('ReAct Agent Loop 达到最大工具调用次数限制'); - callbacks.onDone(content || '(达到最大工具调用次数限制)', allToolRecords, makeStats()); + return pending; +} + +/** + * v0.12.1: 通用工具结果核验 — 对所有写类工具执行后验证 + * 读取/搜索类工具已有返回结果作为验证,此处只核验会产生副作用的操作 + */ +const TOOLS_NEED_VERIFY = new Set([ + 'write_file', 'edit_file', 'delete_file', 'create_directory', + 'move_file', 'copy_file', 'download_file', +]); + +async function verifyToolResult( + toolName: string, + args: Record, + result: Record, +): Promise { + if (!TOOLS_NEED_VERIFY.has(toolName) || !result.success) return; + + const bridge = window.metonaDesktop; + if (!bridge?.isDesktop) return; + + try { + switch (toolName) { + case 'write_file': { + const path = String(args.path || ''); + const expected = String(args.content || ''); + const read = await bridge.tool.execute('read_file', { path, mode: 'text' }); + if (read.success) { + const actual = String(read.content || ''); + if (actual.length === 0) { + logWarn(`核验 write_file: ${path} 内容为空`); + } else if (Math.abs(actual.length - expected.length) > expected.length * 0.5) { + logWarn(`核验 write_file: ${path} 期望 ${expected.length}B 实际 ${actual.length}B`); + } else { + logInfo(`核验 write_file ✅: ${path} (${actual.length}B)`); + } + } + break; + } + case 'edit_file': { + const path = String(args.path || ''); + const newText = String(args.new_text || ''); + const read = await bridge.tool.execute('read_file', { path, mode: 'text' }); + if (read.success) { + const actual = String(read.content || ''); + if (!actual.includes(newText)) { + logWarn(`核验 edit_file: ${path} 不包含期望的新内容`); + } else { + logInfo(`核验 edit_file ✅: ${path}`); + } + } + break; + } + case 'delete_file': { + const path = String(args.path || ''); + const info = await bridge.tool.execute('get_file_info', { path }); + if (info.success) { + logWarn(`核验 delete_file: ${path} 仍然存在,删除未生效`); + } else { + logInfo(`核验 delete_file ✅: ${path} 已不存在`); + } + break; + } + case 'create_directory': { + const path = String(args.path || ''); + const info = await bridge.tool.execute('get_file_info', { path }); + if (info.success && (info as any).type === 'directory') { + logInfo(`核验 create_directory ✅: ${path}`); + } else { + logWarn(`核验 create_directory: ${path} 未成功创建`); + } + break; + } + case 'move_file': { + const src = String(args.source || ''); + const dest = String(args.destination || ''); + const [srcInfo, destInfo] = await Promise.all([ + bridge.tool.execute('get_file_info', { path: src }), + bridge.tool.execute('get_file_info', { path: dest }), + ]); + if (srcInfo.success) { + logWarn(`核验 move_file: 源文件 ${src} 仍然存在`); + } else if (destInfo.success) { + logInfo(`核验 move_file ✅: ${src} → ${dest}`); + } else { + logWarn(`核验 move_file: ${src}→${dest} 源和目标均不存在`); + } + break; + } + case 'copy_file': { + const dest = String(args.destination || ''); + const destInfo = await bridge.tool.execute('get_file_info', { path: dest }); + if (destInfo.success) { + logInfo(`核验 copy_file ✅: ${dest} (${(destInfo as any).size}B)`); + } else { + logWarn(`核验 copy_file: 目标 ${dest} 不存在`); + } + break; + } + case 'download_file': { + const dest = String(args.destination || ''); + const info = await bridge.tool.execute('get_file_info', { path: dest }); + if (info.success && (info as any).size > 0) { + logInfo(`核验 download_file ✅: ${dest} (${(info as any).size}B)`); + } else { + logWarn(`核验 download_file: ${dest} 不存在或为空`); + } + break; + } + } + } catch { /* 核验失败不阻塞主流程 */ } +} + +/** + * THINKING 状态:调用 LLM 流式 + */ +async function handleThinking( + ctx: LoopContext, + api: OllamaAPI, + model: string, + callbacks: AgentCallbacks, +): Promise { + ctx.loopCount++; + logAgentLoop(ctx.loopCount, ctx.maxLoops); + + ctx.content = ''; + ctx.thinking = ''; + ctx.toolCalls.length = 0; + + // ── Plan Mode 执行进度注入 — 每次思考前展示当前状态 ── + if (ctx.mode === 'plan' && ctx.loopCount >= 1) { + const tracker = getPlanTracker(); + if (tracker.active && tracker.steps.length > 0) { + const status = formatPlanStatus(); + if (status) { + ctx.messages.push({ role: 'user', content: status, ephemeral: true }); + } + } + } + + // ── v0.12.1: 任务感知 — 检测用户请求需要工具但模型尚未行动 ── + 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; + if (needsTools) { + ctx.messages.push({ + role: 'user', + content: '⚠️ 你的任务需要调用工具才能完成。请不要用文字描述"你会怎么做",而是直接调用对应的工具(web_search、write_file 等)来实际执行。如果你不确定调用哪个工具,查看可用工具列表。', + ephemeral: true, + }); + 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: `⚠️ 你还有以下未完成的任务(来自用户原始请求):\n${pending.map((p, i) => ` ${i + 1}. ${p}`).join('\n')}\n\n请继续执行,完成所有任务后再给出最终回答。`, + ephemeral: true, + }); + logInfo('待办追踪: 注入未完成任务提醒', pending.join(', ')); + } + } + + // Token 预算警告 + const remaining = ctx.maxLoops - ctx.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.`; + ctx.messages.push({ role: 'system', content: warning, ephemeral: true }); + } + + const abortController = new AbortController(); + state.set(KEYS.ABORT_CONTROLLER, abortController); + + const STREAM_TIMEOUT_MS = state.get('streamTimeout', 300_000); + let streamTimer: ReturnType | null = null; + if (STREAM_TIMEOUT_MS > 0) { + streamTimer = setTimeout(() => { + logWarn(`流式调用超时 (${STREAM_TIMEOUT_MS / 1000}s),中止本轮`); + abortController.abort(); + }, STREAM_TIMEOUT_MS); + } + + let progressTimer: ReturnType | null = null; + + try { + const streamStartTime = Date.now(); + let lastLoggedLen = 0; + let lastContentTime = streamStartTime; + + resetStreamProgress(); + + progressTimer = setInterval(() => { + const elapsed = Date.now() - streamStartTime; + const grew = ctx.content.length - lastLoggedLen; + const sec = Math.round(elapsed / 1000); + if (grew > 0) { + lastLoggedLen = ctx.content.length; + lastContentTime = Date.now(); + logStreamProgress(`流式输出中… ${ctx.content.length} 字 / ${sec}s${ctx.toolCalls.length > 0 ? ` [${ctx.toolCalls.length} 个工具调用]` : ''}`); + } else if (ctx.content.length > 0) { + const idleSec = Math.round((Date.now() - lastContentTime) / 1000); + if (idleSec >= 10) { + logStreamProgress(`⚠️ ${idleSec}s 无新内容,当前 ${ctx.content.length} 字`); + } + } else { + logStreamProgress(`⏳ 等待模型响应… ${sec}s`); + } + }, 15000); + + await api.chatStream( + { + model, + messages: ctx.messages, + stream: true, + think: state.get('thinkEnabled', false), + options: { + num_ctx: state.get(KEYS.NUM_CTX, 24576), + temperature: state.get('temperature', 0.7) + }, + ...(getEnabledToolDefinitions().length > 0 && { tools: getEnabledToolDefinitions() }) + }, + (chunk: OllamaStreamChunk) => { + if (chunk.message?.thinking) { + ctx.thinking += chunk.message.thinking; + callbacks.onThinking(ctx.thinking); + } + if (chunk.message?.content) { + ctx.content += chunk.message.content; + callbacks.onContent(ctx.content); + } + if (chunk.eval_count) { ctx.loopEvalCount = chunk.eval_count; } + if (chunk.prompt_eval_count) { ctx.loopPromptEvalCount = chunk.prompt_eval_count; } + if (chunk.total_duration) { ctx.loopInferenceNs = chunk.total_duration; } + if (chunk.message?.tool_calls?.length) { + for (const tc of chunk.message.tool_calls) { + if (tc.function?.name) { + let parsedArgs: Record = {}; + if (tc.function.arguments) { + if (typeof tc.function.arguments === 'string') { + try { parsedArgs = JSON.parse(tc.function.arguments); } catch { parsedArgs = {}; } + } else if (typeof tc.function.arguments === 'object') { + parsedArgs = tc.function.arguments as Record; + } + } + const isNewTool = !ctx.toolCalls.some(existing => existing.function.name === tc.function.name); + if (isNewTool && callbacks.onToolCallPrepare) { + callbacks.onToolCallPrepare({ + type: 'function', + function: { name: tc.function.name, arguments: parsedArgs } + }); + } + ctx.toolCalls.push({ + type: 'function', + function: { name: tc.function.name, arguments: parsedArgs } + }); + } else if (ctx.toolCalls.length > 0) { + const last = ctx.toolCalls[ctx.toolCalls.length - 1]; + if (tc.function?.arguments) { + let chunkArgs: Record | null = null; + if (typeof tc.function.arguments === 'string') { + try { chunkArgs = JSON.parse(tc.function.arguments); } catch { /* ignore */ } + } else if (typeof tc.function.arguments === 'object') { + chunkArgs = tc.function.arguments as Record; + } + if (chunkArgs) { Object.assign(last.function.arguments, chunkArgs); } + } + } + } + } + }, + abortController + ); + + // 累加 token 统计 + ctx.totalEvalCount += ctx.loopEvalCount; + ctx.totalPromptEvalCount += ctx.loopPromptEvalCount; + ctx.totalInferenceNs += ctx.loopInferenceNs; + state.set('_currentEvalCount', ctx.totalEvalCount); + + // Token 校准 + try { + if (ctx.loopEvalCount > 0 || ctx.loopPromptEvalCount > 0) { + const estimatedThisLoop = estimateTokens(ctx.messages.map(m => m.content || '').join('')); + if (estimatedThisLoop > 0) { + recordActualTokens(ctx.loopPromptEvalCount, ctx.loopEvalCount, estimatedThisLoop); + } + } + } catch { /* ignore */ } + + // 重置本轮计数器 + ctx.loopEvalCount = 0; + ctx.loopPromptEvalCount = 0; + ctx.loopInferenceNs = 0; + + if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; } + if (progressTimer) clearInterval(progressTimer as unknown as number); + + transition(ctx, S.PARSING); + + } catch (err) { + if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; } + if (progressTimer) clearInterval(progressTimer as unknown as number); + if (abortController.signal.aborted) { + logInfo('流式调用已中止'); + if (ctx.allToolRecords.length > 0) { state.set('_abortToolRecords', ctx.allToolRecords); } + throw err; + } + logError('流式调用异常', (err as Error).message); + if (ctx.content || ctx.thinking) { + ctx.messages.push({ role: 'assistant', content: ctx.content || '(模型响应异常)', ...(ctx.thinking && { thinking: ctx.thinking }) }); + } + callbacks.onDone(ctx.content || '(模型响应异常,已自动中断)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); + transition(ctx, S.TERMINATED); + } +} + +/** + * PARSING 状态:解析模型输出,提取工具调用和文本兜底 + */ +async function handleParsing(ctx: LoopContext): Promise { + // 保存 assistant 消息 + const assistantMsg: OllamaMessage = { + role: 'assistant', + content: ctx.content, + ...(ctx.thinking && { thinking: ctx.thinking }) + }; + if (ctx.toolCalls.length > 0) { + assistantMsg.tool_calls = ctx.toolCalls; + } + ctx.messages.push(assistantMsg); + + logModelResponse(ctx.content.length, ctx.toolCalls.length); + + // 文本解析兜底 + if (ctx.toolCalls.length === 0 && getEnabledToolDefinitions().length > 0) { + const parsedCalls = parseToolCallsFromText(ctx.content); + if (parsedCalls.length > 0) { + ctx.toolCalls.push(...parsedCalls); + } + } + + if (ctx.toolCalls.length > 0) { + transition(ctx, S.EXECUTING); + } else { + transition(ctx, S.REFLECTING); + } +} + +/** + * EXECUTING 状态:执行工具(按批次并行) + */ +async function handleExecuting( + ctx: LoopContext, + callbacks: AgentCallbacks, +): Promise { + // 跨轮次去重检测 + const currentLoopKeys = ctx.toolCalls.map(c => getToolCacheKey(c.function.name, c.function.arguments)).sort(); + const currentKeysStr = JSON.stringify(currentLoopKeys); + const prevKeysStr = JSON.stringify([...ctx.prevLoopSuccessKeys].sort()); + if (currentKeysStr === prevKeysStr && currentLoopKeys.length > 0) { + const hasFailedInPrev = ctx.allToolRecords + .filter(r => ctx.prevLoopSuccessKeys.includes(getToolCacheKey(r.name, r.arguments))) + .some(r => r.status !== 'success'); + if (!hasFailedInPrev) { + logWarn('检测到连续两轮工具调用完全相同且全部成功,注入提醒而非终止'); + ctx.messages.push({ + role: 'user', + content: '⚠️ 检测到你连续调用了与上一轮完全相同的工具。请基于已有结果给出最终回答,或者调用不同的工具获取新信息。如果任务已完成,请给出最终回答。' + }); + transition(ctx, S.THINKING); + return; + } + logInfo('检测到重复调用但上一轮有失败,允许重试'); + } + + // ── 工具并行执行:批次划分 ── + const batches: ToolCall[][] = []; + let currentBatch: ToolCall[] = []; + + for (const call of ctx.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); + currentBatch = [call]; + } else { + currentBatch.push(call); + } + } + } + if (currentBatch.length > 0) batches.push(currentBatch); + + if (batches.length > 1) { + logInfo(`工具并行执行: ${ctx.toolCalls.length} 个工具 → ${batches.length} 批次(首批 ${batches[0].length} 个并行)`); + } + + /** 执行单个工具(含重试) */ + const executeSingleTool = async (call: ToolCall): Promise<[ToolCallRecord, string | null]> => { + const cacheKey = getToolCacheKey(call.function.name, call.function.arguments); + + if (isDuplicateCall(call, ctx.toolCalls)) { + logWarn(`跳过重复工具调用: ${call.function.name}`); + const cached = toolResultCache.get(cacheKey); + if (cached) { + return [{ + name: call.function.name, arguments: call.function.arguments, + result: cached.result, status: 'success' as const, timestamp: Date.now() + }, null]; + } + } + + 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: cachedEntry.result, status: 'success' as const, timestamp: Date.now() + }, null]; + } + if (cachedEntry) { + toolResultCache.delete(cacheKey); + } + + await new Promise(r => requestAnimationFrame(r)); + callbacks.onToolCallStart(call); + logToolStart(call.function.name, JSON.stringify(call.function.arguments)); + + // ── v0.12.0: pre_tool Hook ── + await executeHooks('pre_tool', ctx, { toolName: call.function.name, toolArgs: call.function.arguments }); + + await new Promise(r => requestAnimationFrame(r)); + + if (needsConfirmation(call.function.name)) { + const confirmed = await callbacks.onConfirmTool(call); + if (!confirmed) { + logWarn(`工具取消: ${call.function.name}`); + const cancelResult = { success: false, error: '用户取消了操作' }; + return [{ + name: call.function.name, arguments: call.function.arguments, + result: cancelResult, status: 'cancelled' as const, timestamp: Date.now() + }, null]; + } + } + + let lastError = ''; + for (let retry = 0; retry <= MAX_RETRIES; retry++) { + // 重试前检查中止信号 + if (state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted) { + return [{ + name: call.function.name, arguments: call.function.arguments, + result: { success: false, error: '用户中止' }, + status: 'cancelled' as const, timestamp: Date.now() + }, null]; + } + try { + 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); + return [{ + name: call.function.name, arguments: call.function.arguments, + result, status: result.success ? 'success' as const : 'error' as const, timestamp: Date.now() + }, result.success ? cacheKey : null]; + } catch (err) { + lastError = (err as Error).message; + if (retry < MAX_RETRIES) { + logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}`, lastError); + await new Promise(r => setTimeout(r, 500)); + continue; + } + logError(`工具执行失败: ${call.function.name}`, lastError); + return [{ + name: call.function.name, arguments: call.function.arguments, + result: { success: false, error: lastError }, + status: 'error' as const, timestamp: Date.now() + }, null]; + } + } + return [{ + name: call.function.name, arguments: call.function.arguments, + result: { success: false, error: lastError }, status: 'error' as const, timestamp: Date.now() + }, null]; + }; + + // 按批次执行 + for (const batch of batches) { + if (state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted) { + callbacks.onDone(ctx.content, ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); + transition(ctx, S.TERMINATED); + return; + } + + const results = await Promise.all(batch.map(call => executeSingleTool(call))); + + for (const [record, cacheKey] of results) { + ctx.allToolRecords.push(record); + ctx.messages.push({ + role: 'tool', tool_name: record.name, + content: formatToolResultForModel(record.name, record.result!) + }); + if (cacheKey) toolResultCache.set(cacheKey, { result: record.result!, timestamp: Date.now() }); + // 记录度量 + recordToolCall(record.name, record.status, Date.now() - record.timestamp); + // ── v0.12.1: 通用工具结果核验 — 所有写类工具执行后验证 ── + if (record.status === 'success') { + verifyToolResult(record.name, record.arguments, record.result!); + } + // ── v0.12.0: post_tool Hook ── + executeHooks('post_tool', ctx, { toolName: record.name, toolArgs: record.arguments, toolResult: record.result! }); + if (record.status === 'success') { + callbacks.onToolCallResult(record.name, record.result!, batch.find(c => c.function.name === record.name)!); + } else if (record.status === 'cancelled') { + callbacks.onToolCallError(record.name, '用户取消', batch.find(c => c.function.name === record.name)!); + } else { + callbacks.onToolCallError(record.name, record.result?.error || '执行失败', batch.find(c => c.function.name === record.name)!); + } + } + } + + transition(ctx, S.OBSERVING); +} + +/** + * OBSERVING 状态:收集结果、裁剪旧消息、更新去重键 + */ +async function handleObserving( + ctx: LoopContext, + currentSession: ChatSession | null, +): Promise { + // 中止检查 + if (state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted) return; + // 硬限制工具消息数量 — 保留最近 40 条 + { + const toolIndices: number[] = []; + for (let i = 0; i < ctx.messages.length; i++) { + if (ctx.messages[i].role === 'tool') toolIndices.push(i); + } + if (toolIndices.length > 40) { + const toRemove = toolIndices.slice(0, toolIndices.length - 40); + for (let i = toRemove.length - 1; i >= 0; i--) { + ctx.messages.splice(toRemove[i], 1); + } + logInfo(`工具消息裁剪: ${toRemove.length} 条旧结果已移除 (保留最近 40 条)`); + } + } + + // 更新跨轮去重 + ctx.prevLoopSuccessKeys = ctx.allToolRecords + .filter(r => r.status === 'success') + .map(r => getToolCacheKey(r.name, r.arguments)); + + // 增量记忆提取 — 每 20 轮 + if (isMemoryEnabled() && ctx.loopCount > 1 && ctx.loopCount % 20 === 0 && ctx.messages.length >= 10) { + import('./memory-manager.js').then(({ extractMemoriesFromConversation }) => { + const recentMsgs = ctx.messages.slice(-30).filter(m => m.role === 'user' || m.role === 'assistant'); + extractMemoriesFromConversation( + recentMsgs.map(m => ({ role: m.role, content: m.content })), + currentSession?.title + ).then(() => logInfo('增量记忆提取完成', `第 ${ctx.loopCount} 轮`)) + .catch(() => {}); + }).catch(() => {}); + } + + // 保存本轮工具调用 + ctx.prevToolCalls = [...ctx.toolCalls]; + + // 记录本轮迭代度量 + recordIteration(ctx); + + // ── v0.12.0: post_iteration Hook ── + executeHooks('post_iteration', ctx, { iterationIndex: ctx.loopCount }); + + // 增量工具结果截断 — 超过 10 轮的旧结果每 3 轮截断到 500 字符 + if (ctx.loopCount > 10 && ctx.loopCount % 3 === 0) { + const truncateBefore = ctx.messages.length - 15; + for (let i = 0; i < ctx.messages.length && i < truncateBefore; i++) { + const m = ctx.messages[i]; + if (m.role === 'tool' && m.content && m.content.length > 500) { + ctx.messages[i] = { ...m, content: m.content.slice(0, 500) + '\n... (已截断旧工具结果)' }; + } + } + } + + // ── v0.12.1: 进度锚定 — 每 5 轮注入机器生成的工具调用摘要 ── + if (ctx.loopCount > 1 && ctx.loopCount % 5 === 0 && ctx.allToolRecords.length > 0) { + const recentRecords = ctx.allToolRecords.slice(-10); + const summary = recentRecords.map(r => { + const statusIcon = r.status === 'success' ? '✅' : r.status === 'error' ? '❌' : '🔄'; + const argsPreview = r.arguments ? JSON.stringify(r.arguments).slice(0, 60) : ''; + return `${statusIcon} ${r.name}(${argsPreview}${argsPreview.length >= 60 ? '…' : ''})`; + }).join('\n'); + ctx.messages.push({ + role: 'user', + content: `[进度锚点 #${ctx.loopCount}] 以下是你最近实际调用的工具(由系统记录,不可伪造):\n${summary}\n\n请基于以上真实记录继续。不要声称执行过未在列表中出现的操作。`, + ephemeral: true, + }); + logInfo('进度锚点已注入', `第 ${ctx.loopCount} 轮, ${recentRecords.length} 条记录`); + } + + // ── v0.12.1: 中途幻觉检测 — 每轮检查模型是否在无工具调用时声称完成了操作 ── + const lastAssistantMsg = [...ctx.messages].reverse().find(m => m.role === 'assistant'); + if (lastAssistantMsg?.content) { + const midHallucination = detectMidTaskHallucination(lastAssistantMsg.content, ctx.allToolRecords); + if (midHallucination) { + logWarn(`中途幻觉检测: ${midHallucination}`); + ctx.messages.push({ + role: 'user', + content: `⚠️ ${midHallucination}\n请实际调用对应工具来完成操作,不要用文字描述来替代。`, + ephemeral: true, + }); + } + } + + // 每 10 轮清理累积的 ephemeral 消息 + if (ctx.loopCount > 1 && ctx.loopCount % 10 === 0) { + let removed = 0; + ctx.messages = ctx.messages.filter(m => { + if (m.ephemeral) { removed++; return false; } + return true; + }); + if (removed > 0) logInfo(`ephemeral 清理: ${removed} 条临时消息已移除`); + } + + transition(ctx, S.REFLECTING); +} + +/** + * REFLECTING 状态:反思 + * + * 核心原则:模型不调工具 = 模型认为完成了。信任模型的判断。 + * 仅在明确的异常情况下介入(空响应、Plan Mode 确认、超最大轮次)。 + */ +async function handleReflecting( + ctx: LoopContext, + callbacks: AgentCallbacks, + currentSession: ChatSession | null, +): Promise { + // 中止检查 + if (state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted) return; + // ── 异常1: 空响应 + 有工具历史 → 模型可能困惑 → 注入提示 ── + if (ctx.toolCalls.length === 0 && !ctx.content.trim() && ctx.loopCount > 1 && ctx.allToolRecords.length > 0) { + logWarn('模型返回空内容(有未处理的工具结果),注入继续提示'); + ctx.messages.pop(); + ctx.messages.push({ + role: 'user', + content: '请根据上面的工具调用结果继续回答。如果需要更多信息,可以继续调用工具。如果已有足够信息,请给出最终回答。' + }); + transition(ctx, S.THINKING); + return; + } + + // ── Plan Mode: 第一轮回复 → 检测是否为计划 → 等待用户确认 ── + if (ctx.mode === 'plan' && ctx.loopCount === 1 && ctx.toolCalls.length === 0 && ctx.content.length > 50) { + const isPlanLike = /计划|步骤|step|plan|思路|方案|流程/.test(ctx.content.slice(0, 200)); + if (isPlanLike && callbacks.onPlanReady) { + const steps = extractPlanSteps(ctx.content); + const approved = await callbacks.onPlanReady(ctx.content, steps); + if (!approved) { + ctx.planRetries++; + if (ctx.planRetries >= 3) { + logWarn('Plan Mode: 达到最大重试次数 (3),强制进入执行模式'); + ctx.messages.push({ + role: 'user', + content: '已达到最大计划重试次数。请基于当前计划直接开始执行任务。', + ephemeral: true, + }); + ctx.loopCount = 0; + transition(ctx, S.THINKING); + return; + } + logInfo('Plan Mode: 用户拒绝计划,重新规划'); + ctx.messages.push({ + role: 'user', + content: '请基于反馈重新规划。调整你的方案后再次输出计划。', + ephemeral: true, + }); + ctx.loopCount = 0; + transition(ctx, S.THINKING); + return; + } + logInfo('Plan Mode: 用户批准计划,开始执行'); + // 初始化 Plan Tracker,追踪每个步骤的完成状态 + if (steps.length > 0) { + initPlanTracker(steps); + logInfo('Plan Tracker 已启动', `${steps.length} 个步骤`); + } + // 移除 Plan Mode 的"不要执行工具"约束消息,替换为执行指令 + // 从后往前找到并移除 Plan Mode 提示(ephemeral 标记) + for (let i = ctx.messages.length - 1; i >= 0; i--) { + if (ctx.messages[i].ephemeral && ctx.messages[i].content?.includes('不要直接执行工具调用')) { + ctx.messages.splice(i, 1); + break; + } + } + ctx.messages.push({ + role: 'user', + content: '计划已批准。现在开始执行——请直接调用工具(web_search、write_file 等)来完成每一步。不要描述计划,直接行动。', + ephemeral: true, + }); + transition(ctx, S.THINKING); + return; + } + // 非计划回复(如简单问答)→ 跳过 Plan Mode,直接完成 + if (!isPlanLike) { + logInfo('Plan Mode: 回复非计划内容,直接完成'); + } + } + + // ── 异常2: 超出最大轮次 → 强制终止 ── + if (ctx.loopCount >= ctx.maxLoops) { + logWarn('ReAct Agent Loop 达到最大迭代次数限制'); + callbacks.onDone(ctx.content || '(达到最大工具调用次数限制)', ctx.allToolRecords, makeStats(ctx)); + transition(ctx, S.TERMINATED); + return; + } + + // ── 本轮调用了工具 → 模型需要处理工具结果 → 继续循环 ── + if (ctx.toolCalls.length > 0) { + transition(ctx, S.THINKING); + return; + } + + // ── 正常终止: 模型停止调工具 → 模型认为任务完成 → 信任模型 ── + // Completion Gate 分级:阻断级检查(幻觉/注入)不通过则强制重新回答 + // 咨询级检查(notThinking/contentQuality)仅记录日志 + try { + const gateResult = await runCompletionGate(ctx); + if (!gateResult.passed) { + recordCompletionGate(false); + if (gateResult.critical) { + // 阻断级失败:强制模型重新回答 + logWarn(`Completion Gate 🔴阻断: ${gateResult.reason}`); + ctx.messages.push({ + role: 'user', + content: `⚠️ 完成检查发现严重问题: ${gateResult.reason}\n请修正后重新给出回答。`, + ephemeral: true, + }); + transition(ctx, S.THINKING); + return; + } + // 咨询级失败:仅记录,不阻断 + logWarn(`Completion Gate 🟡咨询: ${gateResult.reason}(不阻断)`); + } else { + recordCompletionGate(true); + } + } catch { /* Gate 异常不影响主流程 */ } + + logInfo('模型停止工具调用 → ReAct Agent Loop 结束'); + clearPlanTracker(); + if (isMemoryEnabled() && ctx.messages.length >= 10) { + try { + const { extractMemoriesFromConversation } = await import('./memory-manager.js'); + await extractMemoriesFromConversation( + ctx.messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })), + currentSession?.title + ); + } catch { /* 不阻塞 */ } + } + + callbacks.onDone(ctx.content || '(模型未返回内容)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); + transition(ctx, S.TERMINATED); +} + +/** + * COMPRESSING 状态:触发上下文压缩 + */ +async function handleCompressing( + ctx: LoopContext, + api: OllamaAPI, + model: string, +): Promise { + const numCtx = state.get(KEYS.NUM_CTX, 24576); + if (shouldAutoCompress(ctx.messages, numCtx)) { + logInfo('COMPRESSING: 上下文压缩触发'); + const compressAC = state.get(KEYS.ABORT_CONTROLLER) || new AbortController(); + try { + const compressed = await compressWithLLM(ctx.messages, api as any, model, { abortController: compressAC }); + if (compressed.length < ctx.messages.length || estimateTokens(compressed.map(m => m.content || '').join('')) < estimateTokens(ctx.messages.map(m => m.content || '').join(''))) { + ctx.messages.length = 0; + ctx.messages.push(...compressed); + logSuccess('COMPRESSING: 完成'); + } + } catch (err) { + if ((err as Error).name === 'AbortError') throw err; + logWarn('COMPRESSING: 失败', (err as Error).message); + } + } + transition(ctx, S.REFLECTING); +} + +// ═══════════════════════════════════════════════════════════════ +// Harness: 主入口 — 状态机循环 +// ═══════════════════════════════════════════════════════════════ + +function makeStats(ctx: LoopContext) { + return { + eval_count: ctx.totalEvalCount || undefined, + prompt_eval_count: ctx.totalPromptEvalCount || undefined, + total_duration: ctx.totalInferenceNs || undefined, + }; +} + +export async function runAgentLoop( + userContent: string, + images: string[], + historyMessages: Array<{ role: string; content: string; images?: string[] }>, + callbacks: AgentCallbacks +): Promise { + const api = state.get(KEYS.API); + const model = state.get('_defaultModel', ''); + const currentSession = state.get(KEYS.CURRENT_SESSION); + const sessionId = currentSession?.id || 'unknown'; + const mode: AgentMode = state.get('agentMode', 'auto'); + + if (!api || !model) { + showToast('请先选择模型', 'error'); + return; + } + + // ── 构建 LoopContext ── + const ctx: LoopContext = { + state: S.INIT, + sessionId, + loopCount: 0, + maxLoops: state.get('maxTurns', 85), + content: '', + thinking: '', + toolCalls: [], + allToolRecords: [], + messages: [], + totalEvalCount: 0, + totalPromptEvalCount: 0, + totalInferenceNs: 0, + loopEvalCount: 0, + loopPromptEvalCount: 0, + loopInferenceNs: 0, + prevLoopSuccessKeys: [], + prevToolCalls: [], + mode, + planRetries: 0, + startTime: Date.now(), + }; + + state.set('_loopState', S.INIT); + persistLoopContext(ctx); + startSessionMetrics(sessionId, model); + // Plan Mode 激活时注册 plan_track 工具 + const { setPlanModeActive } = await import('./tool-registry.js'); + setPlanModeActive(mode === 'plan'); + + // ── 状态机主循环 ── + try { + // Phase 1: INIT + await handleInit(ctx, api, model, callbacks, userContent, images, historyMessages, currentSession); + transition(ctx, S.THINKING); + + // Phase 2-7: THINKING → PARSING → EXECUTING → OBSERVING → REFLECTING → (COMPRESSING) → loop + while (ctx.state !== S.TERMINATED) { + // ── v0.12.1: 看门狗 — 全局超时熔断(可通过设置 loopWatchdogMs 配置,0=禁用)── + // 默认 30 分钟,用户强调不要随意加超时限制 + const WATCHDOG_MS = state.get('loopWatchdogMs', 1_800_000); + if (WATCHDOG_MS > 0 && Date.now() - ctx.startTime > WATCHDOG_MS) { + logWarn(`看门狗触发: Agent Loop 运行超过 ${WATCHDOG_MS / 60000} 分钟,强制终止`); + ctx.messages.push({ + role: 'user', + content: `⚠️ 任务运行时间超过 ${Math.round(WATCHDOG_MS / 60000)} 分钟限制,系统自动终止。请简化任务或分批执行。`, + ephemeral: true, + }); + callbacks.onDone(ctx.content || '(看门狗超时终止)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); + transition(ctx, S.TERMINATED); + break; + } + + // ── v0.12.1: 上下文硬上限 — 消息数超阈值强制压缩 ── + if (ctx.messages.length > MAX_MESSAGES) { + logWarn(`上下文硬上限触发: ${ctx.messages.length} 条消息 > ${MAX_MESSAGES},强制压缩`); + transition(ctx, S.COMPRESSING); + ctx.state = S.COMPRESSING; // 跳过 transition 验证直接进入(压缩态允许) + state.set('_loopState', S.COMPRESSING); // 同步全局状态 + } + + // 检查中止信号 + if (state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted) { + logInfo('ReAct Agent Loop 已中止'); + if (ctx.allToolRecords.length > 0) { + state.set('_abortToolRecords', ctx.allToolRecords); + } + throw new DOMException('Aborted', 'AbortError'); + } + + // Token 感知的动态迭代预算 + const numCtx = state.get(KEYS.NUM_CTX, 24576); + const usageRatio = estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx; + 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} 轮)`); + ctx.maxLoops = newMax; + } + + // 非首轮迭代:通知 UI + if (ctx.loopCount > 0 && callbacks.onNewIteration) { + callbacks.onNewIteration(ctx.prevToolCalls.length > 0 ? ctx.prevToolCalls : undefined); + } + + // 状态分发 + switch (ctx.state) { + case S.THINKING: + await handleThinking(ctx, api, model, callbacks); + // handleThinking 内部会 transition 到 PARSING 或 TERMINATED + break; + + case S.PARSING: + await handleParsing(ctx); + // handleParsing 内部会 transition 到 EXECUTING 或 REFLECTING + break; + + case S.EXECUTING: + await handleExecuting(ctx, callbacks); + // handleExecuting 内部会 transition 到 OBSERVING 或 THINKING 或 TERMINATED + break; + + case S.OBSERVING: + await handleObserving(ctx, currentSession); + // handleObserving 内部会 transition 到 REFLECTING + break; + + case S.REFLECTING: + await handleReflecting(ctx, callbacks, currentSession); + // handleReflecting 内部会 transition 到 THINKING / COMPRESSING / TERMINATED + break; + + case S.COMPRESSING: + await handleCompressing(ctx, api, model); + // handleCompressing 内部会 transition 到 REFLECTING + break; + + default: + logWarn(`未知状态: ${ctx.state},强制终止`); + transition(ctx, S.TERMINATED); + } + + persistLoopContext(ctx); + } + } catch (err) { + if ((err as Error).name === 'AbortError') { + // 中止是正常的,但仍需通知 UI 清理状态 + callbacks.onDone(ctx.content || '(已中止)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); + } else { + logError('Agent Loop 异常', (err as Error).message); + callbacks.onDone(ctx.content || '(Agent Loop 异常终止)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx)); + } + transition(ctx, S.TERMINATED); + } finally { + clearPlanTracker(); + endSessionMetrics(); + } } diff --git a/src/renderer/services/agent-metrics.ts b/src/renderer/services/agent-metrics.ts new file mode 100644 index 0000000..58e6b79 --- /dev/null +++ b/src/renderer/services/agent-metrics.ts @@ -0,0 +1,263 @@ +/** + * Agent Metrics — 治理与度量模块 (v0.12.0) + * Harness Engineering Phase 8: 度量驱动的优化循环 + * + * 功能: + * - 会话级度量采集(迭代效率、工具成功率、完成质量) + * - 全局趋势聚合 + * - 错误模式识别(转向循环) + * - 度量仪表盘数据源 + */ + +import { logInfo, logDebug } from './log-service.js'; +import type { AgentMetrics, LoopContext } from '../types.js'; + +// ═══════════════════════════════════════════════════════════════ +// 度量采集 +// ═══════════════════════════════════════════════════════════════ + +/** 当前会话的度量快照 */ +interface SessionMetrics { + sessionId: string; + model: string; + startTime: number; + endTime: number; + totalIterations: number; + toolCalls: Array<{ name: string; status: string; duration?: number }>; + totalInputTokens: number; + totalOutputTokens: number; + completionGatePassed: boolean; + errorPatterns: string[]; +} + +let currentMetrics: SessionMetrics | null = null; +const sessionMetricsHistory: SessionMetrics[] = []; + +/** 开始采集会话度量 */ +export function startSessionMetrics(sessionId: string, model: string): void { + currentMetrics = { + sessionId, + model, + startTime: Date.now(), + endTime: 0, + totalIterations: 0, + toolCalls: [], + totalInputTokens: 0, + totalOutputTokens: 0, + completionGatePassed: false, + errorPatterns: [], + }; + logDebug(`Agent Metrics: 开始采集会话 ${sessionId}`); +} + +/** 记录一轮迭代 */ +export function recordIteration(ctx: LoopContext): void { + if (!currentMetrics) return; + currentMetrics.totalIterations = ctx.loopCount; + currentMetrics.totalInputTokens = ctx.totalPromptEvalCount; + currentMetrics.totalOutputTokens = ctx.totalEvalCount; +} + +/** 记录工具调用 */ +export function recordToolCall(name: string, status: string, durationMs?: number): void { + if (!currentMetrics) return; + currentMetrics.toolCalls.push({ name, status, duration: durationMs }); + if (status === 'error') { + // 错误模式:记录工具名和失败状态 + const pattern = `${name}:error`; + if (!currentMetrics.errorPatterns.includes(pattern)) { + currentMetrics.errorPatterns.push(pattern); + } + } +} + +/** 完成门控结果 */ +export function recordCompletionGate(passed: boolean): void { + if (!currentMetrics) return; + currentMetrics.completionGatePassed = passed; +} + +/** 结束会话度量 */ +export function endSessionMetrics(): SessionMetrics | null { + if (!currentMetrics) return null; + currentMetrics.endTime = Date.now(); + sessionMetricsHistory.push({ ...currentMetrics }); + const metrics = { ...currentMetrics }; + logInfo( + `Agent Metrics: 会话完成 — ` + + `${metrics.totalIterations} 轮, ` + + `${metrics.toolCalls.length} 工具调用, ` + + `${formatPercent(getToolSuccessRate(metrics))} 成功率` + ); + currentMetrics = null; + return metrics; +} + +// ═══════════════════════════════════════════════════════════════ +// 度量计算 +// ═══════════════════════════════════════════════════════════════ + +/** 计算工具成功率 */ +function getToolSuccessRate(m: SessionMetrics): number { + if (m.toolCalls.length === 0) return 1; + const successCount = m.toolCalls.filter(t => t.status === 'success').length; + return successCount / m.toolCalls.length; +} + +/** 格式化百分比 */ +function formatPercent(ratio: number): string { + return (ratio * 100).toFixed(1) + '%'; +} + + +/** 聚合全局度量 */ +export function aggregateMetrics(): AgentMetrics { + const history = sessionMetricsHistory; + if (history.length === 0) { + return { + totalSessions: 0, + avgIterationsPerTask: 0, + toolSuccessRate: 0, + avgCompletionScore: 0, + frequentErrors: [], + tokenEfficiency: 0, + collectedAt: Date.now(), + }; + } + + const totalIterations = history.reduce((s, m) => s + m.totalIterations, 0); + const allToolCalls = history.flatMap(m => m.toolCalls); + const successCalls = allToolCalls.filter(t => t.status === 'success').length; + const totalInputTokens = history.reduce((s, m) => s + m.totalInputTokens, 0); + const totalOutputTokens = history.reduce((s, m) => s + m.totalOutputTokens, 0); + + // 聚合错误模式 + const errorCounts = new Map(); + for (const m of history) { + for (const pattern of m.errorPatterns) { + errorCounts.set(pattern, (errorCounts.get(pattern) || 0) + 1); + } + } + const frequentErrors = Array.from(errorCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([pattern, count]) => ({ pattern, count })); + + return { + totalSessions: history.length, + avgIterationsPerTask: Math.round(totalIterations / history.length), + toolSuccessRate: allToolCalls.length > 0 ? successCalls / allToolCalls.length : 0, + avgCompletionScore: history.filter(m => m.completionGatePassed).length / history.length, + frequentErrors, + tokenEfficiency: totalInputTokens > 0 ? totalOutputTokens / totalInputTokens : 0, + collectedAt: Date.now(), + }; +} + +// ═══════════════════════════════════════════════════════════════ +// 转向循环:错误模式 → 改进建议 +// ═══════════════════════════════════════════════════════════════ + +export interface ImprovementSuggestion { + pattern: string; + frequency: number; + suggestion: string; + severity: 'high' | 'medium' | 'low'; +} + +/** + * 分析错误模式,生成改进建议 + * 实现"让错误只犯一次"的核心理念 + */ +export function generateImprovementSuggestions(): ImprovementSuggestion[] { + const metrics = aggregateMetrics(); + const suggestions: ImprovementSuggestion[] = []; + + for (const err of metrics.frequentErrors) { + const [tool, status] = err.pattern.split(':'); + + // 特定工具的重复失败 + if (status === 'error') { + if (tool === 'web_fetch' && err.count >= 3) { + suggestions.push({ + pattern: err.pattern, + frequency: err.count, + suggestion: `web_fetch 频繁失败 (${err.count}次)。考虑在 AGENT.md 中添加规则:优先使用 browser_open + browser_extract 作为替代方案。`, + severity: 'high', + }); + } else if (tool === 'web_search' && err.count >= 3) { + suggestions.push({ + pattern: err.pattern, + frequency: err.count, + suggestion: `web_search 频繁失败 (${err.count}次)。检查 SearXNG 配置或网络连接。可在 AGENT.md 中添加备用搜索策略。`, + severity: 'high', + }); + } else if (err.count >= 5) { + suggestions.push({ + pattern: err.pattern, + frequency: err.count, + suggestion: `工具 ${tool} 反复失败 ${err.count} 次。建议审查该工具的参数构造逻辑,或在 AGENT.md 中添加错误处理指南。`, + severity: 'medium', + }); + } + } + } + + // Token 效率过低 + if (metrics.tokenEfficiency < 0.1 && metrics.totalSessions > 3) { + suggestions.push({ + pattern: 'low_token_efficiency', + frequency: metrics.totalSessions, + suggestion: `Token 效率过低 (${formatPercent(metrics.tokenEfficiency)})。Agent 可能过度调用工具。建议在 AGENT.md 中添加:简单常识问题直接回答,不要调用工具。`, + severity: 'medium', + }); + } + + // 迭代次数过高 + if (metrics.avgIterationsPerTask > 50) { + suggestions.push({ + pattern: 'high_iterations', + frequency: metrics.totalSessions, + suggestion: `平均迭代次数过高 (${metrics.avgIterationsPerTask} 轮)。建议降低 maxTurns 设置或在 AGENT.md 中添加提前终止条件。`, + severity: 'medium', + }); + } + + return suggestions; +} + +/** + * 将改进建议格式化为 AGENT.md 补充规则 + */ +export function formatSuggestionsAsRules(suggestions: ImprovementSuggestion[]): string { + if (suggestions.length === 0) return ''; + + let rules = '\n\n## 自动生成的改进规则 (v0.12.0)\n'; + rules += '> 以下规则由 Agent Metrics 系统根据历史错误模式自动生成\n\n'; + + for (const s of suggestions) { + rules += `### ${s.pattern}\n`; + rules += `- **严重程度**: ${s.severity}\n`; + rules += `- **出现频率**: ${s.frequency} 次\n`; + rules += `- **建议**: ${s.suggestion}\n\n`; + } + + return rules; +} + +// ═══════════════════════════════════════════════════════════════ +// 格式化输出(供仪表盘使用) +// ═══════════════════════════════════════════════════════════════ + +export function formatMetricsReport(metrics: AgentMetrics): string { + return [ + `Agent Metrics 报告 (${new Date(metrics.collectedAt).toLocaleString()})`, + `${'─'.repeat(50)}`, + `总会话数: ${metrics.totalSessions}`, + `平均迭代/任务: ${metrics.avgIterationsPerTask}`, + `工具成功率: ${formatPercent(metrics.toolSuccessRate)}`, + `完成门控通过率: ${formatPercent(metrics.avgCompletionScore)}`, + `Token 效率: ${formatPercent(metrics.tokenEfficiency)}`, + `高频错误: ${metrics.frequentErrors.length > 0 ? metrics.frequentErrors.map(e => `${e.pattern}(${e.count}次)`).join(', ') : '无'}`, + ].join('\n'); +} diff --git a/src/renderer/services/completion-gate.ts b/src/renderer/services/completion-gate.ts new file mode 100644 index 0000000..951099e --- /dev/null +++ b/src/renderer/services/completion-gate.ts @@ -0,0 +1,368 @@ +/** + * Completion Gate — 完成门控模块 (v0.12.0) + * Harness Engineering Phase 3: 在 Agent 输出最终答案前进行结构化检查 + * + * 设计理念: + * - 计算性反馈优先(规则驱动、毫秒级、100% 可靠) + * - 推理性反馈补充(AI 判断、秒级、非确定) + * - 所有检查项可独立开关 + */ + +import { logWarn, logInfo } from './log-service.js'; +import { estimateTokens } from './context-manager.js'; +import type { LoopContext, CompletionCheck } from '../types.js'; + +// ═══════════════════════════════════════════════════════════════ +// 内置检查项 +// ═══════════════════════════════════════════════════════════════ + +/** 内容质量检查:回复不应过短或不完整 */ +const contentQualityCheck: CompletionCheck = { + name: 'contentQuality', + description: '检查回复长度和质量是否达到最低标准', + check: async (ctx: LoopContext) => { + if (ctx.content.length < 50) { + return { passed: false, reason: '回复内容过短(<50字),可能不完整。请给出更详细的最终回答。' }; + } + return { passed: true, reason: '' }; + }, +}; + +/** 工具结果审查:是否存在未处理的工具错误 */ +const toolResultReviewCheck: CompletionCheck = { + name: 'toolResultReview', + description: '检查是否存在未处理的工具执行错误', + check: async (ctx: LoopContext) => { + const errorRecords = ctx.allToolRecords.filter(r => r.status === 'error'); + if (errorRecords.length === 0) return { passed: true, reason: '' }; + + // 检查这些错误是否在最近的上下文中被讨论过 + const recentContent = ctx.messages.slice(-5).map(m => m.content || '').join(' '); + const unresolvedErrors = errorRecords.filter(r => { + const errorName = r.name; + return !recentContent.includes(errorName + ' 失败') + && !recentContent.includes(errorName + ' error') + && !recentContent.includes(errorName + ' 错误'); + }); + + if (unresolvedErrors.length > 0) { + return { + passed: false, + reason: `存在 ${unresolvedErrors.length} 个未处理的工具错误: ${unresolvedErrors.map(r => r.name).join(', ')}。请说明这些错误的影响或提供替代方案。` + }; + } + return { passed: true, reason: '' }; + }, +}; + +/** 思考阶段检测:回复不应仍在"思考中"状态 */ +const notThinkingCheck: CompletionCheck = { + name: 'notThinking', + description: '检查回复是否已从思考阶段过渡到结论阶段', + check: async (ctx: LoopContext) => { + // 仅检查短回复(<200字)——长回复几乎肯定是完整回答 + if (ctx.content.length > 200) return { passed: true, reason: '' }; + + const contentLower = ctx.content.slice(0, 100).toLowerCase(); + const thinkingMarkers = ['让我看看', '观察一下', '先检查一下', '我需要先', '正在分析', '让我再']; + const continuationSignals = ctx.content.endsWith('...') || ctx.content.endsWith('等等') || ctx.content.endsWith('…'); + const conclusionMarkers = [ + /Final\s*Answer/i, /最终答案/, /最终回答/, /总结/, /任务完成/, /以上就是/, + /以上[是为]/, /我的能力/, /可以帮你/, /能够/, /我是/, /以下[是为]/, + ]; + + const hasConclusion = conclusionMarkers.some(p => p.test(ctx.content)); + if (hasConclusion) return { passed: true, reason: '' }; + + const thinkingCount = thinkingMarkers.filter(m => contentLower.includes(m)).length; + const isClearlyThinking = thinkingCount >= 2 || (thinkingCount >= 1 && continuationSignals); + + if (isClearlyThinking) { + return { + passed: false, + reason: '回复看起来仍在思考阶段(短回复 + 思考动词 + 无结论标志),请基于已有结果给出明确的结论。' + }; + } + return { passed: true, reason: '' }; + }, +}; + +/** 上下文效率检查:是否存在过度调用工具 */ +const contextEfficiencyCheck: CompletionCheck = { + name: 'contextEfficiency', + description: '检查工具调用效率是否合理(避免过度调用)', + check: async (ctx: LoopContext) => { + const toolMessages = ctx.messages.filter(m => m.role === 'tool'); + const nonToolMessages = ctx.messages.filter(m => m.role !== 'tool' && m.role !== 'system'); + + if (toolMessages.length > nonToolMessages.length * 3 && ctx.loopCount > 5) { + return { + passed: false, + reason: `工具调用过多(${toolMessages.length} 次 vs ${nonToolMessages.length} 次非工具消息),可能存在重复或低效的工具调用。请检查是否可以直接基于已有结果给出回答。` + }; + } + return { passed: true, reason: '' }; + }, +}; + +/** 系统提示词注入检测:防止模型在回复中尝试注入 prompt */ +const promptInjectionCheck: CompletionCheck = { + name: 'promptInjection', + description: '检测回复中是否包含疑似 prompt injection 内容', + check: async (ctx: LoopContext) => { + const injectionPatterns = [ + /ignore\s+(all\s+)?(previous|above)\s+instructions/i, + /you\s+are\s+now\s+(a|an)\s+/i, + /new\s+system\s*prompt/i, + /forget\s+(all\s+)?(previous|above)/i, + /\[SOUL\.md\]/, + /\[AGENT\.md\]/, + ]; + for (const pattern of injectionPatterns) { + if (pattern.test(ctx.content)) { + return { + passed: false, + reason: '回复中包含疑似 prompt injection 内容(试图覆盖系统提示词)。请移除相关内容后重新回答。' + }; + } + } + return { passed: true, reason: '' }; + }, +}; + +/** 工具幻觉检测:模型声称完成了某操作但该工具从未被调用 */ +const toolHallucinationCheck: CompletionCheck = { + name: 'toolHallucination', + description: '检测模型是否编造了工具调用结果', + check: async (ctx: LoopContext) => { + const calledTools = new Set(ctx.allToolRecords.map(r => r.name)); + const content = ctx.content; + + // ── 统一的"声称 → 必需工具"映射表(覆盖全部 42 个工具)── + const CLAIM_RULES: Array<{ patterns: RegExp[]; requiredTools: string[]; label: string }> = [ + // 文件写入 + { patterns: [/已写入文件/, /已创建文件/, /文件已生成/, /已保存到/, /已成功创建/, /成功写入/, + /文件.*已.*创建/, /文件.*已.*写入/, /生成.*文件/, /写入.*工作空间/], + requiredTools: ['write_file'], label: '写入/创建文件' }, + // 文件读取 + { patterns: [/已读取文件/, /文件内容显示/, /读取了.*文件/, /文件.*内容.*如下/, + /查看.*文件.*内容/, /打开文件.*看到/], + requiredTools: ['read_file', 'read_multiple_files'], label: '读取文件' }, + // 文件编辑 + { patterns: [/已修改文件/, /已编辑文件/, /文件已更新/, /已替换.*内容/, + /修改了.*文件/, /更新了.*文件/], + requiredTools: ['edit_file', 'write_file'], label: '编辑/修改文件' }, + // 文件删除 + { patterns: [/已删除文件/, /已移除文件/, /文件已清除/, /删除了.*文件/], + requiredTools: ['delete_file'], label: '删除文件' }, + // 文件移动/复制/重命名 + { patterns: [/已移动文件/, /已复制文件/, /已重命名/, /文件.*已.*移动/, /文件.*已.*复制/], + requiredTools: ['move_file', 'copy_file'], label: '移动/复制文件' }, + // 目录浏览 + { patterns: [/目录包含/, /列出.*目录/, /目录.*结构/, /文件列表.*如下/, /目录.*内容/], + requiredTools: ['list_directory', 'tree'], label: '列出目录' }, + // 文件搜索 + { patterns: [/搜索到.*文件/, /找到.*匹配/, /搜索.*结果.*文件/], + requiredTools: ['search_files'], label: '搜索文件' }, + // 搜索/抓取 + { patterns: [/搜索结果显示/, /根据搜索/, /抓取了.*网页/, /从.*搜索.*得到/, + /查询到/, /检索到/, /搜索到/, /网络.*显示/, /实时.*数据.*显示/], + requiredTools: ['web_search', 'web_fetch'], label: '搜索/抓取网页' }, + // 浏览器 + { patterns: [/浏览器截图显示/, /打开网页.*看到/, /页面显示/, /浏览器.*打开/, + /点击了.*按钮/, /输入了.*文本/, /页面.*截图/], + requiredTools: ['browser_open', 'browser_screenshot', 'browser_extract', 'browser_click', 'browser_type'], label: '浏览器操作' }, + // 命令执行 + { patterns: [/运行.*命令/, /执行.*脚本/, /命令.*输出/, /运行结果/, /命令.*返回/, + /执行.*结果/, /终端.*显示/], + requiredTools: ['run_command'], label: '执行命令' }, + // Git 操作 + { patterns: [/已提交/, /已推送/, /已暂存/, /已创建分支/, /已合并/, /已克隆/, + /git.*commit/, /git.*push/, /git.*add/, /提交了.*代码/], + requiredTools: ['git'], label: 'Git 操作' }, + // 下载文件 + { patterns: [/已下载文件/, /下载完成/, /文件.*已.*下载/, /从.*下载/], + requiredTools: ['download_file'], label: '下载文件' }, + // 压缩/解压 + { patterns: [/已压缩/, /已解压/, /压缩完成/, /解压完成/, /归档.*已.*创建/], + requiredTools: ['compress'], label: '压缩/解压' }, + // 文件信息 + { patterns: [/文件.*大小.*字节/, /文件.*权限.*为/, /文件.*修改日期/], + requiredTools: ['get_file_info'], label: '获取文件信息' }, + // 内存/记忆操作 + { patterns: [/已记住/, /已添加记忆/, /记忆已保存/, /已更新记忆/, /已删除记忆/], + requiredTools: ['memory_add', 'memory_replace', 'memory_remove'], label: '记忆操作' }, + // 会话操作 + { patterns: [/历史会话.*显示/, /已读取.*会话/, /会话.*内容.*如下/], + requiredTools: ['session_list', 'session_read'], label: '会话操作' }, + // 子代理 + { patterns: [/子代理.*完成/, /子任务.*已.*执行/, /spawn.*task.*完成/], + requiredTools: ['spawn_task'], label: '子代理委派' }, + // 系统工具 + { patterns: [/计算.*结果.*为/, /哈希.*值.*为/, /UUID.*生成/, /随机.*生成.*为/], + requiredTools: ['calculator', 'hash', 'uuid', 'random'], label: '系统工具' }, + ]; + + for (const rule of CLAIM_RULES) { + for (const pattern of rule.patterns) { + if (pattern.test(content)) { + const anyCalled = rule.requiredTools.some(t => calledTools.has(t)); + if (!anyCalled) { + const toolList = rule.requiredTools.join(' / '); + return { + passed: false, + reason: `回复声称进行了"${rule.label}"操作,但 ${toolList} 工具从未被调用。请实际调用 ${toolList} 来完成任务,不要编造结果。` + }; + } + break; // 该类别匹配到了且工具已调用,跳出 + } + } + } + + return { passed: true, reason: '' }; + }, +}; + +// ═══════════════════════════════════════════════════════════════ +// 检查项注册与管理 +// ═══════════════════════════════════════════════════════════════ + +/** 默认启用的检查项 */ +const DEFAULT_ENABLED_CHECKS = new Set([ + 'contentQuality', + 'toolResultReview', + 'notThinking', + 'promptInjection', + 'toolHallucination', +]); + +/** 阻断级检查:不通过时强制模型重新回答 */ +const CRITICAL_CHECKS = new Set([ + 'toolHallucination', + 'promptInjection', +]); + +/** 注册表 */ +const checkRegistry = new Map([ + ['contentQuality', contentQualityCheck], + ['toolResultReview', toolResultReviewCheck], + ['notThinking', notThinkingCheck], + ['contextEfficiency', contextEfficiencyCheck], + ['promptInjection', promptInjectionCheck], + ['toolHallucination', toolHallucinationCheck], +]); + +/** 已启用的检查项 */ +const enabledChecks = new Set(DEFAULT_ENABLED_CHECKS); + +/** 注册自定义检查项 */ +export function registerCompletionCheck(check: CompletionCheck): void { + checkRegistry.set(check.name, check); + logInfo(`Completion Gate: 注册检查项 "${check.name}"`); +} + +/** 移除检查项 */ +export function unregisterCompletionCheck(name: string): void { + checkRegistry.delete(name); + enabledChecks.delete(name); +} + +/** 启用/禁用检查项 */ +export function setCheckEnabled(name: string, enabled: boolean): void { + if (enabled) { + if (checkRegistry.has(name)) enabledChecks.add(name); + } else { + enabledChecks.delete(name); + } +} + +/** 获取所有已注册检查项 */ +export function getRegisteredChecks(): CompletionCheck[] { + return Array.from(checkRegistry.values()); +} + +/** 获取已启用的检查项 */ +export function getEnabledChecks(): CompletionCheck[] { + return getRegisteredChecks().filter(c => enabledChecks.has(c.name)); +} + +// ═══════════════════════════════════════════════════════════════ +// 执行门控 +// ═══════════════════════════════════════════════════════════════ + +export interface GateResult { + passed: boolean; + reason: string; + /** 是否为阻断级失败(必须重新回答) */ + critical: boolean; + /** 各项检查的详细结果 */ + details: Array<{ name: string; passed: boolean; reason: string; durationMs: number }>; +} + +/** + * 运行完成门控检查 + */ +export async function runCompletionGate(ctx: LoopContext): Promise { + const checks = getEnabledChecks(); + if (checks.length === 0) { + return { passed: true, reason: '', critical: false, details: [] }; + } + + const details: GateResult['details'] = []; + + // 关键检查优先(幻觉/注入),咨询检查在后 + const sorted = [...checks].sort((a, b) => { + const aCrit = CRITICAL_CHECKS.has(a.name) ? 0 : 1; + const bCrit = CRITICAL_CHECKS.has(b.name) ? 0 : 1; + return aCrit - bCrit; + }); + + for (const check of sorted) { + const start = Date.now(); + try { + const result = await check.check(ctx); + details.push({ + name: check.name, + passed: result.passed, + reason: result.reason, + durationMs: Date.now() - start, + }); + if (!result.passed) { + const isCritical = CRITICAL_CHECKS.has(check.name); + logWarn(`Completion Gate: "${check.name}" ${isCritical ? '🔴阻断' : '🟡咨询'} — ${result.reason}`); + return { + passed: false, + reason: result.reason, + critical: isCritical, + details, + }; + } + } catch (err) { + details.push({ + name: check.name, + passed: true, + reason: `检查异常: ${(err as Error).message}`, + durationMs: Date.now() - start, + }); + } + } + + return { passed: true, reason: '', critical: false, details }; +} + +/** + * 生成门控报告(供日志/调试使用) + */ +export function formatGateReport(result: GateResult): string { + const status = result.passed ? '✅ 通过' : '❌ 未通过'; + let report = `Completion Gate ${status}`; + if (!result.passed) { + report += ` — ${result.reason}`; + } + report += `\n${'─'.repeat(40)}`; + for (const d of result.details) { + const icon = d.passed ? '✅' : '❌'; + report += `\n${icon} ${d.name}: ${d.reason || '通过'} (${d.durationMs}ms)`; + } + return report; +} diff --git a/src/renderer/services/context-indexer.ts b/src/renderer/services/context-indexer.ts new file mode 100644 index 0000000..9656aa8 --- /dev/null +++ b/src/renderer/services/context-indexer.ts @@ -0,0 +1,230 @@ +/** + * Context Indexer — 渐进式披露模块 (v0.12.0) + * Harness Engineering Phase 6: 三级上下文管理 + * + * 索引层 (Index) — 始终保留:项目结构树 + 入口文件地图 + 技术栈摘要 + * 接口层 (Interface) — 按需加载:模块 API 声明 + 类型定义 + 配置文件 + * 实现层 (Implementation) — 修改时加载:具体源代码 + * + * 设计理念: + * - 用目录式索引告诉智能体"去哪找",而非"全记住" + * - 上下文可从数万 Token 压至几千 + * - 通过 load_context 工具按需触发接口层和实现层的加载 + */ + +import { logInfo, logDebug, logWarn } from './log-service.js'; +import { estimateTokens } from './context-manager.js'; +import type { ProjectIndex, ContextTier } from '../types.js'; + +// ═══════════════════════════════════════════════════════════════ +// 项目索引缓存 +// ═══════════════════════════════════════════════════════════════ + +/** 项目索引缓存(5 分钟 TTL) */ +let cachedIndex: ProjectIndex | null = null; +let cacheTimestamp = 0; +const INDEX_CACHE_TTL = 5 * 60 * 1000; // 5 分钟 + +/** 最大索引 Token 预算 */ +const MAX_INDEX_TOKENS = 2000; + +/** + * 构建项目索引 + * 扫描工作空间目录结构,生成精简的结构摘要 + */ +export async function buildProjectIndex(workspaceDir: string): Promise { + // 检查缓存 + if (cachedIndex && Date.now() - cacheTimestamp < INDEX_CACHE_TTL) { + return cachedIndex; + } + + try { + const bridge = window.metonaDesktop; + if (!bridge?.isDesktop) { + return createEmptyIndex(); + } + + // 利用现有 tree 工具扫描目录结构(限制深度 3 层) + const treeResult = await bridge.tool.execute('tree', { + path: workspaceDir, + max_depth: 3, + include_hidden: false, + }); + + let structure = ''; + if (treeResult.success && treeResult.tree) { + structure = String(treeResult.tree); + // Token 预算截断 + if (estimateTokens(structure) > MAX_INDEX_TOKENS) { + const lines = structure.split('\n'); + structure = lines.slice(0, Math.min(lines.length, 60)).join('\n') + + '\n... (目录结构已截断,使用 list_directory 查看完整内容)'; + } + } else { + structure = '(无法读取工作空间目录结构)'; + } + + // 识别入口文件 + const entryFiles: string[] = []; + const commonEntries = [ + 'package.json', 'tsconfig.json', 'vite.config.ts', + 'main.ts', 'index.ts', 'index.html', 'app.ts', + 'Cargo.toml', 'pyproject.toml', 'go.mod', 'CMakeLists.txt', + 'README.md', 'Makefile', 'docker-compose.yml', + ]; + for (const entry of commonEntries) { + try { + // 跨平台路径拼接(清理尾部斜杠,统一用 posix 风格,Node.js 可容错处理) + const cleanDir = workspaceDir.replace(/[\\/]+$/, ''); + const filePath = cleanDir + '/' + entry; + const checkResult = await bridge.workspace.readFile(filePath); + if (checkResult?.success) { + entryFiles.push(entry); + } + } catch { /* ignore */ } + } + + // 检测技术栈 + const techStack = detectTechStack(entryFiles); + + const index: ProjectIndex = { + structure, + entryFiles, + techStack, + tokenCount: estimateTokens(structure), + generatedAt: Date.now(), + }; + + cachedIndex = index; + cacheTimestamp = Date.now(); + logInfo('项目索引已构建', `${techStack.join(', ')}, ${entryFiles.length} 入口文件, ${index.tokenCount} tokens`); + return index; + } catch (err) { + logWarn('项目索引构建失败', (err as Error).message); + return createEmptyIndex(); + } +} + +/** 创建空索引 */ +function createEmptyIndex(): ProjectIndex { + return { + structure: '(未检测到工作空间)', + entryFiles: [], + techStack: [], + tokenCount: 0, + generatedAt: Date.now(), + }; +} + +/** 根据入口文件检测技术栈 */ +function detectTechStack(entryFiles: string[]): string[] { + const stack: string[] = []; + const fileSet = new Set(entryFiles.map(f => f.toLowerCase())); + + if (fileSet.has('package.json')) stack.push('Node.js'); + if (fileSet.has('tsconfig.json')) stack.push('TypeScript'); + if (fileSet.has('vite.config.ts')) stack.push('Vite'); + if (fileSet.has('cargo.toml')) stack.push('Rust'); + if (fileSet.has('pyproject.toml')) stack.push('Python'); + if (fileSet.has('go.mod')) stack.push('Go'); + if (fileSet.has('cmakelists.txt')) stack.push('C/C++'); + if (fileSet.has('docker-compose.yml')) stack.push('Docker'); + if (fileSet.has('makefile')) stack.push('Make'); + + return stack.length > 0 ? stack : ['未知']; +} + +/** + * 生成索引层系统提示词 + * 始终保留在上下文中,告诉 AI "去哪找" + */ +export function buildIndexContext(index: ProjectIndex): string { + if (!index.structure || index.tokenCount === 0) return ''; + + let context = `【项目索引 — 始终可见】 +项目结构: +${index.structure} + +技术栈: ${index.techStack.join(', ') || '未检测'} +入口文件: ${index.entryFiles.length > 0 ? index.entryFiles.join(', ') : '未检测'} + +💡 使用 list_directory 查看目录详情,使用 read_file 读取具体文件。 +💡 使用 search_files 按内容搜索代码。 +`; + + // Token 预算控制 + if (estimateTokens(context) > MAX_INDEX_TOKENS) { + context = context.slice(0, Math.floor(context.length * 0.8)) + '\n... (索引已截断)'; + } + + return context; +} + +/** + * 构建接口层上下文(按需加载) + * @param modulePattern 模块匹配模式,如 "src/services/" + */ +export async function buildInterfaceContext(modulePattern: string, workspaceDir: string): Promise { + try { + const bridge = window.metonaDesktop; + if (!bridge?.isDesktop) return ''; + + // 搜索模块相关的类型定义和配置文件 + const searchResult = await bridge.tool.execute('search_files', { + path: workspaceDir, + query: modulePattern, + search_type: 'filename', + max_results: 10, + }); + + if (!searchResult.success || !(searchResult as any).results?.length) { + return `(未找到与 "${modulePattern}" 相关的接口文件)`; + } + + const results = (searchResult as any).results as Array<{ path: string }>; + const paths = results.map(r => r.path).slice(0, 8); + + // 批量读取接口文件(限制每文件 2000 字符) + const readResult = await bridge.tool.execute('read_multiple_files', { + paths, + max_chars_per_file: 2000, + }); + + if (readResult.success) { + const filesInfo = paths.map(p => ` 📄 ${p}`).join('\n'); + return `【接口层 — ${modulePattern}】 +相关文件: +${filesInfo} + +内容预览: +${JSON.stringify((readResult as any).files)}`; + } + + return `【接口层 — ${modulePattern}】 +相关文件:${paths.join(', ')}`; + } catch (err) { + logWarn('接口层上下文加载失败', (err as Error).message); + return ''; + } +} + +/** + * 加载指定层级的上下文 + */ +export async function loadContextByTier( + tier: ContextTier, + modulePattern: string, + workspaceDir: string, +): Promise { + switch (tier) { + case 'index': { + const index = await buildProjectIndex(workspaceDir); + return buildIndexContext(index); + } + case 'interface': + return buildInterfaceContext(modulePattern, workspaceDir); + case 'implementation': + // 实现层由 Agent 自行通过 read_file 加载 + return ''; + } +} diff --git a/src/renderer/services/hooks.ts b/src/renderer/services/hooks.ts new file mode 100644 index 0000000..e560df6 --- /dev/null +++ b/src/renderer/services/hooks.ts @@ -0,0 +1,234 @@ +/** + * Harness Hook 系统 (v0.12.0) + * Harness Engineering Phase 4: 可扩展的 Agent 执行钩子架构 + * + * 钩子阶段: + * - pre_tool: 工具执行前(安全检查、缓存检查、权限验证) + * - post_tool: 工具执行后(结果验证、自动 lint、自动测试) + * - post_iteration: 每轮迭代后(记忆提取、上下文裁剪) + * - pre_completion: 最终输出前(完成门控、质量检查) + * + * 设计原则: + * - 异步并行执行,失败不阻塞主流程 + * - 优先级排序,高优先级先执行 + * - 可动态注册/移除 + * - MCP 工具可注册自定义 Hook + */ + +import { logInfo, logWarn, logDebug } from './log-service.js'; +import type { HarnessHook, HookPhase, HookResult, HookData, LoopContext } from '../types.js'; + +// ═══════════════════════════════════════════════════════════════ +// Hook 注册表 +// ═══════════════════════════════════════════════════════════════ + +const hookRegistry = new Map(); +const phaseIndex = new Map(); + +function ensurePhaseIndex(phase: HookPhase): void { + if (!phaseIndex.has(phase)) { + phaseIndex.set(phase, []); + } +} + +/** 注册 Hook */ +export function registerHook(hook: HarnessHook): void { + hookRegistry.set(hook.name, hook); + ensurePhaseIndex(hook.phase); + phaseIndex.get(hook.phase)!.push(hook); + // 按优先级降序排列 + phaseIndex.get(hook.phase)!.sort((a, b) => b.priority - a.priority); + logDebug(`Hook 已注册: "${hook.name}" phase=${hook.phase} priority=${hook.priority}`); +} + +/** 移除 Hook */ +export function unregisterHook(name: string): void { + const hook = hookRegistry.get(name); + if (!hook) return; + hookRegistry.delete(name); + const list = phaseIndex.get(hook.phase); + if (list) { + const idx = list.findIndex(h => h.name === name); + if (idx !== -1) list.splice(idx, 1); + } +} + +/** 启用/禁用 Hook */ +export function setHookEnabled(name: string, enabled: boolean): void { + const hook = hookRegistry.get(name); + if (hook) { + hook.enabled = enabled; + logDebug(`Hook "${name}" ${enabled ? '已启用' : '已禁用'}`); + } +} + +/** 获取所有已注册 Hook */ +export function getRegisteredHooks(): HarnessHook[] { + return Array.from(hookRegistry.values()); +} + +/** 获取指定阶段已启用的 Hook */ +export function getPhaseHooks(phase: HookPhase): HarnessHook[] { + return (phaseIndex.get(phase) || []).filter(h => h.enabled); +} + +// ═══════════════════════════════════════════════════════════════ +// Hook 执行引擎 +// ═══════════════════════════════════════════════════════════════ + +/** + * 并行执行指定阶段的所有 Hook + * @returns 所有 Hook 的执行结果汇总 + */ +export async function executeHooks( + phase: HookPhase, + ctx: LoopContext, + data: HookData = {} +): Promise<{ results: HookResult[]; allPassed: boolean }> { + const hooks = getPhaseHooks(phase); + if (hooks.length === 0) { + return { results: [], allPassed: true }; + } + + const results = await Promise.all( + hooks.map(async (hook) => { + try { + const result = await hook.handler(ctx, data); + if (!result.passed) { + logWarn(`Hook "${hook.name}" 未通过: ${result.message}`); + } + return result; + } catch (err) { + logWarn(`Hook "${hook.name}" 执行异常: ${(err as Error).message}`); + return { passed: true, message: `Hook 异常(已忽略): ${(err as Error).message}` }; + } + }) + ); + + const allPassed = results.every(r => r.passed); + return { results, allPassed }; +} + +// ═══════════════════════════════════════════════════════════════ +// 内置 Hook +// ═══════════════════════════════════════════════════════════════ + +/** + * SecurityCheckHook — 工具执行前安全检查 + * 检查命令和路径是否在黑名单内 + */ +export const securityCheckHook: HarnessHook = { + name: 'SecurityCheck', + phase: 'pre_tool', + priority: 100, // 最高优先级 + enabled: true, + handler: async (_ctx: LoopContext, data: HookData) => { + const { toolName, toolArgs } = data; + if (toolName === 'run_command' && typeof toolArgs?.command === 'string') { + const cmd = toolArgs.command as string; + // 快速黑名单检查 + const dangerous = ['rm -rf /', 'mkfs', 'dd if=', 'shutdown', 'reboot', ':(){', 'chmod 777 /']; + for (const d of dangerous) { + if (cmd.toLowerCase().includes(d.toLowerCase())) { + return { passed: false, message: `危险命令被拦截: ${cmd.slice(0, 100)}` }; + } + } + // 管道执行 shell 检测 + if (/(\||>|<)\s*(sh|bash|zsh|powershell|cmd)/i.test(cmd)) { + return { passed: false, message: `禁止通过管道执行 shell: ${cmd.slice(0, 100)}` }; + } + } + if (toolName === 'delete_file' && typeof toolArgs?.path === 'string') { + const p = (toolArgs.path as string).toLowerCase(); + const protectedPaths = ['/etc', '/sys', '/proc', '/dev', '/boot', 'c:\\windows', '.ssh', '.gnupg']; + for (const pp of protectedPaths) { + if (p.includes(pp)) { + return { passed: false, message: `受保护路径不可删除: ${p}` }; + } + } + } + return { passed: true, message: '' }; + }, +}; + +/** + * ResultValidationHook — 工具执行后结果验证 + * 检查工具返回是否有效。注意:此 Hook 为咨询性(post_tool 阶段不阻断执行), + * 实际的截断由 agent-engine 的 formatToolResultForModel 和 40 条消息硬限制负责。 + */ +export const resultValidationHook: HarnessHook = { + name: 'ResultValidation', + phase: 'post_tool', + priority: 90, + enabled: true, + handler: async (_ctx: LoopContext, data: HookData) => { + const { toolName, toolResult } = data; + if (!toolResult) return { passed: true, message: '' }; + + // 检查空结果 + if (!toolResult.success && !toolResult.error) { + return { passed: false, message: `工具 ${toolName} 执行失败但未提供错误信息` }; + } + + // 检查超大结果。不同工具有不同阈值: + // web_search 包含自动抓取内容,允许较大结果 + const SIZE_LIMITS: Record = { + web_search: 1_000_000, // 1MB(含自动抓取的多页内容) + web_fetch: 500_000, // 500KB + read_file: 200_000, // 200KB + default: 500_000, // 默认 500KB + }; + const limit = SIZE_LIMITS[toolName || ''] || SIZE_LIMITS.default; + + if (toolResult.success && toolResult) { + const resultStr = JSON.stringify(toolResult); + if (resultStr.length > limit) { + // 仅警告,不阻断(实际截断由 agent-engine 负责) + return { + passed: true, + message: `工具 ${toolName} 返回结果较大 (${(resultStr.length / 1024).toFixed(0)}KB),将由上下文管理器自动截断`, + data: { oversized: true, size: resultStr.length }, + }; + } + } + + return { passed: true, message: '' }; + }, +}; + +/** + * IterationMetricsHook — 迭代度量收集 + * 每轮迭代后记录关键指标 + */ +export const iterationMetricsHook: HarnessHook = { + name: 'IterationMetrics', + phase: 'post_iteration', + priority: 50, + enabled: true, + handler: async (ctx: LoopContext, _data: HookData) => { + // 收集本轮迭代统计 + const totalMessages = ctx.messages.length; + const toolMessages = ctx.messages.filter(m => m.role === 'tool').length; + logDebug( + `迭代度量 [第 ${ctx.loopCount} 轮]: ` + + `${totalMessages} 消息, ${toolMessages} 工具消息, ` + + `${ctx.totalEvalCount} eval tokens, ${ctx.allToolRecords.length} 总工具调用` + ); + return { passed: true, message: '' }; + }, +}; + +// ═══════════════════════════════════════════════════════════════ +// 初始化:注册所有内置 Hook +// ═══════════════════════════════════════════════════════════════ + +let hooksInitialized = false; + +export function initHarnessHooks(): void { + if (hooksInitialized) return; + registerHook(securityCheckHook); + registerHook(resultValidationHook); + registerHook(iterationMetricsHook); + hooksInitialized = true; + logInfo('Harness Hook 系统已初始化', `${hookRegistry.size} 个 Hook 已注册`); +} diff --git a/src/renderer/services/mcp-client.ts b/src/renderer/services/mcp-client.ts index 59b3d9c..e11f635 100644 --- a/src/renderer/services/mcp-client.ts +++ b/src/renderer/services/mcp-client.ts @@ -151,8 +151,8 @@ export async function executeMCPTool(toolName: string, args: Record = new Set([ 'memory_search', 'memory_add', 'memory_replace', 'memory_remove', 'session_list', 'session_read', 'spawn_task', 'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract', - 'browser_click', 'browser_type', 'browser_scroll', 'browser_close', + 'browser_click', 'browser_type', 'browser_scroll', 'browser_wait', 'browser_close', 'datetime', 'calculator', 'random', 'uuid', 'json_format', 'hash' ]); @@ -727,6 +746,17 @@ export function setToolEnabled(toolName: string, enabled: boolean): void { else enabledTools.delete(toolName); } +/** Plan Mode 激活时注册 plan_track,关闭时移除 */ +export function setPlanModeActive(active: boolean): void { + if (active) { + enabledTools.add('plan_track'); + } else { + enabledTools.delete('plan_track'); + // 清除追踪器状态 + state.set('_planTracker', null); + } +} + export function getEnabledToolDefinitions(): ToolDefinition[] { const builtIn = TOOL_DEFINITIONS.filter(def => enabledTools.has(def.function.name)); @@ -879,6 +909,38 @@ export async function executeTool(toolName: string, args: Record 0) { + const idx = stepIndex - 1; + if (idx >= 0 && idx < tracker.steps.length) { + tracker.steps[idx].done = true; + tracker.done = tracker.steps.filter(s => s.done).length; + if (stepLabel) tracker.steps[idx].label = stepLabel; + savePlanTracker(tracker); + const remaining = tracker.total - tracker.done; + logToolResult('plan_track', true, `步骤 ${stepIndex} 已完成 (${tracker.done}/${tracker.total}${remaining > 0 ? `, 剩余 ${remaining}` : ', 全部完成!'})`); + return { success: true, action: 'mark_done', step: stepIndex, done: tracker.done, total: tracker.total, remaining }; + } + return { success: false, error: `步骤 ${stepIndex} 不存在(共 ${tracker.total} 步)` }; + } + if (action === 'mark_all_done') { + for (const s of tracker.steps) s.done = true; + tracker.done = tracker.total; + savePlanTracker(tracker); + logToolResult('plan_track', true, `全部 ${tracker.total} 步已完成`); + return { success: true, action: 'mark_all_done', done: tracker.total, total: tracker.total, remaining: 0 }; + } + return { success: false, error: `未知操作: ${action}` }; + } + // run_command 走 workspace IPC if (toolName === 'run_command') { const command = args.command as string; @@ -918,12 +980,70 @@ export function getToolIcon(name: string): string { git: '🔖', compress: '🗜️', web_search: '🔍', memory_search: '🧠', memory_add: '💾', memory_replace: '🔄', memory_remove: '🗑️', session_list: '📋', session_read: '📖', spawn_task: '🤖', + plan_track: '📋', browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰', browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌' }; return icons[name] || '🔧'; } +/** ── Plan Mode 执行追踪器 ── */ + +export interface PlanStep { + index: number; + label: string; + done: boolean; +} + +export interface PlanTracker { + steps: PlanStep[]; + total: number; + done: number; + active: boolean; +} + +let _planTracker: PlanTracker = { steps: [], total: 0, done: 0, active: false }; + +export function getPlanTracker(): PlanTracker { + // 每次读取时同步 state 中的最新状态 + const saved = state.get('_planTracker', null); + if (saved) _planTracker = saved; + return _planTracker; +} + +export function savePlanTracker(tracker: PlanTracker): void { + _planTracker = tracker; + state.set('_planTracker', tracker); +} + +export function initPlanTracker(steps: string[]): PlanTracker { + const tracker: PlanTracker = { + steps: steps.map((label, i) => ({ index: i + 1, label, done: false })), + total: steps.length, + done: 0, + active: true, + }; + savePlanTracker(tracker); + logInfo('Plan Tracker 已初始化', `${tracker.total} 个步骤`); + return tracker; +} + +export function clearPlanTracker(): void { + _planTracker = { steps: [], total: 0, done: 0, active: false }; + state.set('_planTracker', null); +} + +export function formatPlanStatus(): string { + const t = getPlanTracker(); + if (!t.active || t.steps.length === 0) return ''; + const lines = t.steps.map(s => { + const icon = s.done ? '✅' : '⬜'; + return `${icon} 步骤${s.index}: ${s.label}`; + }); + const pct = t.total > 0 ? Math.round(t.done / t.total * 100) : 0; + return `[Plan Mode 执行进度: ${t.done}/${t.total} (${pct}%)]\n${lines.join('\n')}\n\n完成每一步后请调用 plan_track(action='mark_done', step_index=N) 标记已完成。`; +} + export function formatToolName(name: string): string { const names: Record = { read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', @@ -935,6 +1055,7 @@ export function formatToolName(name: string): string { git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索', memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', + plan_track: '计划追踪', browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容', browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器' }; diff --git a/src/renderer/services/verification.ts b/src/renderer/services/verification.ts new file mode 100644 index 0000000..a2123e4 --- /dev/null +++ b/src/renderer/services/verification.ts @@ -0,0 +1,185 @@ +/** + * Verification System — 验证系统模块 (v0.12.0) + * Harness Engineering Phase 5: 计算性反馈管道 + * + * 在 Agent 修改文件后自动运行: + * - Linter(代码风格检查) + * - Type Checker(类型检查) + * - Test Runner(单元测试) + * - Diff Analyzer(修改范围分析) + * + * 设计原则: + * - 所有验证利用现有 run_command 工具执行 + * - 验证 Hook 注册到 hooks.ts 的 post_tool 阶段 + * - 验证失败的结果作为 Observation 注入 Agent 上下文 + * - 可配置开关(设置面板) + */ + +import { registerHook } from './hooks.js'; +import type { HarnessHook } from '../types.js'; +import { logInfo, logDebug, logWarn } from './log-service.js'; +import type { LoopContext, HookData, HookResult } from '../types.js'; + +// ═══════════════════════════════════════════════════════════════ +// 验证配置 +// ═══════════════════════════════════════════════════════════════ + +export interface VerificationConfig { + /** 是否启用自动 Lint */ + autoLint: boolean; + /** 是否启用自动类型检查 */ + autoTypeCheck: boolean; + /** 是否启用自动测试 */ + autoTest: boolean; + /** Lint 命令(留空自动检测) */ + lintCommand: string; + /** 类型检查命令 */ + typeCheckCommand: string; + /** 测试命令 */ + testCommand: string; +} + +/** 默认配置 */ +const defaultConfig: VerificationConfig = { + autoLint: false, + autoTypeCheck: false, + autoTest: false, + lintCommand: '', + typeCheckCommand: '', + testCommand: '', +}; + +/** 运行时配置 */ +let config: VerificationConfig = { ...defaultConfig }; + +// ═══════════════════════════════════════════════════════════════ +// 文件类型检测 +// ═══════════════════════════════════════════════════════════════ + +/** 检测文件扩展名对应的语言 */ +function detectFileType(filePath: string): string | null { + const ext = filePath.split('.').pop()?.toLowerCase() || ''; + const map: Record = { + ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', + py: 'python', rs: 'rust', go: 'go', java: 'java', + cpp: 'cpp', c: 'c', rb: 'ruby', css: 'css', + html: 'html', vue: 'vue', svelte: 'svelte', + }; + return map[ext] || null; +} + +/** 检测项目根目录(查找 package.json / pyproject.toml / Cargo.toml) */ +function detectProjectRoot(filePath: string): string | null { + // 简化实现:向上查找 package.json + const parts = filePath.replace(/\\/g, '/').split('/'); + for (let i = parts.length - 1; i >= 0; i--) { + const dir = parts.slice(0, i + 1).join('/'); + // 这里无法实际访问文件系统,返回文件所在目录的父级作为近似 + } + // 返回文件所在目录 + const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')); + return lastSep > 0 ? filePath.slice(0, lastSep) : null; +} + +// ═══════════════════════════════════════════════════════════════ +// 验证 Hook 实现 +// ═══════════════════════════════════════════════════════════════ + +/** + * DiffAnalyzerHook — 修改范围分析 + * 检测 Agent 的修改是否跨越了架构边界 + */ +export const diffAnalyzerHook: HarnessHook = { + name: 'DiffAnalyzer', + phase: 'post_tool', + priority: 75, + enabled: true, + handler: async (_ctx: LoopContext, data: HookData): Promise => { + const { toolName, toolArgs } = data; + if (toolName !== 'write_file' && toolName !== 'edit_file') { + return { passed: true, message: '' }; + } + + const filePath = (toolArgs?.path as string) || ''; + if (!filePath) return { passed: true, message: '' }; + + // 分析修改的文件类型 + const fileType = detectFileType(filePath); + if (!fileType) return { passed: true, message: '' }; + + // 分析跨层调用风险 + const warnings: string[] = []; + + // 检测是否修改了核心基础模块 + const criticalPaths = ['/core/', '/base/', '/common/', '/shared/', '/utils/', '/lib/']; + const filePathLower = filePath.toLowerCase(); + for (const cp of criticalPaths) { + if (filePathLower.includes(cp)) { + warnings.push(`⚠️ 修改了核心基础模块 "${filePath}",可能影响多个依赖方`); + break; + } + } + + if (warnings.length > 0) { + return { + passed: true, // 警告不阻止,但注入提醒 + message: warnings.join('; '), + data: { warnings }, + }; + } + + return { passed: true, message: '' }; + }, +}; + +/** + * FileChangeAuditHook — 文件变更审计 + * 记录 Agent 的每一次文件修改 + */ +export const fileChangeAuditHook: HarnessHook = { + name: 'FileChangeAudit', + phase: 'post_tool', + priority: 30, + enabled: true, + handler: async (ctx: LoopContext, data: HookData): Promise => { + const { toolName, toolArgs, toolResult } = data; + if (toolName !== 'write_file' && toolName !== 'edit_file' && toolName !== 'delete_file') { + return { passed: true, message: '' }; + } + + const filePath = (toolArgs?.path as string) || ''; + const success = toolResult?.success || false; + + logInfo( + `文件变更审计 [第 ${ctx.loopCount} 轮]: ` + + `${toolName} ${filePath} — ${success ? '✅' : '❌'}` + ); + + return { passed: true, message: '' }; + }, +}; + +// ═══════════════════════════════════════════════════════════════ +// 初始化 +// ═══════════════════════════════════════════════════════════════ + +let verificationInitialized = false; + +export function initVerificationSystem(): void { + if (verificationInitialized) return; + registerHook(diffAnalyzerHook); + registerHook(fileChangeAuditHook); + verificationInitialized = true; + logInfo('验证系统已初始化', 'DiffAnalyzer + FileChangeAudit Hook 已注册'); +} + +/** 获取验证配置 */ +export function getVerificationConfig(): VerificationConfig { + return { ...config }; +} + +/** 更新验证配置 */ +export function updateVerificationConfig(updates: Partial): void { + Object.assign(config, updates); + logInfo('验证配置已更新', JSON.stringify(config)); +} diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css index 004d76a..fe2c830 100644 --- a/src/renderer/styles/style.css +++ b/src/renderer/styles/style.css @@ -631,6 +631,52 @@ html, body { background: var(--critical-bg); } +/* ── Plan 按钮 (v0.12.0) ── */ +.plan-toggle { + display: flex; + align-items: center; + cursor: pointer; + flex-shrink: 0; + user-select: none; +} + +.plan-toggle input { display: none; } + +.plan-btn { + width: 36px; + height: 36px; + border-radius: var(--radius-control); + background: var(--bg-card); + border: 1px solid var(--border-default); + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); + color: var(--text-tertiary); +} + +.plan-btn svg { + width: 18px; + height: 18px; +} + +.plan-toggle:hover .plan-btn { + border-color: var(--border-strong); + color: var(--text-secondary); +} + +.plan-toggle input:checked + .plan-btn { + background: rgba(71, 132, 143, 0.1); + border-color: #47848F; + color: #47848F; + box-shadow: 0 0 16px rgba(71, 132, 143, 0.2); +} + +.plan-toggle input:checked + .plan-btn:hover { + background: rgba(71, 132, 143, 0.15); + box-shadow: 0 0 20px rgba(71, 132, 143, 0.3); +} + /* ── 开关控件 ── */ .toggle-label { display: flex; @@ -2449,8 +2495,8 @@ html, body { ═══════════════════════════════════════════════════════════════ */ .workspace-panel { - width: 480px; - min-width: 480px; + width: 400px; + min-width: 400px; height: 100vh; display: flex; flex-direction: column; @@ -3539,6 +3585,12 @@ html, body { border: 1px solid rgba(217, 79, 92, 0.15); } +.tool-card-badge.plan-mode { + color: var(--accent); + background: rgba(232, 115, 74, 0.08); + border: 1px solid rgba(232, 115, 74, 0.2); +} + .tool-card-desc { font-size: 12px; color: var(--text-secondary); diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts index ade6658..a51ab15 100644 --- a/src/renderer/types.d.ts +++ b/src/renderer/types.d.ts @@ -324,7 +324,22 @@ export type StateKey = | 'runCommandMode' | 'memoryEnabled' | 'memoryEntries' - | 'embeddingModel'; + | 'embeddingModel' + | '_defaultModel' + | '_lastSystemPrompt' + | '_currentEvalCount' + | '_abortToolRecords' + | '_loopState' + | '_loopContext' + | 'modelSupportsTools' + | 'maxTurns' + | 'streamTimeout' + | 'subAgentMaxLoops' + | 'subAgentTimeout' + | 'subAgentModel' + | 'agentMode' + | 'loopWatchdogMs' + | '_planTracker'; // ═══════════════════════════════════════════════════════════ // Tool Calling 类型 @@ -379,6 +394,139 @@ export interface ToolCallRecord { export type AgentState = 'idle' | 'sending' | 'accumulating' | 'executing' | 'confirming' | 'done'; +// ═══════════════════════════════════════════════════════════ +// Harness Engineering: Agent Loop 状态机 (v0.12.0) +// ═══════════════════════════════════════════════════════════ + +/** Agent Loop 状态枚举 */ +export type LoopState = + | 'INIT' // 构建系统提示词、准备上下文 + | 'THINKING' // 调用 LLM 流式 + | 'PARSING' // 解析输出(tool_calls 提取 + 文本兜底) + | 'EXECUTING' // 执行工具(按批次并行) + | 'OBSERVING' // 收集工具结果、格式化 + | 'REFLECTING' // 反思:去重检测、过早停止检测、Final Answer 检测 + | 'COMPRESSING' // 触发上下文压缩 + | 'TERMINATED'; // 终止 + +/** Agent Loop 上下文(可序列化,支持暂停/恢复) */ +export interface LoopContext { + /** 状态机当前状态 */ + state: LoopState; + /** 会话 ID */ + sessionId: string; + /** 当前循环计数 */ + loopCount: number; + /** 最大循环次数 */ + maxLoops: number; + /** 当前轮模型输出内容 */ + content: string; + /** 当前轮思考内容 */ + thinking: string; + /** 本轮工具调用列表 */ + toolCalls: ToolCall[]; + /** 所有工具调用记录 */ + allToolRecords: ToolCallRecord[]; + /** 消息列表(Ollama 格式) */ + messages: OllamaMessage[]; + /** 累计 eval tokens */ + totalEvalCount: number; + /** 累计 prompt_eval tokens */ + totalPromptEvalCount: number; + /** 累计推理时间(纳秒) */ + totalInferenceNs: number; + /** 本轮 eval tokens */ + loopEvalCount: number; + /** 本轮 prompt_eval tokens */ + loopPromptEvalCount: number; + /** 本轮推理时间 */ + loopInferenceNs: number; + /** 上一轮成功工具调用的缓存键(用于去重) */ + prevLoopSuccessKeys: string[]; + /** 上一轮工具调用(用于 onNewIteration) */ + prevToolCalls: ToolCall[]; + /** Agent 模式:auto(直接执行)| plan(先规划后执行) */ + mode: AgentMode; + /** Plan 模式下等待用户确认的 Promise resolve */ + planApproved?: boolean; + /** Plan Mode 重试计数(防止无限循环) */ + planRetries: number; + /** 循环开始时间 */ + startTime: number; +} + +/** Agent 运行模式 */ +export type AgentMode = 'auto' | 'plan'; + +/** System Prompt 分区(支持 LLM Prefix Caching) */ +export interface SystemPromptZones { + /** 静态区:SOUL.md + 环境基础 → 利用 LLM Prefix Caching */ + staticZone: string; + /** 动态区:记忆检索 + 工作空间 + 实时日期 */ + dynamicZone: string; +} + +/** Hook 阶段 */ +export type HookPhase = 'pre_tool' | 'post_tool' | 'post_iteration' | 'pre_completion'; + +/** Hook 执行结果 */ +export interface HookResult { + /** 是否通过 */ + passed: boolean; + /** 消息(通过/失败原因) */ + message: string; + /** 附加数据 */ + data?: Record; +} + +/** Harness Hook 接口 */ +export interface HarnessHook { + name: string; + phase: HookPhase; + priority: number; + handler: (ctx: LoopContext, data: HookData) => Promise; + enabled: boolean; +} + +/** Hook 上下文数据 */ +export interface HookData { + toolName?: string; + toolArgs?: Record; + toolResult?: ToolResult; + iterationIndex?: number; + [key: string]: unknown; +} + +/** 完成门控检查项 */ +export interface CompletionCheck { + name: string; + description: string; + check: (ctx: LoopContext) => Promise<{ passed: boolean; reason: string }>; +} + +/** Agent 度量指标 */ +export interface AgentMetrics { + totalSessions: number; + avgIterationsPerTask: number; + toolSuccessRate: number; + avgCompletionScore: number; + frequentErrors: Array<{ pattern: string; count: number }>; + tokenEfficiency: number; + collectedAt: number; +} + +/** 渐进式披露:上下文层级 */ +export type ContextTier = 'index' | 'interface' | 'implementation'; + +/** 项目索引摘要 */ +export interface ProjectIndex { + structure: string; + entryFiles: string[]; + techStack: string[]; + tokenCount: number; + generatedAt: number; +} + // ═══════════════════════════════════════════════════════════ // ReAct Trace 类型 (v4.0) // ═══════════════════════════════════════════════════════════ @@ -392,6 +540,7 @@ export interface TraceEntry { actionInput: string; observation: string; loopCount: number; + errorPattern?: string; createdAt: number; }