From 042e00e4a6101144c049039c87f961e58d5e2c83 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=B4=AB=E5=BD=B1233?=
Date: Tue, 23 Jun 2026 17:06:43 +0800
Subject: [PATCH] =?UTF-8?q?v0.12.3:=20=E5=85=A8=E9=9D=A2=E5=AE=A1=E8=AE=A1?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E7=A8=B3=E5=AE=9A=E6=80=A7=E5=8A=A0?=
=?UTF-8?q?=E5=9B=BA=20+=20=E6=96=87=E6=A1=A3=E6=9B=B4=E6=96=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
P0 安全/稳定性修复 (6项):
- sqlite persist 改用临时文件+rename,I/O错误日志
- IPC fs 处理器加入 checkPathAllowed 路径验证
- handleCompress 命令注入修复 + 跨平台 (powershell/tar/zip/unzip)
- browser 全页截图简化,使用 capturePage full rect
- workspace 竞态条件修复
- editFile 5MB 大小限制
P1 安全/稳定性修复 (7项):
- 大小写不敏感搜索: query 同步转换小写
- web_fetch 无 content-length 时流式读取+10MB限制防OOM
- Windows SIGTERM 改用 taskkill /PID /T /F 强制终止
- startsWith 路径检查 Windows 大小写不敏感
- 安全黑名单扩展: 33个目录路径 + 30条危险命令 (Linux+Windows)
- plan_track stepIndex 改用 typeof === 'number' 精确检查
- MCP 前缀改用双下划线分隔 mcp_{server}__{tool} 防歧义
P2 代码质量修复 (8项):
- 修复 EXECUTING→THINKING 死循环Bug (转换表缺失)
- COMPRESSING 硬上限绕过时同步 _loopState 全局状态
- Abort 路径补齐 onDone 回调, 防止UI状态残留
- 幻觉检测从4条扩展到16条规则, 覆盖全部工具类别
- 移除 getTokenEfficiency 死代码
- 跨平台路径清理 (replace(/[\/]+$/, ''))
- 动态 import 加 try/catch, 失败自动批准不阻断
- #modelSelect 加 null 守卫, 防止运行时崩溃
文档 & UI 更新:
- README.md: 版本号/特性表/架构图/安全机制/工具清单全面刷新
- 帮助面板: 新增 Plan Mode、抗幻觉&稳定性章节, Agent Loop补8状态机
- 工具面板: plan_track卡片 + plan-mode徽章样式, 标题更新为42+1
- 工作空间面板宽度: 480px → 400px
- 源码注释移除所有版本标签, 仅保留4处必要位置
- 版本号 0.12.2 → 0.12.3
---
.gitignore | 1 +
README.md | 94 +-
docs/AI_Agent_ReAct_Harness_Engineering.md | 439 +++++
docs/BUILD.md | 6 +-
docs/DEVELOPMENT.md | 2 +-
package-lock.json | 4 +-
package.json | 2 +-
src/main/browser.ts | 36 +-
src/main/db/sqlite.ts | 21 +-
src/main/ipc.ts | 8 +-
src/main/mcp-manager.ts | 2 +-
src/main/menu.ts | 2 +-
src/main/tool-handlers-fs.ts | 6 +-
src/main/tool-handlers-system.ts | 84 +-
src/main/tool-security.ts | 24 +-
src/main/workspace.ts | 23 +-
src/renderer/components/chat-area.ts | 4 +-
src/renderer/components/history-modal.ts | 11 +-
src/renderer/components/input-area.ts | 55 +-
src/renderer/components/prompt-modal.ts | 17 +
src/renderer/components/settings-modal.ts | 39 +-
src/renderer/components/workspace-panel.ts | 4 +-
src/renderer/db/chat-db.ts | 6 +-
src/renderer/index.html | 29 +-
src/renderer/main.ts | 20 +-
src/renderer/services/agent-engine.ts | 1923 +++++++++++++-------
src/renderer/services/agent-metrics.ts | 263 +++
src/renderer/services/completion-gate.ts | 368 ++++
src/renderer/services/context-indexer.ts | 230 +++
src/renderer/services/hooks.ts | 234 +++
src/renderer/services/mcp-client.ts | 4 +-
src/renderer/services/tool-registry.ts | 123 +-
src/renderer/services/verification.ts | 185 ++
src/renderer/styles/style.css | 56 +-
src/renderer/types.d.ts | 151 +-
35 files changed, 3692 insertions(+), 784 deletions(-)
create mode 100644 docs/AI_Agent_ReAct_Harness_Engineering.md
create mode 100644 src/renderer/services/agent-metrics.ts
create mode 100644 src/renderer/services/completion-gate.ts
create mode 100644 src/renderer/services/context-indexer.ts
create mode 100644 src/renderer/services/hooks.ts
create mode 100644 src/renderer/services/verification.ts
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 @@
-
+
@@ -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 @@
+
+ 看门狗(分)
+
+ 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;
}