v0.16.0: Agent ReAct Loop 深度优化 R51-R128 (上下文管理/安全治理/性能分析/会话持久化)

This commit is contained in:
thzxx
2026-07-11 23:31:27 +08:00
parent 9a631ebf15
commit 64b7d307e7
15 changed files with 4173 additions and 91 deletions
+3 -3
View File
@@ -14,7 +14,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-v0.15.0-E8734A?style=flat-square" alt="version"> <img src="https://img.shields.io/badge/version-v0.16.0-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron"> <img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript"> <img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license"> <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
@@ -253,7 +253,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
``` ```
产出:`release/Metona Ollama Setup v0.15.0.exe` 产出:`release/Metona Ollama Setup v0.16.0.exe`
## 🛠️ 常用命令 ## 🛠️ 常用命令
@@ -501,7 +501,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
``` ```
Output: `release/Metona Ollama Setup v0.15.0.exe` Output: `release/Metona Ollama Setup v0.16.0.exe`
## 🛠️ Common Commands ## 🛠️ Common Commands
+636
View File
@@ -0,0 +1,636 @@
# 🔄 Agentic Loop(智能体循环)完全指南
> **一句话定义**Agentic Loop 是驱动 AI Agent 持续自主完成复杂任务的核心调度循环——让 AI 不断重复"思考 → 行动 → 观察 → 再思考"的闭环过程,直到任务完成。
---
## 目录
- [一、核心概念](#一核心概念)
- [二、与传统 LLM 的本质区别](#二与传统-llm的本质区别)
- [三、运作机制:五阶闭环详解](#三运作机制五阶闭环详解)
- [四、三层分级体系](#四三层分级体系)
- [五、循环终止条件](#五循环终止条件)
- [六、核心伪代码实现](#六核心伪代码实现)
- [七、与主流 Agent 范式的关系](#七与主流-agent-范式的关系)
- [八、工具调用(Tool Use)六步闭环](#八工具调用tool-use六步闭环)
- [九、适用场景与不适用场景](#九适用场景与不适用场景)
- [十、实际应用案例](#十实际应用案例)
- [十一、关联循环体系](#十一关联循环体系)
- [十二、行业趋势与未来方向](#十二行业趋势与未来方向)
- [十三、参考来源](#十三参考来源)
---
## 一、核心概念
### 什么是 Agentic Loop
**Agentic Loop(智能体循环)**是 AI Agent 架构中的核心运行机制,它让大语言模型(LLM)从传统的"一问一答"模式升级为能够持续自主地完成复杂任务的闭环系统。
如果把普通 AI 比作「随时待命的客服」,只负责被动应答;那带 Loop 的 AI Agent,就是 **不用催促、自主推进的全职实习生**,懂规划、会试错、能复盘、可收尾。
### 为什么需要它?
传统的 LLM 交互是线性的"一问一答"——用户提问,模型一次性生成回答,交互即结束。一旦任务复杂、变数较多,就会直接"摆烂",需要人不断细化指令、手动推进进度。
**Agentic Loop 引入了「循环」和「反馈」两个关键要素**,使 AI 能够根据中间结果动态调整策略,边执行边验证,而不是死板地执行预设流程。
> 💡 **没有 Agent LoopAI 只是聊天工具;有了 Agent Loop,AI 才是能落地干活的智能体。**
---
## 二、与传统 LLM 的本质区别
| 维度 | 传统 LLM | Agentic Loop |
|------|----------|--------------|
| **交互方式** | 线性"一问一答" | 循环式"思考→行动→观察" |
| **任务能力** | 单次内容生成 | 持续自主完成复杂任务 |
| **反馈机制** | 无中间反馈 | 基于环境反馈动态调整 |
| **适应性** | 静态输出 | 动态纠错与策略优化 |
| **记忆能力** | 无状态 | 可选配持久化记忆 |
| **工具使用** | 不支持 | 原生支持多工具调用 |
```mermaid
flowchart LR
subgraph Traditional["传统 LLM:单次交互"]
A1[用户提问] --> B1[LLM 一次性生成]
B1 --> C1[输出结果 · 结束]
end
subgraph Agentic["Agentic Loop:闭环迭代"]
A2[设定目标] --> B2[思考决策]
B2 --> C2[行动执行]
C2 --> D2[观察反馈]
D2 --> E2{任务完成?}
E2 -->|否| B2
E2 -->|是| F2[输出最终结果]
end
```
---
## 三、运作机制:五阶闭环详解
Agentic Loop 的完整工作流包含五个阶段,在多数任务中可简化为持续迭代的三步核心循环。
### 完整五阶段
#### ① 目标感知(Perceive
人类只需给出 **最终目标和核心规则**,不用拆解步骤、不用预设细节。例如:
- "整理一份2026年新媒体行业报告"
- "排查这段代码的运行漏洞并修复"
- "规划一周减脂食谱+运动计划"
这一步彻底告别了过去「精准逐字写提示词」的繁琐。
#### ② 思考决策(Reason
Agent 结合自身知识库、记忆体系和工具能力,把大目标拆成可落地的小步骤,同时判断:
- 下一步该做什么?
- 需要调用什么工具?
- 优先执行哪项任务?
> **核心区别:步骤不是人定的,是 AI 自己推理出来的。**
#### ③ 行动执行(Act
根据决策结果执行具体动作:
- 🔍 联网搜索信息
- 💻 调用代码工具 / 执行 Shell 命令
- 📄 读取本地文件
- ✍️ 生成文案内容
- 📊 批量处理数据
这是 AI 从「只会输出文字」到「可以真实做事」的关键一步。
#### ④ 观察反馈(Observe
Agent 会主动感知执行后的真实状态:
- 搜索的信息是否全面?
- 代码是否运行成功?
- 生成的内容是否缺漏?
- 数据是否存在异常?
它不再盲目输出,而是 **能看见自己的执行结果**
#### ⑤ 复盘修正(Reflect/Correct
这是 Loop 最核心的灵魂。Agent 会自我校验:
- 当前结果是否达标?
- 有没有遗漏步骤?
- 有没有错误漏洞?
- 是否需要调整方法?
如果不满足终止条件,立刻修正策略、重启下一轮循环;如果达标,才终止任务、输出最终结果。
### 核心三步简化版
在实际工程中,五阶段常被精简为最核心的三步循环:
```
┌─────────────────────────────────────────────┐
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────┐│
│ │ 推理 │──▶│ 行动 │──▶│ 观察 ││
│ │ Reasoning│ │ Acting │ │Observ. ││
│ └──────────┘ └──────────┘ └────────┘│
│ ▲ │ │
│ └──────────────────────────────┘ │
│ 未完成则继续循环 │
└─────────────────────────────────────────────┘
```
> **简单总结:思考 → 行动 → 观察 → 评估 → 修正,往复循环,闭环落地。**
---
## 四、三层分级体系
Agentic Loop 不是固定模板,随着记忆存储、工具管理、配套功能完善,可分为三个层级。开发中遇到的绝大多数问题——AI 重复执行相同操作、遗忘前文对话、多轮回答前后逻辑矛盾——根源基本都是 **任务复杂度与智能体层级不匹配**
### 第一层:基础工具调用循环
**最简形态**,仅依靠大模型调用工具并输出回答,没有持久化记忆、没有外部状态存储。
```
用户输入 → LLM 推理 → 调用工具 → 观察结果 → 回传 LLM → 输出最终答案
```
**特点:**
- ✅ 处理独立、简短的一次性任务完全够用
- ❌ 无法留存历史对话,每次启动都是全新空白状态
- ❌ 上下文窗口是唯一临时存储载体,流程结束后所有状态清空
- ❌ 用于多轮对话时会出现:重复检索运算、遗忘前文决策、前后自相矛盾
**适合场景**:单次问答、简单查询、一次性工具调用
---
### 第二层:内置完整生命周期的循环
升级后,循环内部新增标准化 **记忆操作流程**
- 调用大模型前 **读取** 历史记忆数据
- 智能体完成动作后 **写入/更新** 记忆
- 整套循环形成完整闭环生命周期
#### 关键区分:记忆增强型 vs 记忆感知型
| 类型 | 说明 | 能力上限 |
|------|------|----------|
| **记忆增强型** | 仅被动检索信息注入上下文,不会主动管控内存 | 较低 —— 记忆是外部附加能力 |
| **记忆感知型** | 将内存作为核心工程模块,主动完成编码、存储、检索、注入、遗忘全套操作 | 较高 —— 在单次/跨会话中持续维护自身推理状态 |
第二层是搭建 **记忆感知型智能体** 的起点。
#### 第二层常见挑战及缓解方案
| 挑战 | 说明 | 缓解方法 |
|------|------|----------|
| **检索噪声** | 语义相似但与当前查询实际不相关 | 设置相关性阈值 + 混合检索 + 多级过滤 |
| **陈旧记忆** | 快速变化领域中数据很快过时 | 设置 TTL(生存时间)策略 + 写时更新模式 |
| **工具定义过载** | 工具太多导致上下文膨胀,降低选择准确性 | 采用语义工具检索而非穷举所有工具 |
**适合场景**:多轮对话、长周期任务、需要上下文延续的复杂工作流
---
### 第三层:Harness 工程体系级循环
工程师不仅能管控循环内部逻辑,还在循环外围搭建一套 **设计规范、功能完善的 Harness 框架**。系统操作分为两大板块:
| 板块 | 说明 | 示例 |
|------|------|------|
| **循环内操作** | 程序自动执行 | 自动加载上下文、执行工具调用 |
| **循环外操作** | 智能体自主触发 | AI 判断是否需要额外信息、主动请求人工确认 |
#### 第三层必需的三类优化手段
1. **上下文窗口监控**:实时统计每轮 Token 占用,提前预判溢出风险,及时触发压缩
2. **对话压缩**:用精简摘要替代冗长聊天记录,原始消息永久保存在数据库,支持审计和按需展开
3. **工具输出离线存储**:完整工具返回结果存入独立日志表,上下文仅保留一行引用标识
> **第三层的核心升级不在于内层的基础循环逻辑,而是循环外围一整套配套支撑系统:数据加载框架、运行约束管控、跨会话持久化存储层。此时整套 Harness 本身已经是一套独立、成熟、可单独运维的工程系统。**
**适合场景**:生产级应用、企业级部署、高可靠性要求的复杂 Agent 系统
```mermaid
flowchart TB
L1["第一层: 基础工具调用循环\n✅ 单次任务 / 简单查询"] --> L2["第二层: 内置记忆的完整生命周期\n✅ 多轮对话 / 长周期任务"]
L2 --> L3["第三层: Harness 工程体系\n✅ 生产级 / 企业级部署"]
style L1 fill:#e8f5e9,stroke:#4caf50
style L2 fill:#fff3e0,stroke:#ff9800
style L3 fill:#e3f2fd,stroke:#2196f3
```
---
## 五、循环终止条件
任何循环都必须设置退出机制。设计完善的 Agentic Loop 会明确定义全部终止规则:
### 主流终止条件
| # | 终止条件 | 说明 |
|---|----------|------|
| 1 | **模型输出最终回复** | 无待执行的工具调用(`tool_calls` 为空) |
| 2 | **系统校验任务完成** | Harness 主动校验目标已达成 |
| 3 | **达到最大迭代次数** | 默认通常设为 10~20 次,防止无限循环 |
| 4 | **运行时长超限** | 全局超时保护,双重管控资源消耗 |
| 5 | **不可恢复的系统错误** | 发生无法自动修复的异常 |
| 6 | **死循环检测** | 连续多轮重复执行相同操作,无任何进展 |
| 7 | **Agent 主动结束** | 智能体发出完成标记 |
### ⚠️ 常见误区
> **模型不再发起工具调用 ≠ 用户需求已全部完成**
模型可能输出追问、部分结果或需要补充交互的内容。任务是否真正闭环,需要 **Harness 主动校验**,不能单纯依靠模型停止调用工具来判断。任务流程越长、逻辑越复杂,二者的差距越明显。
### 卡死故障检测
成熟的 Harness 框架会缓存近期全部工具调用记录,识别以下停滞模式后直接终止流程并输出诊断日志:
- 连续三轮用完全相同参数调用同一个工具
- AI 在两种状态间反复来回切换、毫无进展
---
## 六、核心伪代码实现
以下是所有 AI 智能体、自动化工作流的底层通用代码逻辑(无编程基础也能看懂):
```python
# ============================================
# Agentic Loop 核心闭环逻辑(通用极简版)
# ============================================
def agent_loop(目标任务):
# ---- 1. 初始化 ----
当前状态 = "未完成"
最大循环次数 = 20 # 防止无限死循环
历史执行记录 = []
# ---- 2. 主循环 ----
while 当前状态 == "未完成" and 循环次数 < 最大循环次数:
# ① 思考决策:根据目标 + 历史反馈 规划下一步动作
下一步动作 = llm_思考推理(目标任务, 历史执行记录)
# ② 行动执行:调用工具、落地操作
执行结果 = 工具执行(下一步动作)
# ③ 观察反馈:记录本次执行的所有数据和状态
历史执行记录.append({
"动作": 下一步动作,
"结果": 执行结果,
"时间戳": 当前时间()
})
# ④ 复盘评估:判断是否达标、是否需要优化
任务是否完成 = llm_校验(目标任务, 执行结果)
if 任务是否完成 == True:
当前状态 = "已完成"
else:
# 未达标,自动修正策略,开启下一轮循环
修正策略 = llm_纠错优化(目标任务, 历史执行记录)
# (修正策略会自然融入下一轮 llm_思考推理)
# ---- 3. 输出最终成果 ----
return 最终整合结果(历史执行记录)
```
### 代码核心解读
| 要点 | 说明 |
|------|------|
| **`while` 循环是灵魂** | 普通 AI 没有 `while` 循环,只会执行一次输出;Agent Loop 依靠持续循环实现反复干活、反复优化 |
| **自带记忆迭代** | 每一轮都记录「动作 + 结果」,下一轮参考历史数据,越循环越精准 |
| **自带终止机制** | 最大循环次数 + 任务校验双保险,既保证自主迭代又避免无效死循环 |
### 更底层的 LangChain 风格实现
```python
# ============================================
# 用 LangChain 实现的核心循环(更接近真实工程)
# ============================================
while not done:
# 1. 构建上下文(system prompt + 历史消息 + 当前输入)
messages = build_context(system_prompt, history, current_input)
# 2. 调用 LLM API(流式或非流式)
response = call_llm(messages)
# 3. 解析响应
if response.has_tool_calls:
# 4a. 有工具调用 → 逐个执行
results = execute_tools(response.tool_calls)
# 5a. 结果追加到历史
history.append(response)
history.append(results)
# → 回到步骤 1,进入下一次迭代
else:
# 4b. 无工具调用 → 输出最终回复
done = True
return response.content
```
> 一个简单的"帮我查天气"请求通常需要 **2~3 次**迭代;而复杂任务如"帮我改这个 bug"可能需要 **10+ 次**迭代——读文件、运行测试、编辑代码、再运行测试……
---
## 七、与主流 Agent 范式的关系
Agentic Loop 是多个 AI Agent 工程范式的共同基础设施:
### 🔄 ReAct 模式(最基础、最具代表性)
由 Shunyu Yao 等人于 **2022 年**在论文《ReAct: Synergizing Reasoning and Acting in Language Models》中提出。
- **核心思想**:将 **思维链推理(CoT****外部环境交互行动** 相结合
- **解决的问题**:弥补单纯 LLM 缺乏实时信息和容易产生幻觉的缺陷
- **影响范围**:已成为现代 AI 代理设计的基准,深刻影响了 **LangChain****LlamaIndex** 等后续框架
**ReAct 的经典执行轨迹示例**(排查线上服务变慢):
```
Thought 1: 用户报告服务变慢,我需要先查看监控指标
Action 1: query_monitoring(service="api-server")
Observation 1: CPU 使用率 95%,响应时间 P99 > 5s
Thought 2: CPU 飙升且响应慢,可能是慢 SQL 导致的,检查数据库
Action 2: query_database_logs(time_range="last_hour")
Observation 2: 发现全表扫描查询 SELECT * FROM orders WHERE ...
Thought 3: 已定位根因——缺少索引导致的全表扫描,通知负责人修复
Action 3: send_alert(channel="slack", message="发现慢SQL根因...")
Observation 3: ✅ 告警已发送
Final Answer: 服务变慢的原因是 orders 表缺少索引导致全表扫描,
已发送告警给 DBA 团队,建议立即添加索引。
```
### 📋 Plan-and-Execute(先规划再执行)
在 Loop 中引入更复杂的任务分解和调度机制:
1. 先制定完整的分步计划(Plan
2. 再逐步执行每个子任务(Execute)
3. 执行过程中可根据结果回溯调整计划
### 🔍 Reflection(反思机制)
在 Loop 中增加 **自我评估和纠错**环节:
- 每步行动后评估效果
- 从失败中学习
- 提升长期表现
### 👤 Human-in-the-Loop(人机协同 / HITL
将人类作为核心参与者整合到 Agentic 生命周期中(而非仅仅作为监督者):
- 满足企业的法律、合规和安全要求
- AI 在关键节点主动暂停,列出待确认项等待人工审核
- **不是兜底 bug,而是架构设计的分层逻辑**
### 🤖 Multi-Agent System(多智能体协作)
多个 Agent 各自拥有独立的 Agentic Loop,通过协作模式共同完成任务:
- **并行模式**:多个 Agent 同时处理不同子任务
- **顺序模式**:按流水线顺序传递处理
- **循环模式**:Agent 之间形成协作循环
---
## 八、工具调用(Tool Use)六步闭环
大模型本质上是"静态文本生成器",训练后无法自动更新知识,也不能执行计算、查询私有数据或操作外部系统。要让模型"感知并行动",必须把外部能力包装成 **"工具"**,让模型按需调用。
### 六步闭环流程
```
┌──────────────────────────────────────────────────────┐
│ │
│ ① 工具定义 → ② LLM 决策 → ③ 结构化调用 │
│ ↑ │ │
│ └── ⑥ 最终回答 ← ⑤ 结果回传 ← ④ 执行层运行 ←───┘│
│ │
└──────────────────────────────────────────────────────┘
```
| 步骤 | 操作 | 说明 |
|------|------|------|
| **① 工具定义** | 用 JSON Schema 或框架装饰器描述函数名、功能、字段类型与语义 | 供 LLM 读取和理解 |
| **② LLM 决策** | 将"用户提问 + 可用工具描述"一起输入模型 | 模型内部判断是否需要调用工具 |
| **③ 结构化调用** | 模型输出 JSON 格式的调用指令 | 如 `{"name": "get_weather", "arguments": {"city": "London"}}` |
| **④ 执行层截获运行** | 框架根据 JSON 路由到真实函数 | 完成 API 请求、数据库查询、代码运行等 |
| **⑤ 结果回传** | 函数返回值作为新上下文再喂给 LLM | 让模型基于真实结果继续推理 |
| **⑥ 最终回答** | 模型结合原始提问与工具观察生成答案 | 若仍缺信息可循环 ②~⑤ |
### LangChain 代码示例
```python
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain.agents import create_agent
# ① 定义工具
@tool
def get_weather(city: str) -> str:
"""查询指定城市的天气"""
return f"{city}今天晴,25°C"
# ② 创建带工具的 Agent(内部编译出 StateGraph 循环)
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
agent = create_agent(llm, [get_weather])
# ③ 运行(自动进入 Agentic Loop
result = agent.invoke({
"messages": [{"role": "user", "content": "北京今天天气怎么样?"}]
})
print(result["messages"][-1].content)
# → 今天北京天气晴,气温25°C
```
---
## 九、适用场景与不适用场景
### ✅ 适合使用 Agentic Loop 的场景
| 场景特征 | 典型例子 |
|----------|----------|
| **步骤数无法事先确定** | "调研某个技术领域并输出完整报告" |
| **需要根据中间结果动态调整策略** | 搜索失败时换关键词重试;代码报错时换方案修复 |
| **任务完成度比速度更重要** | 深度资料分析、复杂代码重构 |
| **多步骤、长链路任务** | 自动办公流程、多轮内容创作 |
| **需要自我纠错的能力** | 排查 Bug、调试程序 |
### ❌ 不适合使用 Agentic Loop 的场景
| 场景特征 | 原因 | 更好的选择 |
|----------|------|------------|
| **固定序列的工作流** | 高度可预测、步骤固定 | 确定性的代码 Pipeline |
| **简单任务** | 一次 LLM + 一次工具调用就能解决 | 直接函数调用 |
| **严格的延迟约束** | 每次迭代都要调 LLM,时间和 Token 成本累积 | 预计算 / 缓存 |
---
## 十、实际应用案例
### 🖥️ 开发场景:Claude Code / Cursor / OpenClaw
每一次与编程助手的交互会话,本质上都是一个 Agentic Loop
```
读取用户请求 → 检查代码仓库 → 编辑文件 → 运行测试 → 识别报错 → 再次编辑 → ... → 构建成功
```
这套 **推理 → 行动 → 观察结果** 的往复流程,如今几乎所有的生产级编程智能体都以它为核心。
### 📊 办公场景
- 自动整理会议纪要 → 提炼重点 → 拆解待办 → 生成执行方案 → 跟进任务进度
- 读取表格数据 → 分析异常 → 生成可视化报告 → 给出优化建议
### ✍️ 内容场景
- 自主选题 → 搜索资料 → 梳理框架 → 撰写初稿 → 校对纠错 → 优化措辞 → 排版输出
### 🏠 智能硬件场景
扫地机器人避障规划、智能温控调节、自动驾驶路况判断——本质都是持续 **感知 → 决策 → 行动 → 修正** 的小型 Agent Loop 循环。
> **所有「越用越聪明、能自主干活」的 AI,核心都是 Agentic Loop。**
---
## 十一、关联循环体系
Agentic Loop 并非孤立存在,外部多层循环会直接影响其架构设计:
### 三大关联循环
```mermaid
flowchart TB
TL["🔄 训练循环<br/>数据采集 → 梯度更新 → 效果评估 → 版本发布<br/>离线流程 · 周期以天/周计"]
AL["🔄 Agentic Loop(智能体循环)<br/>推理 → 行动 → 观察 → 再推理<br/>在线实时流程 · 以秒计"]
FL["🔄 反馈循环<br/>工具返回 → 用户修正 → 量化指标 → 评估监控"]
HL["🔄 人工介入循环<br/>AI 暂停 → 人工审核 → 确认/修改 → 继续运行"]
AL -->|"产生交互数据"| FL
FL -->|"存入记忆库"| TL
AL -->|"触及权限边界"| HL
HL -->|"确认后继续"| AL
```
| 循环类型 | 性质 | 周期 | 说明 |
|----------|------|------|------|
| **训练循环** | 离线 | 天/周 | 大模型诞生的底层流程;现阶段与 Agentic Loop 完全解耦(模型权重固定) |
| **反馈循环** | 在线 | 实时 | 每次动作产生的反馈信号(工具返回、用户修正、量化指标);持续迭代进化的核心 |
| **人工介入循环** | 按需 | 不定 | AI 遇到无法自主决策的节点时主动暂停,等待人工确认 |
### ⚠️ 关键边界:训练循环 vs Agentic Loop
现阶段两类循环 **完全解耦**
- 模型训练完成后权重固定,Agent 在静态权重之上运行
- 对话中表现出的"记忆""学习""纠错",**并非更新模型权重**,只是从内存检索历史信息
- 分清两者边界才能精准定位问题:需要优化记忆存储?还是重新训练大模型?
---
## 十二、行业趋势与未来方向
### AI 技术的三次范式跃迁
```
判别式 AI ──▶ 生成式 AI ──▶ Agentic AI
(分类/预测) (ChatGPT) (自主执行任务)
```
英伟达 CEO 黄仁勋在 **2025 GTC 大会**上宣称:我们即将步入 **Agentic AI 时代**。英伟达称 Agentic AI 为 **"人工智能的下一个前沿"**。
### 从 Prompt Engineering 到 Loop Engineering
> **AI 的竞争,从「单次提示优化」变成了「循环系统设计」。**
| 过去(Prompt Engineering | 现在(Loop Engineering |
|---------------------------|--------------------------|
| 反复打磨提示词 | 设计更合理的 Agent Loop |
| 细化指令、预设场景 | 优化推理逻辑、调整循环策略 |
| 人工弥补 AI 不智能 | 让 AI 自己试错、自己优化 |
| 单次交互质量 | 循环系统效能 |
### 未来方向:打通全链路持续学习
当前智能体循环、模型训练循环、反馈循环分属三套独立开发体系。未来方向是将它们 **打通闭环**
```
Agentic Loop 产出真实交互经验
存入记忆库(高质量数据)
持续学习技术(Continual Learning
把经验融入模型参数
模型越来越强 → Agent 越来越聪明
```
届时 **记忆存储的数据质量将直接决定训练素材质量**——规整清晰的聊天记录、精准提取的关键信息、可靠的反馈评价,能产出高质量训练数据;杂乱无章、无规划存储的对话,无法用于模型迭代。
---
## 十三、核心价值总结
Agentic Loop 赋予了 AI 系统四大核心能力:
| 能力 | 图标 | 说明 |
|------|------|------|
| **自主性 (Autonomy)** | 🧠 | 无需持续人工干预即可持续推进任务 |
| **目标导向 (Goal-Directed)** | 🎯 | 理解抽象目标并将其转化为具体行动序列 |
| **动态适应 (Adaptive)** | 🔄 | 通过持续的环境反馈优化策略 |
| **多模态协作 (Multi-tool)** | 🔗 | 整合多源数据,支持跨工具/跨系统调用 |
### 一句话总结
> **没有 Agentic LoopAI Agent 就只是一个静态的问答工具;有了它,AI 才真正具备了"持续自主完成复杂任务"的能力。**
>
> **单次输出是工具,循环闭环才是智能。**
---
## 参考来源
### 核心论文
- Yao, S., et al. (2022). **ReAct: Synergizing Reasoning and Acting in Language Models** — [CSDN 解析](https://blog.csdn.net/enjoyedu/article/details/159416232)
### 深度解析文章
- [解读 Agent Loop(智能体循环)的三层分级体系](https://www.111cn.net/new/602954.htm) — 一聚教程网
- [看懂 Agent Loop:AI 从「被动问答」到「自主干活」的核心密码](https://cloud.tencent.com/developer/article/2694843) — 腾讯云开发者社区
- [推理 → 行动 → 观察:用 LangChain + Python 实现一个智能体循环](https://blog.csdn.net/m0_46510245/article/details/161346563)
- [OpenAI 解析 Codex CLI 核心机制:Agent Loop 工作流程详解](https://www.imooc.com/article/388435)
- [OpenClaw 源码深度解析:Agent Loop 如何调用 LLM 和工具](https://blog.csdn.net/ha_9527/article/details/158903245)
- [深入解析 Agent 内部机制:六大核心支柱](https://blog.csdn.net/l01011_/article/details/161116948)
- [Agent 底层运行逻辑拆解:读懂 Agent Loop 与 Turn 运行本质](https://blog.csdn.net/u013970991/article/details/161750174)
### 设计模式与架构
- [《Agentic Design Patterns》中文电子书](https://blog.csdn.net/weixin_54416957/article/details/159979689) — 涵盖反思、工具使用、规划、多智能体协作等模式
- [AI 智能体的系统架构与核心设计模式](https://blog.csdn.net/Code1994/article/details/151221580)
- [Agentic Design Patterns 第5章:Tool Use 工具调用模式](https://blog.csdn.net/wangyaninglm/article/details/153145056)
- [LangChain 源码解析:Function Call 是如何被执行的](https://cloud.tencent.com/developer/article/2657653)
- [Orchestrator 为什么比 Agentic Loop 快:LLM 决策与执行分离](https://so.html5.qq.com/page/real/search_news?docid=70000021_0236a280df558552)
### 行业趋势
- [2026 版 Agentic AI 从原理到实战完整指南](https://blog.csdn.net/weixin_59191169/article/details/160925182)
- [CES 2025Agentic AI 将如何改变未来的工作和生活](https://www.sohu.com/a/844233934_121902920)
- [2025 GTC 大会:Agentic AI 与 Robotic AI 的未来探讨](https://www.sohu.com/a/875875299_121902920)
- [IBM: What Is Agentic Reasoning?](https://www.ibm.com/think/topics/agentic-reasoning)
- [Human-in-the-Loop 人机协同策略](https://blog.csdn.net/Anspire/article/details/150609597)
- [一文读懂 Agentic AI 技术点滴](http://www.51testing.com/mobile/view.php?itemid=7804918)
---
> 📝 **文档版本**:v2.0(增强版)
> 📅 **最后更新**2026-07-11
> 📖 **本文档基于公开技术资料整理,涵盖概念解析、分层体系、代码实现、工程实践与行业趋势
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.15.0", "version": "0.16.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.15.0", "version": "0.16.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.15.0", "version": "0.16.0",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"author": "thzxx", "author": "thzxx",
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, { dialog.showMessageBox(mainWindow!, {
type: 'info', type: 'info',
title: '关于 Metona Ollama', title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v0.15.0', message: 'Metona Ollama Desktop v0.16.0',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath() icon: getIconPath()
}); });
+29
View File
@@ -378,15 +378,40 @@ let _streamRenderPending = false;
let _streamRenderContent = ''; let _streamRenderContent = '';
let _streamRenderThink: string | null = null; let _streamRenderThink: string | null = null;
let _streamRenderModel: string | undefined; let _streamRenderModel: string | undefined;
// R58: 流式渲染优化 — 增量阈值 + 内容指纹,避免高频小片段冗余重渲染
let _streamLastRenderedLen = 0; // 上次渲染时的内容长度
let _streamLastRenderedHash = 0; // 上次渲染时的内容哈希(简单 DJB2)
const STREAM_MIN_DELTA = 15; // 最小增量字符数(不足则跳过本次渲染)
/** R58: 快速 DJB2 哈希 */
function _djb2(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0x7fffffff;
}
return hash;
}
/** /**
* 执行实际的流式 Markdown 渲染 * 执行实际的流式 Markdown 渲染
* R58: 添加增量阈值检查 — 内容增长不足 STREAM_MIN_DELTA 字符时跳过渲染
*/ */
function _doStreamRender(): void { function _doStreamRender(): void {
_streamRenderPending = false; _streamRenderPending = false;
const lastMsg = currentPlaceholder; const lastMsg = currentPlaceholder;
if (!lastMsg) return; if (!lastMsg) return;
// R58: 增量阈值检查 — 内容变化不足时跳过本次渲染
const contentLen = _streamRenderContent.length;
const contentHash = _djb2(_streamRenderContent);
if (contentLen > 0 && contentHash === _streamLastRenderedHash) return; // 内容完全相同
if (contentLen - _streamLastRenderedLen < STREAM_MIN_DELTA && contentLen > 100) {
// 内容增长不足且已有内容 → 跳过本次,等下次 rAF
return;
}
_streamLastRenderedLen = contentLen;
_streamLastRenderedHash = contentHash;
const contentDiv = lastMsg.querySelector('.msg-content'); const contentDiv = lastMsg.querySelector('.msg-content');
if (contentDiv && _streamRenderContent) { if (contentDiv && _streamRenderContent) {
// 智能补全不完整的 Markdown 语法后渲染 // 智能补全不完整的 Markdown 语法后渲染
@@ -460,6 +485,10 @@ export function updateLastAssistantMessage(
requestAnimationFrame(_doStreamRender); requestAnimationFrame(_doStreamRender);
} }
} }
} else if (isFinal) {
// R58: 最终渲染时重置增量追踪状态
_streamLastRenderedLen = 0;
_streamLastRenderedHash = 0;
} }
let thinkBlock = lastMsg.querySelector('.think-block'); let thinkBlock = lastMsg.querySelector('.think-block');
+1 -1
View File
@@ -28,7 +28,7 @@
<div class="header-left"> <div class="header-left">
<img class="logo" src="./assets/icons/llama.png" alt="logo" /> <img class="logo" src="./assets/icons/llama.png" alt="logo" />
<span class="app-title">Metona Ollama</span> <span class="app-title">Metona Ollama</span>
<span class="app-version">v0.15.0</span> <span class="app-version">v0.16.0</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助"> <button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/> <circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
+382 -71
View File
@@ -9,19 +9,65 @@ import { state, KEYS } from '../state/state.js';
import { import {
executeTool, executeTool,
getEnabledToolDefinitions, getEnabledToolDefinitions,
getRelevantToolDefinitions,
needsConfirmation, needsConfirmation,
initPlanTracker, initPlanTracker,
getPlanTracker, getPlanTracker,
formatPlanStatus, formatPlanStatus,
clearPlanTracker, clearPlanTracker,
} from './tool-registry.js'; } from './tool-registry.js';
import {
compactOldToolResult,
detectOscillation,
recordToolCallHistory,
detectConsecutiveIdentical,
setUserGoal,
checkGoalAlignment,
resetAllSafetyState,
checkRateLimit,
classifyError,
calculateBackoff,
validatePathSandbox,
isToolCircuitBroken,
recordToolFailure,
recordToolSuccess,
// R88: 工具结果元数据
addResultMetadata,
// R97: 错误模式学习
recordErrorPattern,
// R104: 工具结果去重
isDuplicateToolResult,
// R109: 工具参数消毒
sanitizeToolArgs,
// R112: 诊断系统
collectDiagnostics,
// R113: 命令安全检查
checkCommandSafety,
// R95: 按工具类型智能截断
smartTruncateByToolType,
// R99: 工具结果引用解析
checkArchivedReferences,
} from './agent-safety.js';
import { search, formatMemoryContext, loadAllEntries } from './memory-service.js'; import { search, formatMemoryContext, loadAllEntries } from './memory-service.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js'; import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js'; import { getWorkspaceDirPath } from '../components/workspace-panel.js';
import { generateId } from '../utils/utils.js'; import { generateId } from '../utils/utils.js';
import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js'; import {
buildContext, estimateTokens, shouldAutoCompress, compressWithLLM,
AUTO_COMPRESS_THRESHOLD, recordActualTokens, predictContextOverflow, recordTokenUsage,
// R91: 上下文压力分级评估
getContextPressureLevel,
// R93: Token 预算追踪器
recordBudgetUsage, setTokenBudgetNumCtx, resetTokenBudget,
// R96: 消息角色压缩
mergeConsecutiveMessages,
// R98: 压缩触发阈值优化
getTrendAwareCompressThreshold,
// R100: Token 使用统计报告
generateTokenReport, formatTokenReport,
} from './context-manager.js';
import { runCompletionGate } from './completion-gate.js'; import { runCompletionGate } from './completion-gate.js';
import { executeHooks } from './hooks.js'; import { executeHooks } from './hooks.js';
import { recordIteration, recordToolCall, recordCompletionGate, startSessionMetrics, endSessionMetrics } from './agent-metrics.js'; import { recordIteration, recordToolCall, recordCompletionGate, startSessionMetrics, endSessionMetrics } from './agent-metrics.js';
@@ -43,6 +89,12 @@ const MAX_MESSAGES = 300; // 上下文硬上限,超过则强制压
const API_MAX_RETRIES = 2; // Ollama API 调用失败重试次数 const API_MAX_RETRIES = 2; // Ollama API 调用失败重试次数
const MAX_PARALLEL_TOOLS = 5; // R6: 单批次最大并行工具数 const MAX_PARALLEL_TOOLS = 5; // R6: 单批次最大并行工具数
// R51-R54: 工具结果离线存储、震荡检测、死循环检测已迁移至 agent-safety.ts
// R56: 目标对齐验证也已迁移至 agent-safety.ts
// R55: 语义工具检索 — 缓存当前轮过滤后的工具定义,避免 handleThinking 重复计算
let _filteredTools: import('../types.js').ToolDefinition[] = [];
/** /**
* 计算增量压缩触发阈值(按 token 数而非消息条数) * 计算增量压缩触发阈值(按 token 数而非消息条数)
* 当上下文 token 占比超过 numCtx 的 30% 时触发压缩 * 当上下文 token 占比超过 numCtx 的 30% 时触发压缩
@@ -145,45 +197,91 @@ const SIDE_EFFECT_TOOLS = new Set([
* 防止单个工具调用挂起导致整个 Agent Loop 卡死 * 防止单个工具调用挂起导致整个 Agent Loop 卡死
* 0 = 不限制(仅依赖全局 AbortController * 0 = 不限制(仅依赖全局 AbortController
*/ */
const TOOL_TIMEOUT_MAP: Record<string, number> = { /** R3/R108: 工具超时配置(基础超时 + 动态调整) */
read_file: 30_000, // 读取文件 30s const TOOL_TIMEOUT_MAP: Record<string, { base: number; grade: 'fast' | 'medium' | 'slow' | 'long'; description: string }> = {
write_file: 15_000, // 写入文件 15s // fast (<5秒)
edit_file: 15_000, // 编辑文件 15s datetime: { base: 1_000, grade: 'fast', description: '时间查询' },
list_directory: 10_000, // 列目录 10s random: { base: 1_000, grade: 'fast', description: '随机数' },
search_files: 60_000, // 搜索文件 60s uuid: { base: 1_000, grade: 'fast', description: 'UUID' },
create_directory: 5_000, // 创建目录 5s calculator: { base: 3_000, grade: 'fast', description: '计算器' },
delete_file: 10_000, // 删除文件 10s json_format: { base: 3_000, grade: 'fast', description: 'JSON 格式化' },
move_file: 15_000, // 移动文件 15s hash: { base: 3_000, grade: 'fast', description: '哈希计算' },
copy_file: 30_000, // 复制文件 30s(可能大文件) create_directory: { base: 5_000, grade: 'fast', description: '创建目录' },
get_file_info: 5_000, // 获取文件信息 5s get_file_info: { base: 5_000, grade: 'fast', description: '获取文件信息' },
tree: 15_000, // 目录树 15s memory: { base: 5_000, grade: 'fast', description: '记忆操作' },
diff_files: 15_000, // 文件差异 15s session_list: { base: 5_000, grade: 'fast', description: '会话列表' },
replace_in_files: 30_000, // 批量替换 30s
read_multiple_files: 60_000,// 批量读取 60s // medium (5-15秒)
web_search: 30_000, // 网页搜索 30s list_directory: { base: 10_000, grade: 'medium', description: '列目录' },
web_fetch: 60_000, // 网页抓取 60s delete_file: { base: 10_000, grade: 'medium', description: '删除文件' },
download_file: 120_000, // 下载文件 120s write_file: { base: 15_000, grade: 'medium', description: '写入文件' },
git: 30_000, // Git 操作 30s edit_file: { base: 15_000, grade: 'medium', description: '编辑文件' },
compress: 60_000, // 压缩 60s move_file: { base: 15_000, grade: 'medium', description: '移动文件' },
memory: 5_000, // 记忆操作 5s tree: { base: 15_000, grade: 'medium', description: '目录树' },
session_list: 5_000, // 会话列表 5s diff_files: { base: 15_000, grade: 'medium', description: '文件差异' },
session_read: 10_000, // 会话读取 10s session_read: { base: 10_000, grade: 'medium', description: '会话读取' },
datetime: 1_000, // 时间查询 1s web_search: { base: 30_000, grade: 'medium', description: '网页搜索' },
calculator: 3_000, // 计算器 3s git: { base: 30_000, grade: 'medium', description: 'Git 操作' },
random: 1_000, // 随机数 1s
uuid: 1_000, // UUID 1s // slow (15-60秒)
json_format: 3_000, // JSON 格式化 3s read_file: { base: 30_000, grade: 'slow', description: '读取文件' },
hash: 3_000, // 哈希计算 3s copy_file: { base: 30_000, grade: 'slow', description: '复制文件' },
default: 60_000, // 默认 60s replace_in_files: { base: 30_000, grade: 'slow', description: '批量替换' },
search_files: { base: 60_000, grade: 'slow', description: '搜索文件' },
read_multiple_files: { base: 60_000, grade: 'slow', description: '批量读取' },
web_fetch: { base: 60_000, grade: 'slow', description: '网页抓取' },
compress: { base: 60_000, grade: 'slow', description: '压缩' },
// long (>60秒)
download_file: { base: 120_000, grade: 'long', description: '下载文件' },
default: { base: 60_000, grade: 'medium', description: '默认工具' },
}; };
/** R108: 根据工具参数动态调整超时时间 */
function getAdjustedToolTimeout(toolName: string, args: Record<string, unknown>): number {
const config = TOOL_TIMEOUT_MAP[toolName] ?? TOOL_TIMEOUT_MAP.default;
let timeout = config.base;
// run_command 特殊处理:根据命令类型调整
if (toolName === 'run_command' && args.command) {
const cmd = String(args.command);
if (cmd.includes('npm install') || cmd.includes('pip install') || cmd.includes('apt-get install')) {
timeout = 300_000; // 包安装:5分钟
} else if (cmd.includes('build') || cmd.includes('compile') || cmd.includes('make')) {
timeout = 180_000; // 编译:3分钟
} else if (cmd.includes('test')) {
timeout = 120_000; // 测试:2分钟
} else {
timeout = 120_000; // 默认命令:2分钟
}
}
// web_fetch 特殊处理:视频/大型资源
if (toolName === 'web_fetch' && args.url) {
const url = String(args.url);
if (url.includes('youtube') || url.includes('video') || url.includes('mp4')) {
timeout = 90_000;
}
}
// write_file 特殊处理:大文件
if (toolName === 'write_file' && args.content) {
const contentLen = String(args.content).length;
if (contentLen > 100_000) {
timeout = 30_000; // 大文件写入
}
}
return timeout;
}
/** R3: 带超时的工具执行包装器 */ /** R3: 带超时的工具执行包装器 */
async function executeToolWithTimeout( async function executeToolWithTimeout(
toolName: string, toolName: string,
args: Record<string, unknown>, args: Record<string, unknown>,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<ToolResult> { ): Promise<ToolResult> {
const timeoutMs = TOOL_TIMEOUT_MAP[toolName] ?? TOOL_TIMEOUT_MAP.default; const timeoutMs = getAdjustedToolTimeout(toolName, args);
if (timeoutMs <= 0) { if (timeoutMs <= 0) {
return executeTool(toolName, args); return executeTool(toolName, args);
} }
@@ -1084,9 +1182,15 @@ async function handleInit(
): Promise<void> { ): Promise<void> {
// 新一轮对话,清空工具缓存 // 新一轮对话,清空工具缓存
toolResultCache.clear(); toolResultCache.clear();
resetAllSafetyState(); // R51-R56: 重置所有安全状态
resetTokenBudget(); // R93: 重置 Token 预算追踪
setTokenBudgetNumCtx(getEffectiveNumCtx()); // R93: 设置当前预算 numCtx
setUserGoal(userContent); // R56: 设置用户原始目标
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false); const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
const tools = getEnabledToolDefinitions(); // R55: 语义工具检索 — 根据用户查询过滤相关工具,减少 token 占用
const tools = getRelevantToolDefinitions(userContent);
_filteredTools = tools; // 缓存供 handleThinking 使用
const useTools = tools.length > 0; const useTools = tools.length > 0;
ctx.messages.length = 0; ctx.messages.length = 0;
@@ -1568,7 +1672,7 @@ async function handleThinking(
num_ctx: getEffectiveNumCtx(), num_ctx: getEffectiveNumCtx(),
temperature: state.get<number>('temperature', 0.7) temperature: state.get<number>('temperature', 0.7)
}, },
...(getEnabledToolDefinitions().length > 0 && { tools: getEnabledToolDefinitions() }) ...(_filteredTools.length > 0 && { tools: _filteredTools })
}, },
(chunk: OllamaStreamChunk) => { (chunk: OllamaStreamChunk) => {
if (chunk.message?.thinking) { if (chunk.message?.thinking) {
@@ -1710,6 +1814,8 @@ async function handleThinking(
if (estimatedThisLoop > 0) { if (estimatedThisLoop > 0) {
recordActualTokens(ctx.loopPromptEvalCount, ctx.loopEvalCount, estimatedThisLoop, model); recordActualTokens(ctx.loopPromptEvalCount, ctx.loopEvalCount, estimatedThisLoop, model);
} }
// R93: 记录 Token 预算使用
recordBudgetUsage(ctx.loopCount, ctx.loopPromptEvalCount, ctx.loopEvalCount, estimatedThisLoop);
} }
} catch { /* ignore */ } } catch { /* ignore */ }
@@ -1845,6 +1951,25 @@ async function handleExecuting(
toolResultCache.delete(cacheKey); toolResultCache.delete(cacheKey);
} }
// R109: 工具参数消毒 — 防止通过工具参数注入恶意内容
call.function.arguments = sanitizeToolArgs(call.function.name, call.function.arguments);
// R113: 命令安全检查 — 对 run_command 进行风险评估
if (call.function.name === 'run_command') {
const cmdStr = String(call.function.arguments?.command || '');
if (cmdStr) {
const cmdSafety = checkCommandSafety(cmdStr);
if (cmdSafety.riskLevel === 'forbidden') {
logWarn(`R113: 命令安全拦截: ${cmdSafety.reason}`);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: cmdSafety.reason || '命令被安全规则拦截' },
status: 'error' as const, timestamp: Date.now()
}, null];
}
}
}
// P2-4: 工具参数前置校验 — 参数无效直接返回错误,不执行实际工具,节省一轮迭代 // P2-4: 工具参数前置校验 — 参数无效直接返回错误,不执行实际工具,节省一轮迭代
const paramError = validateToolArgs(call.function.name, call.function.arguments); const paramError = validateToolArgs(call.function.name, call.function.arguments);
if (paramError) { if (paramError) {
@@ -1856,6 +1981,62 @@ async function handleExecuting(
}, null]; }, null];
} }
// R77: 工具调用速率限制 — 防止快速连续调用同一工具浪费资源
const rateLimitResult = checkRateLimit(call.function.name);
if (!rateLimitResult.allowed) {
const waitSec = Math.ceil(rateLimitResult.retryAfterMs / 1000);
logWarn(`R77: 速率限制拦截: ${call.function.name},需等待 ${waitSec}s`);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: `工具「${call.function.name}」调用频率过高,请等待 ${waitSec} 秒后重试。` },
status: 'error' as const, timestamp: Date.now()
}, null];
}
// R87: 工具熔断器检查 — 连续失败后自动禁用工具
const circuitBreaker = isToolCircuitBroken(call.function.name);
if (circuitBreaker.broken) {
const waitSec = Math.ceil(circuitBreaker.remainingMs / 1000);
logWarn(`R87: 熔断器拦截: ${call.function.name},冷却中(还需 ${waitSec}s`);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: `工具「${call.function.name}」因连续失败已被暂时禁用,请等待 ${waitSec} 秒后重试,或使用其他方法。` },
status: 'error' as const, timestamp: Date.now()
}, null];
}
// R79: 路径沙箱验证 — 确保文件操作不超出工作空间边界
const FILE_PATH_TOOLS = new Set([
'read_file', 'write_file', 'edit_file', 'delete_file', 'create_directory',
'list_directory', 'search_files', 'get_file_info', 'tree', 'compress',
'move_file', 'copy_file', 'replace_in_files', 'download_file',
]);
if (FILE_PATH_TOOLS.has(call.function.name)) {
const workspaceDir = getWorkspaceDirPath();
if (workspaceDir) {
const pathArg = String(call.function.arguments?.path || call.function.arguments?.source || call.function.arguments?.destination || call.function.arguments?.file1 || '');
if (pathArg) {
const sandboxResult = validatePathSandbox(pathArg, workspaceDir);
if (!sandboxResult.valid) {
logWarn(`R79: 路径沙箱拦截: ${call.function.name}(${pathArg}) — ${sandboxResult.reason}`);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: sandboxResult.reason || '路径不在工作空间范围内' },
status: 'error' as const, timestamp: Date.now()
}, null];
}
// R79: 使用标准化路径替换原始路径,防止路径遍历
if (sandboxResult.normalizedPath && sandboxResult.normalizedPath !== pathArg) {
if (call.function.arguments?.path) {
call.function.arguments.path = sandboxResult.normalizedPath;
} else if (call.function.arguments?.source) {
call.function.arguments.source = sandboxResult.normalizedPath;
}
}
}
}
}
// P2-10 修复:queueMicrotask 替代 rAF,在隐藏窗口下不会降频 // P2-10 修复:queueMicrotask 替代 rAF,在隐藏窗口下不会降频
await new Promise<void>(r => { queueMicrotask(r); }); await new Promise<void>(r => { queueMicrotask(r); });
callbacks.onToolCallStart(call); callbacks.onToolCallStart(call);
@@ -1887,8 +2068,9 @@ async function handleExecuting(
} }
} }
// ── P0-1 修复:区分瞬态错误和永久错误 ── // ── R78: 增强错误分类重试 — 使用 classifyError 区分瞬态/永久/安全错误 ──
let lastError = ''; let lastError = '';
let classifiedError: import('./agent-safety.js').ClassifiedError | null = null;
for (let retry = 0; retry <= MAX_RETRIES; retry++) { for (let retry = 0; retry <= MAX_RETRIES; retry++) {
// 重试前检查中止信号 // 重试前检查中止信号
if (isAborted()) { if (isAborted()) {
@@ -1903,6 +2085,12 @@ async function handleExecuting(
state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal) state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal)
.catch(err => ({ success: false, error: err?.message || String(err) }) as ToolResult); .catch(err => ({ success: false, error: err?.message || String(err) }) as ToolResult);
logToolResult(call.function.name, result.success, result.success ? undefined : result.error); logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
// R87: 记录工具成功/失败到熔断器
if (result.success) {
recordToolSuccess(call.function.name);
} else {
recordToolFailure(call.function.name);
}
// 记录 write_file 成功路径及内容指纹,供 FileWriteDedup Hook 做内容级去重 // 记录 write_file 成功路径及内容指纹,供 FileWriteDedup Hook 做内容级去重
if (call.function.name === 'write_file' && result.success && call.function.arguments?.path) { if (call.function.name === 'write_file' && result.success && call.function.arguments?.path) {
const { addWrittenFile } = await import('./hooks.js'); const { addWrittenFile } = await import('./hooks.js');
@@ -1914,27 +2102,47 @@ async function handleExecuting(
}, result.success ? cacheKey : null]; }, result.success ? cacheKey : null];
} catch (err) { } catch (err) {
lastError = (err as Error).message; lastError = (err as Error).message;
// 永久性错误不重试:文件不存在、权限拒绝、路径不可达 // R78: 使用错误分类系统替代原有的 isPermanentError
if (isPermanentError(lastError)) { classifiedError = classifyError(lastError);
logWarn(`工具永久错误(不重试): ${call.function.name}`, lastError);
// 安全错误或永久错误:不重试
if (!classifiedError.shouldRetry) {
logWarn(`R78: 工具${classifiedError.class}错误(不重试): ${call.function.name}`, classifiedError.userMessage);
return [{ return [{
name: call.function.name, arguments: call.function.arguments, name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: lastError }, result: { success: false, error: classifiedError.userMessage },
status: 'error' as const, timestamp: Date.now() status: 'error' as const, timestamp: Date.now()
}, null]; }, null];
} }
// R78: 检查是否超过最大重试次数
if (retry >= (classifiedError.maxRetries || MAX_RETRIES)) {
logError(`R78: 工具执行失败(已达最大重试 ${classifiedError.maxRetries}: ${call.function.name}`, lastError);
// R97: 记录错误模式并获取改进建议
const errorSuggestion = recordErrorPattern(call.function.name, lastError);
if (errorSuggestion) {
logWarn(`R97: ${errorSuggestion}`);
}
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: classifiedError.userMessage },
status: 'error' as const, timestamp: Date.now()
}, null];
}
if (retry < MAX_RETRIES) { if (retry < MAX_RETRIES) {
// 瞬态错误:指数退避重试 // R78: 使用 calculateBackoff 计算指数退避延迟
const delay = 500 * Math.pow(2, retry); const delay = calculateBackoff(retry, classifiedError.backoffMs);
logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}${delay}ms 后)`, lastError); logWarn(`R78: 工具重试 ${retry + 1}/${classifiedError.maxRetries}: ${call.function.name}${classifiedError.class}错误,${delay}ms 后)`, lastError);
await new Promise(r => setTimeout(r, delay)); await new Promise(r => setTimeout(r, delay));
continue; continue;
} }
logError(`工具执行失败(已达最大重试): ${call.function.name}`, lastError); logError(`工具执行失败(已达最大重试): ${call.function.name}`, lastError);
// R97: 记录错误模式
recordErrorPattern(call.function.name, lastError);
return [{ return [{
name: call.function.name, arguments: call.function.arguments, name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: lastError }, result: { success: false, error: lastError }, status: 'error' as const, timestamp: Date.now()
status: 'error' as const, timestamp: Date.now()
}, null]; }, null];
} }
} }
@@ -1962,9 +2170,23 @@ async function handleExecuting(
for (const [record, cacheKey] of results) { for (const [record, cacheKey] of results) {
ctx.allToolRecords.push(record); ctx.allToolRecords.push(record);
// R88: 为工具结果添加元数据提示(token 估算)
const formattedResult = addResultMetadata(formatToolResultForModel(record.name, record.result!));
// R104: 检测重复工具结果
if (record.status === 'success') {
const dedup = isDuplicateToolResult(record.name, formattedResult);
if (dedup.duplicate) {
logInfo(`R104: 检测到重复工具结果: ${record.name},添加标记`);
}
}
// R92: 工具结果格式标准化 — 添加统一头信息(工具名、状态、耗时、结果大小)
const resultDuration = Date.now() - record.timestamp;
const resultSize = formattedResult.length;
const sizeCategory = resultSize > 10000 ? 'large' : resultSize > 2000 ? 'medium' : 'small';
const r92Header = `[工具:${record.name} 状态:${record.status} 耗时:${resultDuration}ms 大小:${sizeCategory}(${resultSize}字符)]`;
ctx.messages.push({ ctx.messages.push({
role: 'tool', tool_name: record.name, role: 'tool', tool_name: record.name,
content: `<<<TOOL_RESULT_START name="${record.name}">>>\n${formatToolResultForModel(record.name, record.result!)}\n<<<TOOL_RESULT_END>>>` content: `<<<TOOL_RESULT_START name="${record.name}">>>\n${r92Header}\n${formattedResult}\n<<<TOOL_RESULT_END>>>`
}); });
// P1-3 优化:移除逐次 nudge 注入,改为 AGENT.md 通用规则 // P1-3 优化:移除逐次 nudge 注入,改为 AGENT.md 通用规则
// 原逻辑:memory/session_read/session_list/spawn_task 后注入额外 user 消息 // 原逻辑:memory/session_read/session_list/spawn_task 后注入额外 user 消息
@@ -2006,13 +2228,15 @@ async function handleObserving(
): Promise<void> { ): Promise<void> {
// 中止检查 // 中止检查
if (isAborted()) return; if (isAborted()) return;
// 硬限制工具消息数量 — 保留最近 80 条(token感知:压力大时自动缩减) // R91: 上下文压力分级评估 — 根据压力等级选择压缩策略
const numCtx = getEffectiveNumCtx();
const pressureInfo = getContextPressureLevel(ctx.messages, numCtx);
{ {
const numCtx = getEffectiveNumCtx(); // R91: 根据压力等级动态调整工具消息保留数量
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join('')); const maxToolMsgs = pressureInfo.level === 'critical' ? 30
const pressureRatio = numCtx > 0 ? currentTokens / numCtx : 0; : pressureInfo.level === 'high' ? 40
// 压力小时保留 80 条,压力大时缩减到 40 条 : pressureInfo.level === 'medium' ? 60
const maxToolMsgs = pressureRatio > 0.5 ? 40 : 80; : 80;
const toolIndices: number[] = []; const toolIndices: number[] = [];
for (let i = 0; i < ctx.messages.length; i++) { for (let i = 0; i < ctx.messages.length; i++) {
if (ctx.messages[i].role === 'tool') toolIndices.push(i); if (ctx.messages[i].role === 'tool') toolIndices.push(i);
@@ -2022,7 +2246,7 @@ async function handleObserving(
for (let i = toRemove.length - 1; i >= 0; i--) { for (let i = toRemove.length - 1; i >= 0; i--) {
ctx.messages.splice(toRemove[i], 1); ctx.messages.splice(toRemove[i], 1);
} }
logInfo(`工具消息裁剪: ${toRemove.length} 条旧结果已移除 (保留最近 ${maxToolMsgs} 条, 压力 ${(pressureRatio * 100).toFixed(0)}%)`); logInfo(`R91: 工具消息裁剪: ${toRemove.length} 条旧结果已移除 (压力:${pressureInfo.level}, 保留 ${maxToolMsgs})`);
} }
} }
@@ -2034,6 +2258,75 @@ async function handleObserving(
// 保存本轮工具调用 // 保存本轮工具调用
ctx.prevToolCalls = [...ctx.toolCalls]; ctx.prevToolCalls = [...ctx.toolCalls];
// R52: 状态震荡检测 — 检测 A→B→A→B 模式
for (const call of ctx.toolCalls) {
recordToolCallHistory(call.function.name, call.function.arguments);
}
if (detectOscillation()) {
logWarn('R52: 检测到工具调用震荡模式(A→B→A→B),注入终止信号');
ctx.messages.push({
role: 'user',
content: '⚠️ 检测到你在两种操作之间来回切换但没有进展。请停下来分析当前状态,换一种策略,或基于已有结果给出最终回答。',
ephemeral: true,
});
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 2);
}
// R54: 增强死循环检测 — 连续 3 次完全相同的工具调用(文档标准)
const consecutive = detectConsecutiveIdentical(3);
if (consecutive.detected) {
logWarn(`R54: 检测到连续 ${consecutive.count} 次相同调用: ${consecutive.toolName},强制终止`);
ctx.messages.push({
role: 'user',
content: `⛔ 检测到连续 ${consecutive.count} 次用完全相同参数调用「${consecutive.toolName}」。这已被系统识别为死循环。立即停止重复调用,基于已有结果给出最终回答。`,
});
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1);
}
// R53: 主动上下文压缩 — 使用预测系统提前触发压缩
{
const numCtx = getEffectiveNumCtx();
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
recordTokenUsage(currentTokens, numCtx);
const prediction = predictContextOverflow(numCtx);
if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) {
logWarn(`R53: 主动压缩触发 — ${prediction.message}`);
transition(ctx, S.COMPRESSING);
return;
}
}
// R51: 工具结果离线存储 — 将旧的大工具结果精简为引用
if (ctx.loopCount > 5 && ctx.loopCount % 3 === 0) {
const compactBefore = ctx.messages.length - 10; // 保留最近 10 条不动
let compactedCount = 0;
for (let i = 0; i < ctx.messages.length && i < compactBefore; i++) {
const m = ctx.messages[i];
if (m.role === 'tool' && m.content && m.content.length > 1500 && !m.content.startsWith('[工具结果已归档]')) {
ctx.messages[i] = compactOldToolResult(m);
compactedCount++;
}
}
if (compactedCount > 0) {
logInfo(`R51: 工具结果归档: ${compactedCount} 条旧结果已精简为引用`);
}
}
// R56: 目标对齐验证 — 每 8 轮检查 Agent 是否偏离用户原始目标
if (ctx.loopCount > 0 && ctx.loopCount % 8 === 0) {
const recentToolNames = ctx.allToolRecords.slice(-8).map(r => r.name);
const recentContent = ctx.messages.slice(-10).map(m => m.content || '').join(' ').slice(0, 2000);
const alignment = checkGoalAlignment(recentToolNames, recentContent, ctx.loopCount);
if (!alignment.aligned && alignment.suggestion) {
logWarn(`R56: 目标对齐检测 — ${alignment.reason}`);
ctx.messages.push({
role: 'user',
content: `[目标对齐提醒] ${alignment.suggestion}`,
ephemeral: true,
});
}
}
// P2-1: 工具调用失败恢复指引 — 同一工具同一参数连续失败2次以上时注入恢复建议 // P2-1: 工具调用失败恢复指引 — 同一工具同一参数连续失败2次以上时注入恢复建议
{ {
const errorRecords = ctx.allToolRecords.filter(r => r.status === 'error'); const errorRecords = ctx.allToolRecords.filter(r => r.status === 'error');
@@ -2057,30 +2350,43 @@ async function handleObserving(
} }
} }
// R112: 每 15 轮输出诊断报告 + R100: Token 使用统计报告
if (ctx.loopCount > 0 && ctx.loopCount % 15 === 0) {
try {
const { collectDiagnostics, formatDiagnosticsReport } = await import('./agent-safety.js');
const diag = collectDiagnostics();
logInfo('R112: 诊断报告\n' + formatDiagnosticsReport(diag));
// R100: Token 使用统计报告
const tokenReport = generateTokenReport(getEffectiveNumCtx());
logInfo('R100: ' + formatTokenReport(tokenReport));
} catch { /* 诊断失败不影响主流程 */ }
}
// 记录本轮迭代度量 // 记录本轮迭代度量
recordIteration(ctx); recordIteration(ctx);
// ── post_iteration Hook ── // ── post_iteration Hook ──
executeHooks('post_iteration', ctx, { iterationIndex: ctx.loopCount }); executeHooks('post_iteration', ctx, { iterationIndex: ctx.loopCount });
// ── P1-7: 智能工具结果截断 — 保留 JSON 结构,token 感知软上限 ── // R95: 按工具类型智能截断 — 根据压力等级和工具类型选择截断策略
// 仅在上下文压力较大时触发(token使用率 > 50%),且截断阈值按当前压力动态调整
if (ctx.loopCount > 10 && ctx.loopCount % 5 === 0) { if (ctx.loopCount > 10 && ctx.loopCount % 5 === 0) {
const numCtx = getEffectiveNumCtx(); if (pressureInfo.level === 'high' || pressureInfo.level === 'critical') {
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
const pressureRatio = numCtx > 0 ? currentTokens / numCtx : 0;
if (pressureRatio > 0.5) {
// 上下文压力大时,截断旧的工具结果(保留最近15条不动) // 上下文压力大时,截断旧的工具结果(保留最近15条不动)
// 截断阈值按压力动态调整50%压力=8000字符,80%压力=3000字符 // R91: 截断阈值按压力等级动态调整
const maxLen = Math.max(2000, Math.floor(8000 - (pressureRatio - 0.5) * 10000)); const maxLen = pressureInfo.level === 'critical' ? 3000 : 5000;
const truncateBefore = ctx.messages.length - 15; const truncateBefore = ctx.messages.length - 15;
let truncatedCount = 0;
for (let i = 0; i < ctx.messages.length && i < truncateBefore; i++) { for (let i = 0; i < ctx.messages.length && i < truncateBefore; i++) {
const m = ctx.messages[i]; const m = ctx.messages[i];
if (m.role === 'tool' && m.content && m.content.length > maxLen) { if (m.role === 'tool' && m.content && m.content.length > maxLen) {
ctx.messages[i] = { ...m, content: smartTruncate(m.content, maxLen) }; // R95: 使用按工具类型截断替代通用截断
ctx.messages[i] = { ...m, content: smartTruncateByToolType(m.tool_name || 'unknown', m.content, maxLen) };
truncatedCount++;
} }
} }
logInfo(`工具结果截断: 压力 ${(pressureRatio * 100).toFixed(0)}%, 阈值 ${maxLen} 字符`); if (truncatedCount > 0) {
logInfo(`R95: 工具结果按类型截断: ${truncatedCount} 条, 压力 ${pressureInfo.level}, 阈值 ${maxLen} 字符`);
}
} }
} }
@@ -2139,14 +2445,15 @@ async function handleObserving(
} }
} }
// ── P0-4: 增量压缩 — 按token使用率触发,防止 Ollama context 超限 ── // R96: 消息角色压缩 — 合并连续相同角色消息,减少消息条数开销
const numCtx = getEffectiveNumCtx(); if (pressureInfo.level === 'medium' || pressureInfo.level === 'high') {
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join('')); ctx.messages = mergeConsecutiveMessages(ctx.messages);
const tokenUsageRatio = numCtx > 0 ? currentTokens / numCtx : 0; }
// 当 token 使用率超过 30% 或消息数超过动态阈值时触发压缩
const compressThreshold = getIncrementalCompressThreshold(numCtx); // R98: 趋势感知压缩触发 — 结合趋势预测动态调整压缩阈值
if (tokenUsageRatio > 0.3 || ctx.messages.length >= compressThreshold) { const compressDecision = getTrendAwareCompressThreshold(numCtx, ctx.messages);
logWarn(`增量压缩触发: token使用率 ${(tokenUsageRatio * 100).toFixed(0)}%, 消息数 ${ctx.messages.length}/${compressThreshold}`); if (compressDecision.shouldCompress) {
logWarn(`R98: 压缩触发 (${compressDecision.urgency}) — ${compressDecision.reason}`);
transition(ctx, S.COMPRESSING); transition(ctx, S.COMPRESSING);
return; return;
} }
@@ -2340,6 +2647,10 @@ async function handleReflecting(
} else { } else {
recordCompletionGate(true); recordCompletionGate(true);
} }
// R90: 记录完成门控评分到日志
if (gateResult.score < 100) {
logInfo(`R90: Completion Gate 评分: ${gateResult.score}/100`);
}
} catch { /* Gate 异常不影响主流程 */ } } catch { /* Gate 异常不影响主流程 */ }
logInfo('模型停止工具调用 → ReAct Agent Loop 结束'); logInfo('模型停止工具调用 → ReAct Agent Loop 结束');
+63
View File
@@ -101,9 +101,72 @@ export function endSessionMetrics(): SessionMetrics | null {
`${formatPercent(getToolSuccessRate(metrics))} 成功率` `${formatPercent(getToolSuccessRate(metrics))} 成功率`
); );
currentMetrics = null; currentMetrics = null;
// R84: 持久化度量历史到 localStorage
persistMetricsHistory();
return metrics; return metrics;
} }
// ═══════════════════════════════════════════════════════════════
// R84: 度量持久化 — localStorage 存储与恢复
// ═══════════════════════════════════════════════════════════════
const METRICS_STORAGE_KEY = '_metona_agent_metrics';
const METRICS_HISTORY_MAX = 100;
/** R84: 将度量历史保存到 localStorage */
function persistMetricsHistory(): void {
try {
const recent = sessionMetricsHistory.slice(-METRICS_HISTORY_MAX);
const serialized = recent.map(m => ({
sessionId: m.sessionId,
model: m.model,
duration: m.endTime - m.startTime,
iterations: m.totalIterations,
toolCallCount: m.toolCalls.length,
toolSuccessRate: getToolSuccessRate(m),
completionGatePassed: m.completionGatePassed,
errorPatterns: m.errorPatterns,
inputTokens: m.totalInputTokens,
outputTokens: m.totalOutputTokens,
timestamp: m.endTime,
}));
localStorage.setItem(METRICS_STORAGE_KEY, JSON.stringify(serialized));
} catch {
// localStorage 满或不可用时静默忽略
}
}
/** R84: 从 localStorage 恢复度量历史 */
export function loadMetricsHistory(): void {
try {
const raw = localStorage.getItem(METRICS_STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as Array<Record<string, any>>;
for (const item of parsed) {
sessionMetricsHistory.push({
sessionId: item.sessionId || 'unknown',
model: item.model || 'unknown',
startTime: item.timestamp ? item.timestamp - (item.duration || 0) : 0,
endTime: item.timestamp || 0,
totalIterations: item.iterations || 0,
toolCalls: [],
totalInputTokens: item.inputTokens || 0,
totalOutputTokens: item.outputTokens || 0,
completionGatePassed: item.completionGatePassed || false,
errorPatterns: item.errorPatterns || [],
});
}
logInfo(`R84: 恢复了 ${sessionMetricsHistory.length} 条历史度量`);
} catch {
// 解析失败静默忽略
}
}
/** R84: 获取度量历史(供 UI 仪表盘使用) */
export function getMetricsHistory(): SessionMetrics[] {
return [...sessionMetricsHistory];
}
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// 度量计算 // 度量计算
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
File diff suppressed because it is too large Load Diff
+93 -8
View File
@@ -105,6 +105,32 @@ const contextEfficiencyCheck: CompletionCheck = {
}, },
}; };
/** R83: Plan Mode 完成检查 — 验证所有计划步骤是否已标记完成 */
const planModeCompletionCheck: CompletionCheck = {
name: 'planModeCompletion',
description: 'Plan Mode 下检查所有计划步骤是否已完成',
check: async (ctx: LoopContext) => {
if (ctx.mode !== 'plan') return { passed: true, reason: '' };
// 从 state 获取 Plan Tracker
try {
const { getPlanTracker } = await import('./tool-registry.js');
const tracker = getPlanTracker();
if (!tracker.active || tracker.steps.length === 0) return { passed: true, reason: '' };
if (tracker.done < tracker.total) {
const remaining = tracker.total - tracker.done;
const undoneSteps = tracker.steps.filter(s => !s.done).map(s => s.label).slice(0, 3);
return {
passed: false,
reason: `Plan Mode: 还有 ${remaining} 步未完成(${undoneSteps.join('、')}${remaining > 3 ? '...' : ''})。请继续执行剩余步骤,或说明为什么这些步骤无法完成。`
};
}
return { passed: true, reason: '' };
} catch {
return { passed: true, reason: '' };
}
},
};
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// 检查项注册与管理 // 检查项注册与管理
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -114,6 +140,7 @@ const DEFAULT_ENABLED_CHECKS = new Set([
'contentQuality', 'contentQuality',
'toolResultReview', 'toolResultReview',
'notThinking', 'notThinking',
'planModeCompletion',
]); ]);
/** 注册表 */ /** 注册表 */
@@ -122,6 +149,7 @@ const checkRegistry = new Map<string, CompletionCheck>([
['toolResultReview', toolResultReviewCheck], ['toolResultReview', toolResultReviewCheck],
['notThinking', notThinkingCheck], ['notThinking', notThinkingCheck],
['contextEfficiency', contextEfficiencyCheck], ['contextEfficiency', contextEfficiencyCheck],
['planModeCompletion', planModeCompletionCheck],
]); ]);
/** 已启用的检查项 */ /** 已启用的检查项 */
@@ -165,54 +193,104 @@ export function getEnabledChecks(): CompletionCheck[] {
export interface GateResult { export interface GateResult {
passed: boolean; passed: boolean;
reason: string; reason: string;
/** 各项检查的详细结果 */ /** R90: 各项检查的详细结果 */
details: Array<{ name: string; passed: boolean; reason: string; durationMs: number }>; details: Array<{ name: string; passed: boolean; reason: string; durationMs: number }>;
/** R90/R127: 完成门控评分(0-100 */
score: number;
/** R90/R127: 评分明细 */
scoreBreakdown: Array<{ check: string; passed: boolean; points: number }>;
} }
/** R127: 各检查项的权重分配 */
const CHECK_WEIGHTS: Record<string, number> = {
contentQuality: 30,
toolResultReview: 25,
notThinking: 20,
contextEfficiency: 15,
planModeCompletion: 10,
};
/** /**
* 运行完成门控检查 * 运行完成门控检查
* R90: 返回评分结果,评分基于各检查项的权重
*/ */
export async function runCompletionGate(ctx: LoopContext): Promise<GateResult> { export async function runCompletionGate(ctx: LoopContext): Promise<GateResult> {
const checks = getEnabledChecks(); const checks = getEnabledChecks();
if (checks.length === 0) { if (checks.length === 0) {
return { passed: true, reason: '', details: [] }; return { passed: true, reason: '', details: [], score: 100, scoreBreakdown: [] };
} }
const details: GateResult['details'] = []; const details: GateResult['details'] = [];
const scoreBreakdown: GateResult['scoreBreakdown'] = [];
let totalScore = 0;
let maxScore = 0;
for (const check of checks) { for (const check of checks) {
const start = Date.now(); const start = Date.now();
try { try {
const result = await check.check(ctx); const result = await check.check(ctx);
const durationMs = Date.now() - start;
const weight = CHECK_WEIGHTS[check.name] ?? 10;
maxScore += weight;
details.push({ details.push({
name: check.name, name: check.name,
passed: result.passed, passed: result.passed,
reason: result.reason, reason: result.reason,
durationMs: Date.now() - start, durationMs,
}); });
if (!result.passed) {
scoreBreakdown.push({
check: check.name,
passed: result.passed,
points: result.passed ? weight : 0,
});
if (result.passed) {
totalScore += weight;
} else {
logWarn(`Completion Gate: "${check.name}" — ${result.reason}`); logWarn(`Completion Gate: "${check.name}" — ${result.reason}`);
return { passed: false, reason: result.reason, details }; return {
passed: false,
reason: result.reason,
details,
score: Math.round((totalScore / Math.max(1, maxScore)) * 100),
scoreBreakdown,
};
} }
} catch (err) { } catch (err) {
const weight = CHECK_WEIGHTS[check.name] ?? 10;
maxScore += weight;
details.push({ details.push({
name: check.name, name: check.name,
passed: true, passed: true,
reason: `检查异常: ${(err as Error).message}`, reason: `检查异常: ${(err as Error).message}`,
durationMs: Date.now() - start, durationMs: Date.now() - start,
}); });
scoreBreakdown.push({
check: check.name,
passed: true,
points: weight,
});
totalScore += weight;
} }
} }
return { passed: true, reason: '', details }; return {
passed: true,
reason: '',
details,
score: Math.round((totalScore / Math.max(1, maxScore)) * 100),
scoreBreakdown,
};
} }
/** /**
* 生成门控报告(供日志/调试使用) * R127: 生成门控报告(供日志/调试使用)
*/ */
export function formatGateReport(result: GateResult): string { export function formatGateReport(result: GateResult): string {
const status = result.passed ? '✅ 通过' : '❌ 未通过'; const status = result.passed ? '✅ 通过' : '❌ 未通过';
let report = `Completion Gate ${status}`; let report = `Completion Gate ${status} (评分: ${result.score}/100)`;
if (!result.passed) { if (!result.passed) {
report += `${result.reason}`; report += `${result.reason}`;
} }
@@ -221,5 +299,12 @@ export function formatGateReport(result: GateResult): string {
const icon = d.passed ? '✅' : '❌'; const icon = d.passed ? '✅' : '❌';
report += `\n${icon} ${d.name}: ${d.reason || '通过'} (${d.durationMs}ms)`; report += `\n${icon} ${d.name}: ${d.reason || '通过'} (${d.durationMs}ms)`;
} }
// R127: 评分明细
if (result.scoreBreakdown.length > 0) {
report += `\n${'─'.repeat(40)}\n评分明细:`;
for (const s of result.scoreBreakdown) {
report += `\n ${s.passed ? '✅' : '❌'} ${s.check}: ${s.points}`;
}
}
return report; return report;
} }
+963 -1
View File
@@ -903,7 +903,13 @@ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaM
return [...systemMsgs, ...recentMsgs]; return [...systemMsgs, ...recentMsgs];
} }
// 按重要性降序排列,取能装下的最大数量 // R94: 按综合评分降序排列(重要性 + 时近性),取能装下的最大数量
const totalOlder = olderMsgs.length;
scored.forEach((s, idx) => {
// R94: 时近性因子 — 越靠近最近窗口的消息得分越高(0~2 分加成)
const recencyRatio = totalOlder > 1 ? idx / (totalOlder - 1) : 1;
s.importance += Math.round(recencyRatio * 2);
});
scored.sort((a, b) => b.importance - a.importance); scored.sort((a, b) => b.importance - a.importance);
let usedTokens = 0; let usedTokens = 0;
@@ -924,3 +930,959 @@ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaM
return result; return result;
} }
// ═══════════════════════════════════════════════════════════════
// R91: 上下文压力分级评估 — 三级压力系统指导压缩策略选择
// ═══════════════════════════════════════════════════════════════
export type ContextPressureLevel = 'low' | 'medium' | 'high' | 'critical';
export interface ContextPressureInfo {
level: ContextPressureLevel;
tokenUsageRatio: number; // 0-1
messageCount: number;
recommendedActions: string[]; // 建议的压缩动作
}
/**
* R91: 评估当前上下文压力等级
* - low (<30%): 无需压缩
* - medium (30-50%): 轻量压缩(归档旧工具结果、清理 ephemeral)
* - high (50-70%): 中等压缩(截断工具结果、合并消息)
* - critical (>70%): LLM 压缩
*/
export function getContextPressureLevel(
messages: OllamaMessage[],
numCtx: number,
): ContextPressureInfo {
const totalTokens = messages.reduce((sum, m) => {
let t = estimateTokens(m.content || '');
if (m.tool_calls?.length) {
for (const tc of m.tool_calls) {
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
t += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
}
}
if (m.images?.length) t += m.images.length * 100;
return sum + t;
}, 0);
const ratio = numCtx > 0 ? totalTokens / numCtx : 0;
const msgCount = messages.length;
const actions: string[] = [];
let level: ContextPressureLevel;
if (ratio > 0.7) {
level = 'critical';
actions.push('llm_compress', 'truncate_results', 'compact_old', 'merge_messages', 'clear_ephemeral');
} else if (ratio > 0.5) {
level = 'high';
actions.push('truncate_results', 'compact_old', 'merge_messages');
} else if (ratio > 0.3) {
level = 'medium';
actions.push('compact_old', 'clear_ephemeral');
} else {
level = 'low';
if (msgCount > 60) actions.push('compact_old');
}
return { level, tokenUsageRatio: ratio, messageCount: msgCount, recommendedActions: actions };
}
// ═══════════════════════════════════════════════════════════════
// R93: Token 预算追踪器 — 实时追踪输入/输出 token 与预算比例
// ═══════════════════════════════════════════════════════════════
interface TokenBudgetEntry {
loop: number;
inputTokens: number; // prompt_eval_count
outputTokens: number; // eval_count
estimatedTokens: number; // 本地估算值
timestamp: number;
}
const _tokenBudgetHistory: TokenBudgetEntry[] = [];
const MAX_BUDGET_ENTRIES = 50;
let _totalInputTokens = 0;
let _totalOutputTokens = 0;
let _budgetNumCtx = 131072;
/** R93: 设置当前预算的 numCtx */
export function setTokenBudgetNumCtx(numCtx: number): void {
_budgetNumCtx = numCtx;
}
/** R93: 记录一轮的 token 消耗 */
export function recordBudgetUsage(
loop: number,
inputTokens: number,
outputTokens: number,
estimatedTokens: number,
): void {
_tokenBudgetHistory.push({
loop, inputTokens, outputTokens, estimatedTokens, timestamp: Date.now(),
});
if (_tokenBudgetHistory.length > MAX_BUDGET_ENTRIES) {
_tokenBudgetHistory.shift();
}
_totalInputTokens += inputTokens;
_totalOutputTokens += outputTokens;
}
/** R93: 获取 Token 预算使用情况 */
export interface TokenBudgetStatus {
totalInput: number;
totalOutput: number;
totalSpent: number;
avgInputPerLoop: number;
avgOutputPerLoop: number;
budgetNumCtx: number;
currentLoopInput: number;
budgetUtilization: number; // 当前轮输入占预算比例 0-1
trend: 'increasing' | 'stable' | 'decreasing';
history: TokenBudgetEntry[];
}
/** R93: 获取当前 Token 预算状态 */
export function getTokenBudgetStatus(): TokenBudgetStatus {
const history = [..._tokenBudgetHistory];
const currentLoop = history.length > 0 ? history[history.length - 1] : null;
// 计算趋势
let trend: 'increasing' | 'stable' | 'decreasing' = 'stable';
if (history.length >= 3) {
const recent = history.slice(-3);
const avg = recent.reduce((s, e) => s + e.inputTokens, 0) / recent.length;
const oldest = recent[0].inputTokens;
if (avg > oldest * 1.15) trend = 'increasing';
else if (avg < oldest * 0.85) trend = 'decreasing';
}
const avgInput = history.length > 0
? Math.round(_totalInputTokens / history.length)
: 0;
const avgOutput = history.length > 0
? Math.round(_totalOutputTokens / history.length)
: 0;
return {
totalInput: _totalInputTokens,
totalOutput: _totalOutputTokens,
totalSpent: _totalInputTokens + _totalOutputTokens,
avgInputPerLoop: avgInput,
avgOutputPerLoop: avgOutput,
budgetNumCtx: _budgetNumCtx,
currentLoopInput: currentLoop?.inputTokens || 0,
budgetUtilization: _budgetNumCtx > 0 && currentLoop
? currentLoop.inputTokens / _budgetNumCtx
: 0,
trend,
history,
};
}
/** R93: 重置 Token 预算追踪 */
export function resetTokenBudget(): void {
_tokenBudgetHistory.length = 0;
_totalInputTokens = 0;
_totalOutputTokens = 0;
}
// ═══════════════════════════════════════════════════════════════
// R96: 消息角色压缩 — 合并连续相同角色消息,减少消息条数开销
// ═══════════════════════════════════════════════════════════════
/**
* R96: 合并连续相同角色的非工具消息
* 规则:
* - 连续的 user 消息合并为一条(用分隔符连接)
* - 连续的 assistant 消息合并为一条(保留 tool_calls
* - tool 消息不合并(每条对应一个 tool_call)
* - system 消息不合并(已有 R17 处理)
* - ephemeral 临时消息不合并
* - compressed 消息不合并
*/
export function mergeConsecutiveMessages(messages: OllamaMessage[]): OllamaMessage[] {
if (messages.length <= 2) return messages;
const result: OllamaMessage[] = [];
let mergedCount = 0;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const last = result[result.length - 1];
// 不合并的情况
if (
!last ||
msg.role === 'tool' ||
msg.role === 'system' ||
msg.ephemeral ||
msg.compressed ||
last.role !== msg.role ||
last.ephemeral ||
last.compressed ||
msg.tool_calls?.length || // 有工具调用的 assistant 不合并
last.tool_calls?.length
) {
result.push(msg);
continue;
}
// 合并连续相同角色消息
// 限制合并后内容不超过 3000 字符,避免合并后过长
const combinedContent = (last.content || '') + '\n\n' + (msg.content || '');
if (combinedContent.length > 3000) {
result.push(msg);
continue;
}
result[result.length - 1] = {
...last,
content: combinedContent,
};
mergedCount++;
}
if (mergedCount > 0) {
logInfo(`R96: 消息角色压缩 — 合并了 ${mergedCount} 条连续同角色消息 (${messages.length}${result.length})`);
}
return result;
}
// ═══════════════════════════════════════════════════════════════
// R98: 压缩触发阈值优化 — 结合趋势预测动态调整
// ═══════════════════════════════════════════════════════════════
/**
* R98: 获取结合趋势的压缩触发阈值
* 如果 token 使用趋势在快速增长,提前触发压缩
* 如果趋势稳定或下降,延后压缩
*/
export function getTrendAwareCompressThreshold(
numCtx: number,
messages: OllamaMessage[],
): { shouldCompress: boolean; reason: string; urgency: 'low' | 'medium' | 'high' } {
const baseThreshold = getAdaptiveCompressThreshold(numCtx);
const currentTokens = messages.reduce((sum, m) => {
let t = estimateTokens(m.content || '');
if (m.tool_calls?.length) {
for (const tc of m.tool_calls) {
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
t += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
}
}
if (m.images?.length) t += m.images.length * 100;
return sum + t;
}, 0);
const usageRatio = numCtx > 0 ? currentTokens / numCtx : 0;
const prediction = predictContextOverflow(numCtx);
// 紧急情况:预测即将溢出
if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) {
return {
shouldCompress: true,
reason: `趋势预测触发: ${prediction.message}`,
urgency: 'high',
};
}
// 趋势加速增长 + 使用率超过基础阈值
if (prediction.turnsToOverflow > 0 && prediction.turnsToOverflow <= 5 && usageRatio > baseThreshold * 0.8) {
return {
shouldCompress: true,
reason: `趋势加速: ${prediction.turnsToOverflow} 轮后可能溢出,当前使用率 ${(usageRatio * 100).toFixed(0)}%`,
urgency: 'medium',
};
}
// 标准阈值触发
if (usageRatio > baseThreshold) {
return {
shouldCompress: true,
reason: `标准阈值触发: 使用率 ${(usageRatio * 100).toFixed(0)}% > 阈值 ${(baseThreshold * 100).toFixed(0)}%`,
urgency: usageRatio > 0.6 ? 'high' : 'medium',
};
}
// 消息条数硬阈值
const msgThreshold = getIncrementalCompressThresholdMessages(numCtx);
if (messages.length >= msgThreshold) {
return {
shouldCompress: true,
reason: `消息条数触发: ${messages.length} >= ${msgThreshold}`,
urgency: 'low',
};
}
return { shouldCompress: false, reason: '', urgency: 'low' };
}
/** R98: 消息条数阈值(独立函数,供 engine 复用) */
function getIncrementalCompressThresholdMessages(numCtx: number): number {
const tokenThreshold = Math.floor(numCtx * 0.3);
return Math.max(20, Math.min(120, Math.floor(tokenThreshold / 100)));
}
// ═══════════════════════════════════════════════════════════════
// R100: Token 使用统计报告 — 生成详细消耗分析
// ═══════════════════════════════════════════════════════════════
export interface TokenReport {
generatedAt: number;
session: {
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
loopCount: number;
avgInputPerLoop: number;
avgOutputPerLoop: number;
};
budget: {
numCtx: number;
currentUtilization: number;
peakUtilization: number;
trend: 'increasing' | 'stable' | 'decreasing';
};
compression: {
historyCount: number;
avgCompressionRatio: number;
lastCompressionRatio: number | null;
};
warnings: string[];
}
/**
* R100: 生成 Token 使用统计报告
*/
export function generateTokenReport(numCtx: number): TokenReport {
const budget = getTokenBudgetStatus();
const compressionHistory = getCompressionHistory();
// 计算峰值利用率
let peakUtilization = 0;
for (const entry of budget.history) {
const util = numCtx > 0 ? entry.inputTokens / numCtx : 0;
if (util > peakUtilization) peakUtilization = util;
}
const warnings: string[] = [];
if (budget.budgetUtilization > 0.7) {
warnings.push(`当前轮 token 使用率过高: ${(budget.budgetUtilization * 100).toFixed(0)}%`);
}
if (budget.trend === 'increasing' && budget.avgInputPerLoop > numCtx * 0.3) {
warnings.push(`token 消耗趋势上升,平均每轮 ${budget.avgInputPerLoop} tokens`);
}
if (compressionHistory.length > 0) {
const avgRatio = getAverageCompressionRatio();
if (avgRatio > 0.8) {
warnings.push(`压缩效率偏低: 平均压缩率 ${(avgRatio * 100).toFixed(0)}% (越低越好)`);
}
}
return {
generatedAt: Date.now(),
session: {
totalInputTokens: budget.totalInput,
totalOutputTokens: budget.totalOutput,
totalTokens: budget.totalSpent,
loopCount: budget.history.length,
avgInputPerLoop: budget.avgInputPerLoop,
avgOutputPerLoop: budget.avgOutputPerLoop,
},
budget: {
numCtx,
currentUtilization: budget.budgetUtilization,
peakUtilization,
trend: budget.trend,
},
compression: {
historyCount: compressionHistory.length,
avgCompressionRatio: getAverageCompressionRatio(),
lastCompressionRatio: compressionHistory.length > 0
? compressionHistory[compressionHistory.length - 1].compressionRatio
: null,
},
warnings,
};
}
/** R100: 格式化 Token 报告为可读字符串 */
export function formatTokenReport(report: TokenReport): string {
const lines: string[] = [
`Token Usage Report (${new Date(report.generatedAt).toLocaleTimeString()})`,
`${'─'.repeat(50)}`,
`Session:`,
` Total Input: ${report.session.totalInputTokens.toLocaleString()} tokens`,
` Total Output: ${report.session.totalOutputTokens.toLocaleString()} tokens`,
` Total Spent: ${report.session.totalTokens.toLocaleString()} tokens`,
` Loops: ${report.session.loopCount}`,
` Avg In/Loop: ${report.session.avgInputPerLoop.toLocaleString()} tokens`,
` Avg Out/Loop: ${report.session.avgOutputPerLoop.toLocaleString()} tokens`,
`Budget:`,
` numCtx: ${report.budget.numCtx.toLocaleString()}`,
` Current Usage: ${(report.budget.currentUtilization * 100).toFixed(1)}%`,
` Peak Usage: ${(report.budget.peakUtilization * 100).toFixed(1)}%`,
` Trend: ${report.budget.trend}`,
`Compression:`,
` History Count: ${report.compression.historyCount}`,
` Avg Ratio: ${(report.compression.avgCompressionRatio * 100).toFixed(0)}%`,
` Last Ratio: ${report.compression.lastCompressionRatio !== null ? (report.compression.lastCompressionRatio * 100).toFixed(0) + '%' : 'N/A'}`,
];
if (report.warnings.length > 0) {
lines.push(`Warnings:`);
for (const w of report.warnings) {
lines.push(` ⚠️ ${w}`);
}
}
return lines.join('\n');
}
// ═══════════════════════════════════════════════════════════════
// R111: 压缩策略自适应选择 — 根据上下文特征选择快速压缩或 LLM 压缩
// ═══════════════════════════════════════════════════════════════
export type CompressionStrategy = 'skip' | 'fast' | 'medium' | 'llm';
export interface CompressionDecision {
strategy: CompressionStrategy;
reason: string;
estimatedSavings: number; // 预估节省 token 数
}
/** R111: 根据上下文压力和消息特征选择最优压缩策略 */
export function chooseCompressionStrategy(
messages: OllamaMessage[],
numCtx: number,
pressureLevel: string
): CompressionDecision {
const totalTokens = estimateTokens(messages.map(m => m.content || '').join(''));
const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0;
// R120: 如果上下文压力很低且消息不多,跳过压缩
if (pressureLevel === 'low' && messages.length < 30) {
return {
strategy: 'skip',
reason: `上下文压力低 (${messages.length} 条消息, ${(usageRatio * 100).toFixed(0)}%),无需压缩`,
estimatedSavings: 0,
};
}
// 统计工具结果消息占比
const toolMsgs = messages.filter(m => m.role === 'tool');
const toolRatio = messages.length > 0 ? toolMsgs.length / messages.length : 0;
// 如果工具结果占比高,使用快速压缩(截断+归档)
if (toolRatio > 0.4 && pressureLevel !== 'critical') {
const savings = Math.floor(totalTokens * 0.3);
return {
strategy: 'fast',
reason: `工具结果占比高 (${(toolRatio * 100).toFixed(0)}%),使用快速截断压缩`,
estimatedSavings: savings,
};
}
// 中等压力:中等压缩(消息合并+旧消息裁剪)
if (pressureLevel === 'medium' || pressureLevel === 'high') {
const savings = Math.floor(totalTokens * 0.4);
return {
strategy: 'medium',
reason: `中等压力 (${pressureLevel}),使用消息合并+裁剪`,
estimatedSavings: savings,
};
}
// 关键压力或高使用率:使用 LLM 摘要压缩
if (pressureLevel === 'critical' || usageRatio > 0.75) {
const savings = Math.floor(totalTokens * 0.6);
return {
strategy: 'llm',
reason: `高压力 (${pressureLevel}, ${(usageRatio * 100).toFixed(0)}%),使用 LLM 摘要压缩`,
estimatedSavings: savings,
};
}
// 默认:快速压缩
return {
strategy: 'fast',
reason: '默认快速压缩',
estimatedSavings: Math.floor(totalTokens * 0.2),
};
}
// ═══════════════════════════════════════════════════════════════
// R115: 上下文水印 — 标记不可压缩的关键信息
// ═══════════════════════════════════════════════════════════════
/** 水印标记:带有此标记的消息在压缩时会被保留 */
const WATERMARK_PREFIX = '[PRESERVE]';
const _watermarkedIndices = new Set<number>();
/** R115: 标记消息为不可压缩 */
export function watermarkMessage(index: number): void {
_watermarkedIndices.add(index);
}
/** R115: 检查消息是否被水印保护 */
export function isWatermarked(index: number): boolean {
return _watermarkedIndices.has(index);
}
/** R115: 自动为关键消息添加水印 */
export function autoWatermarkCritical(messages: OllamaMessage[]): number[] {
const protectedIndices: number[] = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const content = msg.content || '';
// 系统消息始终保护
if (msg.role === 'system') {
watermarkMessage(i);
protectedIndices.push(i);
continue;
}
// 包含错误信息的用户消息保护
if (msg.role === 'user' && (content.includes('错误') || content.includes('error') || content.includes('失败'))) {
watermarkMessage(i);
protectedIndices.push(i);
continue;
}
// 最近 5 条消息保护
if (i >= messages.length - 5) {
watermarkMessage(i);
protectedIndices.push(i);
}
}
return protectedIndices;
}
/** R115: 清除水印 */
export function clearWatermarks(): void {
_watermarkedIndices.clear();
}
/** R115: 获取受保护的消息索引列表 */
export function getWatermarkedIndices(): number[] {
return Array.from(_watermarkedIndices).sort((a, b) => a - b);
}
// ═══════════════════════════════════════════════════════════════
// R120: 上下文压缩跳过逻辑 — 不值得压缩时跳过
// ═══════════════════════════════════════════════════════════════
/** R120: 判断是否应该跳过压缩 */
export function shouldSkipCompression(
messages: OllamaMessage[],
numCtx: number,
recentCompressionRatio: number
): { skip: boolean; reason: string } {
const totalTokens = estimateTokens(messages.map(m => m.content || '').join(''));
const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0;
// 如果使用率很低,跳过
if (usageRatio < 0.2) {
return { skip: true, reason: `上下文使用率极低 (${(usageRatio * 100).toFixed(0)}%),无需压缩` };
}
// 如果消息数太少,跳过
if (messages.length < 10) {
return { skip: true, reason: `消息数过少 (${messages.length} 条),无需压缩` };
}
// 如果最近压缩收益很低(压缩比 < 10%),跳过
if (recentCompressionRatio > 0.9) {
return { skip: true, reason: `最近压缩收益低 (压缩比 ${(recentCompressionRatio * 100).toFixed(0)}%),跳过` };
}
// 如果大部分消息已经被归档/压缩过,跳过
const archivedCount = messages.filter(m =>
m.content?.includes('[工具结果已归档]') || m.content?.includes('[PRESERVE]')
).length;
if (archivedCount / messages.length > 0.6) {
return { skip: true, reason: `大部分消息已归档 (${(archivedCount / messages.length * 100).toFixed(0)}%),跳过` };
}
return { skip: false, reason: '' };
}
// ═══════════════════════════════════════════════════════════════
// R121: 滑动窗口自适应大小 — 根据上下文压力动态调整窗口大小
// ═══════════════════════════════════════════════════════════════
/** R121: 根据上下文压力获取自适应滑动窗口大小 */
export function getAdaptiveWindowSize(
totalMessages: number,
pressureLevel: string,
numCtx: number
): { keepRecent: number; keepSystem: number; reason: string } {
const baseWindow = Math.min(totalMessages, 40);
switch (pressureLevel) {
case 'critical':
return {
keepRecent: Math.min(baseWindow, 15),
keepSystem: 2,
reason: '关键压力:保留最近 15 条 + 系统 2 条',
};
case 'high':
return {
keepRecent: Math.min(baseWindow, 25),
keepSystem: 3,
reason: '高压力:保留最近 25 条 + 系统 3 条',
};
case 'medium':
return {
keepRecent: Math.min(baseWindow, 35),
keepSystem: 5,
reason: '中等压力:保留最近 35 条 + 系统 5 条',
};
case 'low':
default:
return {
keepRecent: Math.min(baseWindow, 50),
keepSystem: 5,
reason: '低压力:保留最近 50 条 + 系统 5 条',
};
}
}
// ═══════════════════════════════════════════════════════════════
// R122: Token 趋势分析 — 深度分析 token 使用趋势用于预测性压缩
// ═══════════════════════════════════════════════════════════════
export interface TrendAnalysis {
trend: 'increasing' | 'decreasing' | 'stable';
avgGrowthRate: number; // 每轮平均 token 增长量
projectedOverflow: number; // 预计几轮后溢出(-1=不会)
recommendedAction: string;
confidence: number; // 0-1
}
/** R122: 分析 token 使用趋势 */
export function analyzeTokenTrend(numCtx: number): TrendAnalysis {
if (_tokenUsageTrend.length < 3) {
return {
trend: 'stable',
avgGrowthRate: 0,
projectedOverflow: -1,
recommendedAction: '数据不足,暂不推荐操作',
confidence: 0,
};
}
const points = _tokenUsageTrend;
const n = points.length;
// 计算平均增长率
let totalGrowth = 0;
let growthCount = 0;
for (let i = 1; i < n; i++) {
const growth = points[i].tokens - points[i - 1].tokens;
totalGrowth += growth;
growthCount++;
}
const avgGrowthRate = growthCount > 0 ? totalGrowth / growthCount : 0;
// 线性回归确定趋势
const xs = points.map(p => p.turn);
const ys = points.map(p => p.tokens);
const xMean = xs.reduce((s, x) => s + x, 0) / n;
const yMean = ys.reduce((s, y) => s + y, 0) / n;
let num = 0, den = 0;
for (let i = 0; i < n; i++) {
num += (xs[i] - xMean) * (ys[i] - yMean);
den += (xs[i] - xMean) ** 2;
}
const slope = den !== 0 ? num / den : 0;
// 判断趋势
let trend: TrendAnalysis['trend'];
if (slope > 100) trend = 'increasing';
else if (slope < -50) trend = 'decreasing';
else trend = 'stable';
// 预测溢出
let projectedOverflow = -1;
if (slope > 0) {
const currentTokens = points[n - 1].tokens;
const remaining = numCtx - currentTokens;
projectedOverflow = Math.ceil(remaining / slope);
if (projectedOverflow < 0) projectedOverflow = 0;
}
// 推荐操作
let recommendedAction = '';
if (trend === 'increasing' && projectedOverflow >= 0 && projectedOverflow <= 5) {
recommendedAction = `⚠️ 预计 ${projectedOverflow} 轮后上下文溢出,建议立即压缩`;
} else if (trend === 'increasing' && projectedOverflow > 5 && projectedOverflow <= 10) {
recommendedAction = `建议在接下来 2-3 轮内进行压缩(${projectedOverflow} 轮后溢出)`;
} else if (trend === 'stable') {
recommendedAction = 'Token 使用趋势稳定,无需额外操作';
} else if (trend === 'decreasing') {
recommendedAction = 'Token 使用量在下降,压缩策略生效';
}
// 置信度:基于数据点数量和趋势一致性
let confidence = Math.min(1, n / 10);
if (trend === 'stable') confidence *= 0.7;
return {
trend,
avgGrowthRate: Math.round(avgGrowthRate),
projectedOverflow,
recommendedAction,
confidence,
};
}
// ═══════════════════════════════════════════════════════════════
// R123: 会话摘要持久化 — 跨会话引用
// ═══════════════════════════════════════════════════════════════
export interface SessionSummary {
id: string;
createdAt: number;
goal: string;
summary: string;
toolsUsed: string[];
keyFindings: string[];
tokenUsage: number;
}
const SESSION_SUMMARY_KEY = 'metona_session_summaries';
const MAX_SESSION_SUMMARIES = 10;
/** R123: 保存会话摘要到 localStorage */
export function saveSessionSummary(summary: SessionSummary): void {
try {
const existing = loadSessionSummaries();
existing.unshift(summary);
if (existing.length > MAX_SESSION_SUMMARIES) {
existing.length = MAX_SESSION_SUMMARIES;
}
localStorage.setItem(SESSION_SUMMARY_KEY, JSON.stringify(existing));
logInfo(`R123: 会话摘要已保存 (${summary.id})`);
} catch (err) {
logWarn(`R123: 保存会话摘要失败: ${(err as Error).message}`);
}
}
/** R123: 加载所有会话摘要 */
export function loadSessionSummaries(): SessionSummary[] {
try {
const raw = localStorage.getItem(SESSION_SUMMARY_KEY);
if (!raw) return [];
return JSON.parse(raw) as SessionSummary[];
} catch {
return [];
}
}
/** R123: 生成当前会话摘要 */
export function generateSessionSummary(
goal: string,
messages: OllamaMessage[],
toolRecords: Array<{ name: string }>,
totalTokens: number
): SessionSummary {
const toolsUsed = [...new Set(toolRecords.map(t => t.name))];
const assistantMessages = messages.filter(m => m.role === 'assistant');
const lastAssistant = assistantMessages[assistantMessages.length - 1];
return {
id: `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
createdAt: Date.now(),
goal: goal.slice(0, 200),
summary: (lastAssistant?.content || '').slice(0, 500),
toolsUsed,
keyFindings: [],
tokenUsage: totalTokens,
};
}
/** R123: 格式化历史会话摘要供注入 */
export function formatSessionSummariesForContext(summaries: SessionSummary[]): string {
if (summaries.length === 0) return '';
const lines = ['[历史会话参考]', ''];
for (const s of summaries.slice(0, 3)) {
const date = new Date(s.createdAt).toLocaleDateString();
lines.push(`- ${date}: 目标="${s.goal.slice(0, 60)}..." | 工具=[${s.toolsUsed.join(', ')}] | 结果=${s.summary.slice(0, 100)}...`);
}
return lines.join('\n');
}
// ═══════════════════════════════════════════════════════════════
// R125: Agent 状态检查点 — 保存和恢复 Agent 状态
// ═══════════════════════════════════════════════════════════════
export interface AgentCheckpoint {
id: string;
timestamp: number;
loopCount: number;
state: string;
messagesSnapshot: OllamaMessage[];
toolRecordsCount: number;
goal: string;
}
const _checkpoints: AgentCheckpoint[] = [];
const MAX_CHECKPOINTS = 5;
/** R125: 创建状态检查点 */
export function createCheckpoint(
loopCount: number,
agentState: string,
messages: OllamaMessage[],
toolRecordsCount: number,
goal: string
): AgentCheckpoint {
const checkpoint: AgentCheckpoint = {
id: `cp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
timestamp: Date.now(),
loopCount,
state: agentState,
messagesSnapshot: messages.map(m => ({ ...m })),
toolRecordsCount,
goal,
};
_checkpoints.push(checkpoint);
if (_checkpoints.length > MAX_CHECKPOINTS) {
_checkpoints.shift();
}
logInfo(`R125: 检查点已创建 (loop=${loopCount}, state=${agentState})`);
return checkpoint;
}
/** R125: 获取最近的检查点 */
export function getLatestCheckpoint(): AgentCheckpoint | null {
return _checkpoints.length > 0 ? _checkpoints[_checkpoints.length - 1] : null;
}
/** R125: 恢复到指定检查点 */
export function restoreCheckpoint(id: string): AgentCheckpoint | null {
const cp = _checkpoints.find(c => c.id === id);
if (!cp) {
logWarn(`R125: 检查点 ${id} 不存在`);
return null;
}
logInfo(`R125: 恢复到检查点 ${id} (loop=${cp.loopCount})`);
return cp;
}
/** R125: 获取所有检查点 */
export function getAllCheckpoints(): AgentCheckpoint[] {
return [..._checkpoints];
}
/** R125: 清除所有检查点 */
export function clearCheckpoints(): void {
_checkpoints.length = 0;
}
// ═══════════════════════════════════════════════════════════════
// R126: 上下文预算分配 — 按消息类型分配上下文 token 预算
// ═══════════════════════════════════════════════════════════════
export interface ContextBudgetAllocation {
system: number; // 系统消息预算
user: number; // 用户消息预算
assistant: number; // 助手消息预算
tool: number; // 工具结果预算
memory: number; // 记忆注入预算
total: number; // 总预算
}
/** R126: 默认预算分配比例 */
const DEFAULT_BUDGET_RATIOS = {
system: 0.05, // 5%
user: 0.15, // 15%
assistant: 0.25, // 25%
tool: 0.45, // 45%
memory: 0.10, // 10%
};
/** R126: 根据消息分布动态调整预算分配 */
export function allocateContextBudget(
messages: OllamaMessage[],
numCtx: number
): ContextBudgetAllocation {
const total = numCtx;
// 统计各类型消息当前占比
const counts = { system: 0, user: 0, assistant: 0, tool: 0 };
let memorySize = 0;
for (const msg of messages) {
if (msg.role in counts) {
counts[msg.role as keyof typeof counts]++;
}
if (msg.content?.includes('[记忆注入]')) {
memorySize += estimateTokens(msg.content);
}
}
const totalMsgs = messages.length || 1;
// 动态调整:如果工具结果占比过高,增加工具预算
const toolRatio = counts.tool / totalMsgs;
const ratios = { ...DEFAULT_BUDGET_RATIOS };
if (toolRatio > 0.5) {
// 工具结果过多,从助手预算中转移一部分给工具
const shift = Math.min(0.1, (toolRatio - 0.5) * 0.3);
ratios.assistant -= shift;
ratios.tool += shift;
}
// 如果记忆注入很大,增加记忆预算
if (memorySize > numCtx * 0.1) {
const shift = Math.min(0.05, (memorySize / numCtx - 0.1) * 0.2);
ratios.tool -= shift;
ratios.memory += shift;
}
return {
system: Math.floor(total * ratios.system),
user: Math.floor(total * ratios.user),
assistant: Math.floor(total * ratios.assistant),
tool: Math.floor(total * ratios.tool),
memory: Math.floor(total * ratios.memory),
total,
};
}
/** R126: 检查消息是否超出预算 */
export function checkBudgetOverflow(
messages: OllamaMessage[],
budget: ContextBudgetAllocation
): { role: string; current: number; budget: number; overflow: number }[] {
const tokensByRole: Record<string, number> = {};
for (const msg of messages) {
tokensByRole[msg.role] = (tokensByRole[msg.role] || 0) + estimateTokens(msg.content || '');
}
const overflows: { role: string; current: number; budget: number; overflow: number }[] = [];
const budgetMap: Record<string, number> = {
system: budget.system,
user: budget.user,
assistant: budget.assistant,
tool: budget.tool,
};
for (const [role, current] of Object.entries(tokensByRole)) {
const bud = budgetMap[role] || Infinity;
if (current > bud) {
overflows.push({ role, current, budget: bud, overflow: current - bud });
}
}
return overflows;
}
+230 -3
View File
@@ -41,6 +41,9 @@ export interface MemoryEntry {
content: string; content: string;
importance: number; // 1-10 importance: number; // 1-10
tags: string[]; tags: string[];
// R106: 访问追踪 — 用于 TTL 衰减时的保留策略
lastAccessed?: number; // 最后访问时间(毫秒时间戳)
accessCount?: number; // 访问次数
} }
export interface MemorySearchResult extends MemoryEntry { export interface MemorySearchResult extends MemoryEntry {
@@ -283,8 +286,54 @@ export function serializeMemoryMd(entries: MemoryEntry[]): string {
// 搜索 // 搜索
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
/**
* R101: 计算词项的 IDF
* IDF
*/
function computeIDF(entries: MemoryEntry[], words: string[]): Map<string, number> {
const idfMap = new Map<string, number>();
const N = entries.length;
if (N === 0) return idfMap;
for (const word of words) {
let docFreq = 0;
for (const entry of entries) {
if (entry.content.toLowerCase().includes(word)) docFreq++;
}
// IDF = log(N / (df + 1)),加 1 防止除零
const idf = Math.log((N + 1) / (docFreq + 1)) + 1;
idfMap.set(word, idf);
}
return idfMap;
}
/**
* R101: 模糊匹配
* <= 10 使
*/
function fuzzyMatch(a: string, b: string, maxDistance: number): boolean {
if (a === b) return true;
if (Math.abs(a.length - b.length) > maxDistance) return false;
// 简化编辑距离(Levenshtein
const matrix: number[][] = [];
for (let i = 0; i <= a.length; i++) matrix[i] = [i];
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost,
);
}
}
return matrix[a.length][b.length] <= maxDistance;
}
/** /**
* *
* R101: 增强 IDF + +
*/ */
export function searchMemory(entries: MemoryEntry[], query: string, limit = 0): MemorySearchResult[] { export function searchMemory(entries: MemoryEntry[], query: string, limit = 0): MemorySearchResult[] {
if (!query || entries.length === 0) return []; if (!query || entries.length === 0) return [];
@@ -292,6 +341,9 @@ export function searchMemory(entries: MemoryEntry[], query: string, limit = 0):
const queryLower = query.toLowerCase(); const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1); const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1);
// R101: 预计算 IDF 权重
const idfMap = computeIDF(entries, queryWords);
// rule 和 preference 类型的记忆全局生效,始终注入 // rule 和 preference 类型的记忆全局生效,始终注入
// M5: 限制全局注入数量,避免大量 rule/preference 膨胀搜索结果 // M5: 限制全局注入数量,避免大量 rule/preference 膨胀搜索结果
const MAX_GLOBAL_INJECT = 10; const MAX_GLOBAL_INJECT = 10;
@@ -310,28 +362,70 @@ export function searchMemory(entries: MemoryEntry[], query: string, limit = 0):
const contentLower = entry.content.toLowerCase(); const contentLower = entry.content.toLowerCase();
const tagsLower = entry.tags.map(t => t.toLowerCase()); const tagsLower = entry.tags.map(t => t.toLowerCase());
// 精确匹配查询字符串
if (contentLower.includes(queryLower)) score += 50; if (contentLower.includes(queryLower)) score += 50;
// R101: 多词短语连续匹配奖励
if (queryWords.length >= 2 && contentLower.includes(queryWords.join(' '))) {
score += 25;
}
for (const tag of tagsLower) { for (const tag of tagsLower) {
if (queryLower.includes(tag) || tag.includes(queryLower)) score += 30; if (queryLower.includes(tag) || tag.includes(queryLower)) score += 30;
for (const word of queryWords) { for (const word of queryWords) {
if (tag.includes(word)) score += 15; if (tag.includes(word)) {
// R101: 使用 IDF 权重
score += 15 * (idfMap.get(word) || 1);
}
// R101: 对短词进行模糊匹配
if (word.length <= 10 && tag.length <= 15 && fuzzyMatch(word, tag, 1)) {
score += 8;
}
} }
} }
for (const word of queryWords) { for (const word of queryWords) {
if (contentLower.includes(word)) score += 10; if (contentLower.includes(word)) {
// R101: 使用 IDF 权重
score += 10 * (idfMap.get(word) || 1);
}
// R101: 对短词进行模糊匹配
if (word.length <= 10) {
// 检查内容中是否有编辑距离为 1 的近似词
const words = contentLower.split(/\s+/);
for (const cw of words) {
if (cw.length <= 12 && fuzzyMatch(word, cw, 1)) {
score += 5;
break; // 每个查询词只奖励一次
}
}
}
} }
score *= (0.5 + entry.importance / 20); score *= (0.5 + entry.importance / 20);
if (score > 0) scored.push({ ...entry, score }); if (score > 0) scored.push({ ...entry, score: Math.round(score) });
} }
// 去重 + 排序 // 去重 + 排序
const seen = new Set<string>(); const seen = new Set<string>();
const merged: MemorySearchResult[] = []; const merged: MemorySearchResult[] = [];
const accessedIds: string[] = []; // R106: 需要记录访问的 ID
for (const item of [...alwaysInclude, ...scored.sort((a, b) => b.score - a.score)]) { for (const item of [...alwaysInclude, ...scored.sort((a, b) => b.score - a.score)]) {
if (!seen.has(item.id)) { if (!seen.has(item.id)) {
seen.add(item.id); seen.add(item.id);
merged.push(item); merged.push(item);
// R106: 记录访问(但不持久化,只影响本次搜索后的衰减判断)
accessedIds.push(item.id);
}
}
// R106: 更新原始 entries 中的访问追踪(影响未来的 TTL 衰减)
// 注意:这不会立即持久化,在 writeMemoryFile 时才生效
for (const id of accessedIds) {
const entry = entries.find(e => e.id === id);
if (entry) {
entry.lastAccessed = Date.now();
entry.accessCount = (entry.accessCount || 0) + 1;
} }
} }
@@ -443,6 +537,8 @@ export async function loadAllEntries(): Promise<MemoryEntry[]> {
/** 搜索记忆(读取 MEMORY.md → 解析 → 搜索) */ /** 搜索记忆(读取 MEMORY.md → 解析 → 搜索) */
export async function search(query: string, limit = 0): Promise<MemorySearchResult[]> { export async function search(query: string, limit = 0): Promise<MemorySearchResult[]> {
try { try {
// R57: 触发 TTL 衰减检查(带节流,不会每次搜索都执行)
maybeRunTTLDecay().catch(() => {}); // 非阻塞,失败不影响搜索
const entries = await loadAllEntries(); const entries = await loadAllEntries();
return searchMemory(entries, query, limit); return searchMemory(entries, query, limit);
} catch (err) { } catch (err) {
@@ -812,6 +908,137 @@ function autoCleanEntries(entries: MemoryEntry[]): MemoryEntry[] {
return entries.filter(e => !evictIds.has(e.id)); return entries.filter(e => !evictIds.has(e.id));
} }
// ═══════════════════════════════════════════════════════════════
// R57: 记忆 TTL 衰减 — 基于时间的自动重要性衰减
// ═══════════════════════════════════════════════════════════════
/** R57: 上次 TTL 衰减执行时间(避免每次搜索都触发文件写入) */
let _lastTTLDecayTime = 0;
const TTL_DECAY_INTERVAL = 6 * 3600 * 1000; // 每 6 小时最多执行一次衰减
/** R57: 从记忆 ID 中提取创建日期 */
function extractDateFromId(id: string): number {
const m = id.match(/mem_(\d{4})(\d{2})(\d{2})_/);
if (m) {
return new Date(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3])).getTime();
}
return Date.now(); // 无法解析时视为刚创建
}
/**
* R106: 记录条目访问 访 TTL
*
*/
export function recordMemoryAccess(entryId: string, entries: MemoryEntry[]): void {
const entry = entries.find(e => e.id === entryId);
if (entry) {
entry.lastAccessed = Date.now();
entry.accessCount = (entry.accessCount || 0) + 1;
}
}
/**
* R57: 对记忆条目应用 TTL
*
*
* - rule
* - preference
* - fact
* - importance 2 fact 60
* - R106: 访问频率作为保留因素访访
*
* @param entries
* @returns { decayed: MemoryEntry[], removed: number, changed: boolean }
*/
export function applyTTLDecay(entries: MemoryEntry[]): { decayed: MemoryEntry[]; removed: number; changed: boolean } {
const now = Date.now();
const ONE_DAY = 24 * 3600 * 1000;
const removed: string[] = [];
let changed = false;
const decayed = entries.filter(entry => {
const createdAt = extractDateFromId(entry.id);
const ageDays = (now - createdAt) / ONE_DAY;
const lastAccessed = entry.lastAccessed || createdAt;
const accessCount = entry.accessCount || 0;
const daysSinceAccess = (now - lastAccessed) / ONE_DAY;
// R106: 访问频率保护 — 最近 30 天内有访问或访问次数 >= 3 的条目优先保留
const accessProtected = daysSinceAccess < 30 || accessCount >= 3;
// rule 类型永久保留
if (entry.type === 'rule') return true;
// preference 类型:90 天以上且 importance ≤ 3 才移除(访问频率可豁免)
if (entry.type === 'preference') {
if (ageDays > 90 && entry.importance <= 3 && !accessProtected) {
removed.push(entry.id);
changed = true;
return false;
}
return true;
}
// fact 类型:按年龄和重要性衰减
// 衰减公式:每 30 天 importance - 1(仅对 importance < 8 的条目)
if (entry.type === 'fact') {
// R106: 访问频率保护 — 最近访问过的不易删除
if (accessProtected && ageDays < 120) {
// 120 天内访问过的 fact 豁免删除
return true;
}
// 超过 60 天且 importance ≤ 2 → 自动移除
if (ageDays > 60 && entry.importance <= 2 && !accessProtected) {
removed.push(entry.id);
changed = true;
return false;
}
// 超过 90 天且 importance ≤ 4 → 自动移除
if (ageDays > 90 && entry.importance <= 4 && !accessProtected) {
removed.push(entry.id);
changed = true;
return false;
}
// 高重要性的 fact 永久保留
if (entry.importance >= 8) return true;
return true;
}
return true;
});
if (removed.length > 0) {
logMemory('TTL 衰减', `自动移除 ${removed.length} 条过期记忆(fact/preference 类型,低重要性+超龄)`);
}
return { decayed, removed: removed.length, changed };
}
/**
* R57: 执行 TTL
* TTL_DECAY_INTERVAL
*/
async function maybeRunTTLDecay(): Promise<void> {
const now = Date.now();
if (now - _lastTTLDecayTime < TTL_DECAY_INTERVAL) return;
_lastTTLDecayTime = now;
try {
const entries = await loadAllEntries();
if (entries.length === 0) return;
const { decayed, removed, changed } = applyTTLDecay(entries);
if (changed && removed > 0) {
const fileContent = decayed.length > 0 ? serializeMemoryMd(decayed) : '';
await writeMemoryFile(fileContent);
logMemory('TTL 衰减', `已持久化: 移除 ${removed} 条,剩余 ${decayed.length}`);
}
} catch (err) {
logWarn('TTL 衰减执行失败', (err as Error).message);
}
}
/** /**
* *
*/ */
+75
View File
@@ -9,6 +9,8 @@ import { OllamaAPI } from '../api/ollama.js';
import { TOOL_DEFINITIONS } from './tool-registry.js'; import { TOOL_DEFINITIONS } from './tool-registry.js';
import { getEnabledToolDefinitions } from './tool-registry.js'; import { getEnabledToolDefinitions } from './tool-registry.js';
import { logInfo, logWarn, logError } from './log-service.js'; import { logInfo, logWarn, logError } from './log-service.js';
import { validatePathSandbox, checkRateLimit, isToolCircuitBroken, recordToolFailure, recordToolSuccess, sanitizeToolArgs, checkCommandSafety } from './agent-safety.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
import type { ToolResult, ToolCall, ToolDefinition } from '../types.js'; import type { ToolResult, ToolCall, ToolDefinition } from '../types.js';
const SUB_AGENT_MAX_LOOPS = 10; // 子代理最多 10 轮 const SUB_AGENT_MAX_LOOPS = 10; // 子代理最多 10 轮
@@ -169,9 +171,82 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
// 工具执行前再次检查中止信号 // 工具执行前再次检查中止信号
if (subAgentAC.signal.aborted) break; if (subAgentAC.signal.aborted) break;
// R89: 子 Agent 熔断器检查 — 连续失败后自动禁用工具
const circuitBreaker = isToolCircuitBroken(tc.name);
if (circuitBreaker.broken) {
const waitSec = Math.ceil(circuitBreaker.remainingMs / 1000);
logWarn(`R89: 子 Agent 熔断器拦截: ${tc.name},冷却中(还需 ${waitSec}s`);
messages.push({
role: 'tool',
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: `工具「${tc.name}」因连续失败已被暂时禁用,请等待 ${waitSec} 秒后重试。` })}\n<<<TOOL_RESULT_END>>>`,
tool_name: tc.name
});
continue;
}
// R109: 子 Agent 参数消毒
tc.arguments = sanitizeToolArgs(tc.name, tc.arguments);
// R113: 子 Agent 命令安全检查
if (tc.name === 'run_command') {
const cmdStr = String(tc.arguments?.command || '');
if (cmdStr) {
const cmdSafety = checkCommandSafety(cmdStr);
if (cmdSafety.riskLevel === 'forbidden') {
logWarn(`R113: 子 Agent 命令安全拦截: ${cmdSafety.reason}`);
messages.push({
role: 'tool',
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: cmdSafety.reason || '命令被安全规则拦截' })}\n<<<TOOL_RESULT_END>>>`,
tool_name: tc.name
});
continue;
}
}
}
// R82: 子 Agent 速率限制 — 防止子代理快速连续调用同一工具
const rateLimit = checkRateLimit(tc.name);
if (!rateLimit.allowed) {
const waitSec = Math.ceil(rateLimit.retryAfterMs / 1000);
logWarn(`R82: 子 Agent 速率限制: ${tc.name},等待 ${waitSec}s`);
messages.push({
role: 'tool',
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: `调用频率过高,等待 ${waitSec}s` })}\n<<<TOOL_RESULT_END>>>`,
tool_name: tc.name
});
continue;
}
// R81: 子 Agent 路径沙箱 — 确保文件操作不超出工作空间
const SUB_FILE_TOOLS = new Set(['read_file', 'list_directory', 'search_files', 'web_fetch']);
if (SUB_FILE_TOOLS.has(tc.name)) {
const wsDir = getWorkspaceDirPath();
if (wsDir) {
const pathArg = String(tc.arguments?.path || '');
if (pathArg) {
const sandbox = validatePathSandbox(pathArg, wsDir);
if (!sandbox.valid) {
logWarn(`R81: 子 Agent 路径沙箱拦截: ${tc.name}(${pathArg}) — ${sandbox.reason}`);
messages.push({
role: 'tool',
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: sandbox.reason || '路径不在工作空间范围内' })}\n<<<TOOL_RESULT_END>>>`,
tool_name: tc.name
});
continue;
}
}
}
}
try { try {
const { executeTool } = await import('./tool-registry.js'); const { executeTool } = await import('./tool-registry.js');
const result = await executeTool(tc.name, tc.arguments); const result = await executeTool(tc.name, tc.arguments);
// R89: 记录工具成功/失败到熔断器
if (result.success) {
recordToolSuccess(tc.name);
} else {
recordToolFailure(tc.name);
}
const resultStr = formatResult(tc.name, result); const resultStr = formatResult(tc.name, result);
messages.push({ messages.push({
role: 'tool', role: 'tool',
+85
View File
@@ -754,6 +754,91 @@ export function getEnabledToolDefinitions(): ToolDefinition[] {
return [...builtIn, ..._mcpToolCache]; return [...builtIn, ..._mcpToolCache];
} }
// R55: 语义工具检索 — 根据用户查询过滤相关工具,减少上下文占用
/** 核心工具集 — 始终包含的关键工具 */
const CORE_TOOLS = new Set([
'read_file', 'write_file', 'list_directory', 'search_files', 'edit_file',
'run_command', 'web_search', 'web_fetch', 'memory', 'git',
]);
/** 工具关键词映射 — 用于语义匹配 */
const TOOL_KEYWORDS: Record<string, string[]> = {
read_file: ['read', 'file', '读取', '文件', '看', '查看', '内容'],
write_file: ['write', 'file', '写入', '保存', '创建文件', '输出'],
list_directory: ['list', 'directory', '目录', '文件夹', '列'],
search_files: ['search', 'find', '搜索', '查找', 'grep', 'find'],
create_directory: ['create', 'directory', 'mkdir', '创建目录', '新建'],
delete_file: ['delete', 'remove', '删除', 'rm', '移除'],
run_command: ['run', 'command', 'shell', '执行', '命令', '终端'],
move_file: ['move', 'rename', '移动', '重命名', 'mv'],
copy_file: ['copy', '复制', 'cp'],
web_fetch: ['fetch', 'url', '网页', '抓取', '获取'],
web_search: ['search', 'web', '搜索', '联网', '查', 'google', 'bing'],
edit_file: ['edit', 'replace', '编辑', '替换', '修改'],
get_file_info: ['info', 'stat', '信息', '属性', '详情'],
tree: ['tree', '结构', '树', '目录结构'],
download_file: ['download', '下载'],
diff_files: ['diff', 'compare', '对比', '差异'],
replace_in_files: ['replace', 'batch', '批量替换'],
read_multiple_files: ['read', 'multiple', '批量读取'],
git: ['git', 'commit', 'push', 'pull', 'branch', '仓库'],
compress: ['compress', 'zip', 'tar', '压缩', '解压', 'extract'],
memory: ['memory', '记忆', 'remember', 'save', 'rule', '规则'],
session_list: ['session', 'history', '会话', '历史'],
session_read: ['session', 'read', '读取会话'],
spawn_task: ['sub', 'agent', 'delegate', '子代理', '委派'],
plan_track: ['plan', 'track', '计划', '进度'],
browser_open: ['browser', 'open', '浏览器', '打开网页'],
browser_screenshot: ['screenshot', '截图', '屏幕'],
browser_evaluate: ['evaluate', 'javascript', 'js', '执行JS'],
browser_extract: ['extract', '提取', '内容'],
browser_click: ['click', '点击'],
browser_type: ['type', 'input', '输入'],
browser_scroll: ['scroll', '滚动'],
browser_wait: ['wait', '等待'],
browser_close: ['close', '关闭浏览器'],
datetime: ['time', 'date', '时间', '日期'],
calculator: ['calculate', 'math', '计算', '算'],
random: ['random', '随机'],
uuid: ['uuid', 'guid', '唯一'],
json_format: ['json', 'format', '格式化'],
hash: ['hash', 'sha', 'md5', '哈希'],
};
/** R55: 根据用户查询语义过滤工具定义,减少 token 占用 */
export function getRelevantToolDefinitions(userQuery: string): ToolDefinition[] {
const allTools = getEnabledToolDefinitions();
// 查询为空或很短时返回全部
if (!userQuery || userQuery.length < 5) return allTools;
const queryLower = userQuery.toLowerCase();
const relevantTools = new Set<string>();
// 核心工具始终包含
for (const t of CORE_TOOLS) relevantTools.add(t);
// 语义匹配
for (const [toolName, keywords] of Object.entries(TOOL_KEYWORDS)) {
if (relevantTools.has(toolName)) continue;
for (const kw of keywords) {
if (queryLower.includes(kw.toLowerCase())) {
relevantTools.add(toolName);
break;
}
}
}
// 如果匹配到的工具太少,返回全部(避免遗漏)
if (relevantTools.size < 8) return allTools;
const filtered = allTools.filter(t => relevantTools.has(t.function.name));
// 确保不过度过滤 — 至少保留 60% 的工具
if (filtered.length < allTools.length * 0.6) return allTools;
return filtered;
}
/** MCP 工具缓存 */ /** MCP 工具缓存 */
let _mcpToolCache: ToolDefinition[] = []; let _mcpToolCache: ToolDefinition[] = [];