supeng пре 3 недеља
родитељ
комит
517d874ebc
100 измењених фајлова са 30625 додато и 2 уклоњено
  1. 458 2
      README.md
  2. 168 0
      agent/README.md
  3. 70 0
      agent/__init__.py
  4. 11 0
      agent/cli/__init__.py
  5. 290 0
      agent/cli/interactive.py
  6. 22 0
      agent/core/__init__.py
  7. 72 0
      agent/core/presets.py
  8. 58 0
      agent/core/prompts/__init__.py
  9. 50 0
      agent/core/prompts/compression.py
  10. 98 0
      agent/core/prompts/knowledge.py
  11. 39 0
      agent/core/prompts/runner.py
  12. 1863 0
      agent/core/runner.py
  13. 1400 0
      agent/docs/architecture.md
  14. 1159 0
      agent/docs/decisions.md
  15. 203 0
      agent/docs/knowledge.md
  16. 126 0
      agent/docs/multimodal.md
  17. 331 0
      agent/docs/scope-design.md
  18. 234 0
      agent/docs/skills.md
  19. 1194 0
      agent/docs/tools.md
  20. 414 0
      agent/docs/trace-api.md
  21. 32 0
      agent/llm/__init__.py
  22. 455 0
      agent/llm/gemini.py
  23. 761 0
      agent/llm/openrouter.py
  24. 353 0
      agent/llm/pricing.py
  25. 6 0
      agent/llm/prompts/__init__.py
  26. 190 0
      agent/llm/prompts/loader.py
  27. 168 0
      agent/llm/prompts/wrapper.py
  28. 297 0
      agent/llm/usage.py
  29. 488 0
      agent/llm/yescode.py
  30. 37 0
      agent/memory/__init__.py
  31. 177 0
      agent/memory/models.py
  32. 106 0
      agent/memory/protocols.py
  33. 402 0
      agent/memory/skill_loader.py
  34. 35 0
      agent/memory/skills/browser.md
  35. 109 0
      agent/memory/skills/core.md
  36. 65 0
      agent/memory/skills/planning.md
  37. 419 0
      agent/memory/skills/research.md
  38. 103 0
      agent/memory/stores.py
  39. 21 0
      agent/tools/__init__.py
  40. 13 0
      agent/tools/adapters/__init__.py
  41. 62 0
      agent/tools/adapters/base.py
  42. 120 0
      agent/tools/adapters/opencode-wrapper.ts
  43. 138 0
      agent/tools/adapters/opencode_bun_adapter.py
  44. 15 0
      agent/tools/advanced/__init__.py
  45. 52 0
      agent/tools/advanced/lsp.py
  46. 60 0
      agent/tools/advanced/webfetch.py
  47. 55 0
      agent/tools/builtin/__init__.py
  48. 315 0
      agent/tools/builtin/bash.py
  49. 115 0
      agent/tools/builtin/browser/__init__.py
  50. 2200 0
      agent/tools/builtin/browser/baseClass.py
  51. 86 0
      agent/tools/builtin/browser/sync_mysql_help.py
  52. 90 0
      agent/tools/builtin/feishu/FEISHU_TOOLS_PROMPT.md
  53. 9 0
      agent/tools/builtin/feishu/__init__.py
  54. 491 0
      agent/tools/builtin/feishu/chat.py
  55. 79 0
      agent/tools/builtin/feishu/chat_test.py
  56. 945 0
      agent/tools/builtin/feishu/feishu_client.py
  57. 92 0
      agent/tools/builtin/feishu/websocket_event.py
  58. 19 0
      agent/tools/builtin/file/__init__.py
  59. 531 0
      agent/tools/builtin/file/edit.py
  60. 108 0
      agent/tools/builtin/file/glob.py
  61. 216 0
      agent/tools/builtin/file/grep.py
  62. 319 0
      agent/tools/builtin/file/read.py
  63. 129 0
      agent/tools/builtin/file/write.py
  64. 108 0
      agent/tools/builtin/glob_tool.py
  65. 541 0
      agent/tools/builtin/knowledge.py
  66. 418 0
      agent/tools/builtin/sandbox.py
  67. 260 0
      agent/tools/builtin/search.py
  68. 258 0
      agent/tools/builtin/skill.py
  69. 809 0
      agent/tools/builtin/subagent.py
  70. 126 0
      agent/tools/models.py
  71. 503 0
      agent/tools/registry.py
  72. 199 0
      agent/tools/schema.py
  73. 247 0
      agent/tools/sensitive.py
  74. 142 0
      agent/tools/url_matcher.py
  75. 34 0
      agent/trace/__init__.py
  76. 173 0
      agent/trace/api.py
  77. 323 0
      agent/trace/compaction.py
  78. 144 0
      agent/trace/examples_api.py
  79. 543 0
      agent/trace/goal_models.py
  80. 333 0
      agent/trace/goal_tool.py
  81. 102 0
      agent/trace/logs_websocket.py
  82. 531 0
      agent/trace/models.py
  83. 232 0
      agent/trace/protocols.py
  84. 490 0
      agent/trace/run_api.py
  85. 748 0
      agent/trace/store.py
  86. 147 0
      agent/trace/trace_id.py
  87. 825 0
      agent/trace/tree_dump.py
  88. 382 0
      agent/trace/websocket.py
  89. 7 0
      agent/utils/__init__.py
  90. 40 0
      agent/utils/logging.py
  91. 130 0
      api_server.py
  92. 28 0
      config/feishu_contacts.json
  93. 224 0
      config/pricing.yaml
  94. 122 0
      docs/README.md
  95. 651 0
      docs/a2a-im.md
  96. 71 0
      docs/research/README.md
  97. 733 0
      docs/research/a2a-continuous-dialogue.md
  98. 640 0
      docs/research/a2a-cross-device.md
  99. 504 0
      docs/research/a2a-mamp-protocol.md
  100. 114 0
      docs/research/a2a-protocols.md

+ 458 - 2
README.md

@@ -1,3 +1,459 @@
-# content-finder-agent
+# Reson Agent
 
-fork from https://git.yishihui.com/howard/Agent.git
+可扩展的 Agent 框架。支持多步工具调用、计划管理、子 Agent 协作、回溯重跑和上下文压缩。
+
+## Quick Start
+
+```bash
+pip install -r requirements.txt
+
+# 配置 LLM API Key
+cp .env.example .env  # 编辑填入 API Key
+```
+
+### 最小示例
+
+```python
+import asyncio
+from agent import AgentRunner, RunConfig
+from agent.trace import FileSystemTraceStore
+from agent.llm import create_openrouter_llm_call
+
+runner = AgentRunner(
+    trace_store=FileSystemTraceStore(base_path=".trace"),
+    llm_call=create_openrouter_llm_call(model="anthropic/claude-sonnet-4.5"),
+)
+
+async def main():
+    async for item in runner.run(
+        messages=[{"role": "user", "content": "列出当前目录的文件"}],
+        config=RunConfig(model="anthropic/claude-sonnet-4.5"),
+    ):
+        print(item)
+
+asyncio.run(main())
+```
+
+## 自定义工具
+
+用 `@tool` 装饰器注册。`RunConfig(tools=None)`(默认)时所有已注册工具自动对 LLM 可用,无需额外配置。
+
+```python
+from agent import tool, ToolResult
+
+@tool(description="查询产品库存")
+async def check_inventory(product_id: str, warehouse: str = "default") -> ToolResult:
+    """查询指定仓库的产品库存
+
+    Args:
+        product_id: 产品唯一标识符
+        warehouse: 仓库编码,默认为主仓库
+    """
+    stock = await query_db(product_id, warehouse)
+    return ToolResult(output=f"库存: {stock}")
+
+# 确保此模块在 runner.run() 之前被 import
+```
+
+**注意**: `@tool` 通过副作用注册到全局 registry,必须确保定义工具的模块在调用 `runner.run()` 前被 import。
+
+### 参数 Schema 生成
+
+框架从函数签名和 docstring 自动生成 OpenAI Tool Schema,无需手写 JSON:
+
+- **参数类型**:从类型注解推断(`str`/`int`/`float`/`bool`/`list`/`dict`,支持 `Optional`、`Literal`、`List[T]`)
+- **参数描述**:从 Google 风格 docstring 的 `Args:` 段提取
+- **必填/可选**:有默认值的参数为可选,否则为必填
+- **工具描述**:优先使用 `@tool(description=...)` 参数,其次取 docstring 首行
+- `uid` 和 `context` 参数由框架自动注入,不会出现在 Schema 中
+
+上面的 `check_inventory` 会生成:
+
+```json
+{
+  "type": "function",
+  "function": {
+    "name": "check_inventory",
+    "description": "查询产品库存",
+    "parameters": {
+      "type": "object",
+      "properties": {
+        "product_id": {"type": "string", "description": "产品唯一标识符"},
+        "warehouse": {"type": "string", "description": "仓库编码,默认为主仓库", "default": "default"}
+      },
+      "required": ["product_id"]
+    }
+  }
+}
+```
+
+### 限制工具范围
+
+```python
+# 只启用指定工具(在内置工具基础上追加)
+config = RunConfig(tools=["check_inventory", "another_tool"])
+```
+
+## 自定义 Skills
+
+Skills 是 Markdown 文件,提供领域知识,注入到 system prompt。
+
+```
+my_project/
+└── skills/
+    └── my_domain.md
+```
+
+```markdown
+---
+name: my-domain-skill
+description: 领域专属知识
+---
+
+## Guidelines
+- 规则 1
+- 规则 2
+```
+
+```python
+runner = AgentRunner(
+    llm_call=...,
+    trace_store=...,
+    skills_dir="./skills",  # 指向你的 skills 目录
+)
+```
+
+内置 skills(`agent/memory/skills/`)始终自动加载,`skills_dir` 的内容额外追加。
+
+## 知识管理系统(Knowledge Management)
+
+知识管理系统通过**提取、存储、注入**三个环节,让 Agent 积累和复用结构化知识。
+
+### 核心流程
+
+**1. 提取(Extract)**
+- **触发时机**:
+  - 压缩时提取:消息量超阈值触发压缩时,在 Level 1 过滤前用完整 history 反思
+  - 完成时提取:Agent 运行完成后(不代表任务完成,可能中途退出等待人工评估)
+- **提取方式**:调用 LLM 对执行过程进行反思,提取可复用的知识
+- **自定义 Prompt**:可通过配置自定义反思 prompt,空则使用默认(见 `agent/core/prompts/knowledge.py`)
+
+**2. 存储(Store)**
+- **存储位置**:KnowHub 服务(默认 `http://localhost:8765`)
+- **知识结构**:
+  - `title`: 知识标题
+  - `content`: 知识内容
+  - `type`: 知识类型(strategy/tool/pattern/pitfall 等)
+  - `tags`: 标签(键值对,用于分类和检索)
+  - `scopes`: 作用域(如 `org:cybertogether`)
+  - `owner`: 所有者(默认从 git config user.email 获取)
+  - `resource_ids`: 关联资源 ID 列表(代码片段、凭证、cookies 等)
+- **资源管理**:
+  - 知识可关联多个资源(通过 `resource_ids` 字段)
+  - 资源包含 `body`(公开内容)和 `secure_body`(加密内容)
+  - 支持代码片段、API 凭证、cookies 等多种资源类型
+
+**3. 注入(Inject)**
+- **触发时机**:Agent 切换当前工作的 Goal 时自动触发
+- **检索策略**:基于 Goal 描述和上下文,从知识库检索相关知识
+- **注入方式**:将检索到的知识注入到 Agent 的上下文中
+
+### 配置
+
+知识管理配置通过 `RunConfig.knowledge` 传递:
+
+```python
+from agent.core.runner import KnowledgeConfig, RunConfig
+
+run_config = RunConfig(
+    model="claude-sonnet-4.5",
+    temperature=0.3,
+    max_iterations=1000,
+
+    knowledge=KnowledgeConfig(
+        # 压缩时提取(消息量超阈值触发压缩时,用完整 history 反思)
+        enable_extraction=True,
+        reflect_prompt="",  # 空则使用默认,见 agent/core/prompts/knowledge.py:REFLECT_PROMPT
+
+        # agent运行完成后提取
+        enable_completion_extraction=True,
+        completion_reflect_prompt="",  # 空则使用默认
+
+        # 知识注入(agent切换当前工作的goal时,自动注入相关知识)
+        enable_injection=True,
+
+        # 默认字段(保存/搜索时自动注入)
+        owner="",  # 空则从 git config user.email 获取(隐藏参数,LLM 不可见)
+        default_tags={"project": "my_project"},  # 与 LLM 传递的 tags 合并
+        default_scopes=["org:cybertogether"],  # 与 LLM 传递的 scopes 合并
+        default_search_types=["strategy", "tool"],
+        default_search_owner=""  # 空则不过滤
+    )
+)
+```
+
+**参数注入规则**:
+- `owner`:隐藏参数,LLM 不可见,框架自动注入
+- `tags`:框架默认值 + LLM 传递的值合并
+- `scopes`:框架默认值 + LLM 传递的值合并
+
+### 知识工具
+
+框架提供以下内置工具用于知识管理:
+
+- `knowledge_save`: 保存知识到知识库
+- `knowledge_search`: 搜索知识库
+- `knowledge_get`: 获取指定知识详情
+- `resource_save`: 保存资源(代码、凭证等)
+- `resource_get`: 获取资源内容
+
+这些工具会自动注入配置的默认字段(owner, tags, scopes 等)。
+
+## AgentRunner 参数
+
+```python
+AgentRunner(
+    llm_call,                # 必需:LLM 调用函数
+    trace_store=None,        # Trace 持久化(推荐 FileSystemTraceStore)
+    tool_registry=None,      # 工具注册表(默认:全局 registry)
+    skills_dir=None,         # 自定义 skills 目录
+    utility_llm_call=None,   # 轻量 LLM(生成任务标题等)
+    debug=False,             # 调试模式
+)
+```
+
+## RunConfig 参数
+
+```python
+RunConfig(
+    model="gpt-4o",          # 模型标识
+    temperature=0.3,
+    max_iterations=200,       # Agent loop 最大轮数
+    tools=None,               # None=全部已注册工具,List[str]=内置+指定工具
+    system_prompt=None,       # None=从 skills 自动构建
+    agent_type="default",     # 预设类型:default / explore / analyst
+    trace_id=None,            # 续跑/回溯时传入已有 trace ID
+    after_sequence=None,      # 从哪条消息后续跑(message sequence)
+    knowledge=KnowledgeConfig(),  # 知识管理配置
+)
+```
+    system_prompt=None,       # None=从 skills 自动构建
+    agent_type="default",     # 预设类型:default / explore / analyst
+    trace_id=None,            # 续跑/回溯时传入已有 trace ID
+    after_sequence=None,      # 从哪条消息后续跑(message sequence)
+)
+```
+
+## LLM Providers
+
+框架内置两个 provider:
+
+```python
+from agent.llm import create_openrouter_llm_call, create_gemini_llm_call
+
+# OpenRouter(支持多种模型)
+llm = create_openrouter_llm_call(model="anthropic/claude-sonnet-4.5")
+
+# Google Gemini
+llm = create_gemini_llm_call(model="gemini-2.5-flash")
+```
+
+自定义 provider 只需实现签名:
+
+```python
+async def my_llm_call(messages, model, tools, temperature, **kwargs) -> dict:
+    # 调用你的 LLM
+    return {
+        "content": "...",
+        "tool_calls": [...] or None,
+        "prompt_tokens": 100,
+        "completion_tokens": 50,
+        "cost": 0.001,
+        "finish_reason": "stop",
+    }
+```
+
+## API Server
+
+```bash
+python api_server.py
+```
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/traces` | 列出 Traces |
+| GET | `/api/traces/{id}` | Trace 详情 |
+| GET | `/api/traces/{id}/messages` | 消息列表 |
+| POST | `/api/traces` | 新建并执行 |
+| POST | `/api/traces/{id}/run` | 续跑/回溯 |
+| POST | `/api/traces/{id}/stop` | 停止 |
+| WS | `/api/traces/{id}/watch` | 实时事件 |
+
+需在 `api_server.py` 中配置 Runner 才能启用 POST 端点。
+
+## 项目结构
+
+```
+agent/
+├── core/           # AgentRunner + 预设
+├── tools/          # 工具系统(registry + 内置工具)
+├── trace/          # 执行追踪 + 计划(GoalTree)+ API
+├── memory/         # Skills + Experiences
+└── llm/            # LLM Provider 适配
+```
+
+详细架构文档:[docs/README.md](./docs/README.md)
+
+
+
+## 交互式 CLI(Interactive CLI)
+
+框架提供交互式控制器,支持实时监控、手动干预和经验总结。
+
+### 使用方式
+
+```python
+from agent.cli import InteractiveController
+
+# 创建交互控制器
+interactive = InteractiveController(
+    runner=runner,
+    store=store,
+    enable_stdin_check=True  # 启用标准输入检查
+)
+
+# 在执行循环中检查用户输入
+async for item in runner.run(messages=messages, config=config):
+    cmd = interactive.check_stdin()
+    if cmd == 'pause':
+        await runner.stop(trace_id)
+        menu_result = await interactive.show_menu(trace_id, current_sequence)
+        # 处理菜单结果...
+    elif cmd == 'quit':
+        await runner.stop(trace_id)
+        break
+```
+
+### 交互控制
+
+在执行过程中,可以通过命令行实时控制:
+
+| 按键 | 动作 | 说明 |
+| --- | --- | --- |
+| `p` / `pause` | **暂停执行** | 立即挂起 Agent 循环,进入交互菜单 |
+| `q` / `quit` | **停止执行** | 安全停止并保存当前的执行状态 |
+
+### 交互菜单功能
+
+进入暂停模式后,系统提供以下操作:
+
+1. **插入干预消息**:直接向 Agent 下达新指令
+2. **触发经验总结 (Reflect)**:强制 Agent 对当前过程进行反思
+3. **查看 GoalTree**:可视化当前任务的拆解结构和完成进度
+4. **上下文压缩 (Compact)**:手动精简对话历史
+
+### 项目配置示例
+
+完整的项目配置示例见 `examples/research/config.py`:
+
+```python
+from agent.core.runner import KnowledgeConfig, RunConfig
+from agent.utils import setup_logging
+
+# Agent 运行配置
+RUN_CONFIG = RunConfig(
+    model="claude-sonnet-4.5",
+    temperature=0.3,
+    max_iterations=1000,
+    name="Research Agent",
+
+    knowledge=KnowledgeConfig(
+        enable_extraction=True,
+        enable_completion_extraction=True,
+        enable_injection=True,
+        owner="",  # 空则从 git config 获取
+        default_tags={"project": "research"},
+        default_scopes=["org:cybertogether"],
+        default_search_types=["strategy", "tool"],
+    )
+)
+
+# 基础设施配置
+SKILLS_DIR = "./skills"
+TRACE_STORE_PATH = ".trace"
+DEBUG = True
+LOG_LEVEL = "INFO"
+LOG_FILE = None  # 可设置为文件路径
+
+# 在 run.py 中使用
+setup_logging(level=LOG_LEVEL, file=LOG_FILE)
+
+runner = AgentRunner(
+    trace_store=FileSystemTraceStore(base_path=TRACE_STORE_PATH),
+    llm_call=create_openrouter_llm_call(model=f"anthropic/{RUN_CONFIG.model}"),
+    skills_dir=SKILLS_DIR,
+    debug=DEBUG
+)
+
+async for item in runner.run(messages=messages, config=RUN_CONFIG):
+    # 处理执行结果
+    pass
+```
+
+**配置说明**:
+- 直接使用框架的 `RunConfig` 和 `KnowledgeConfig`,不需要自定义配置类
+- 基础设施配置(skills_dir, trace_store_path 等)用简单变量定义
+- 使用 `agent.utils.setup_logging()` 配置日志
+
+## 任务可视化与调试
+
+框架在运行期间会生成唯一的 `trace_id`。
+
+* **本地日志**:所有的执行细节、工具调用和 Goal 状态均持久化在 `.trace/` 目录下。
+* **Web 可视化**:
+1. 启动服务器:`python api_server.py`
+2. 启动前端:
+```
+  cd frontend/react-template
+  yarn
+  yarn dev
+```
+2. 访问控制台:`http://localhost:3000`
+3. 在前端界面中切换任务,即直观追踪 Agent 的思考链路。
+
+### 提示:目前前端可视化只供观看本地运行过的trace结果,新任务运行等功能正在开发中,运行可在命令行中执行
+### 绿色节点为整体的goal(目标),蓝色节点为子goal(目标),灰色节点为基础信息节点。点击蓝色边/绿色边会折叠节点,点击节点会在右侧显示详情。
+
+---
+
+## 示例项目结构
+
+可以参考其他文件夹中的结构:
+
+```text
+examples/[your_example]/
+├── input/             # (可选)输入数据
+├── output_1/          # (可选)输出目录
+├── skills/            # (可选)领域专属 Skill (.md)
+├── tool/              # (可选)自定义工具
+├── presets.json       # (可选)预定义的子 Agent 配置
+├── config.py          # (推荐)项目配置
+├── [task].prompt      # (必须)任务 System Prompt 和 User Prompt
+└── run.py             # (必须)交互式运行入口
+```
+
+---
+
+## 环境兼容性
+
+针对 Clash Verge / TUN 模式等网络环境,本项目已内置代理自动避让逻辑:
+
+* **代理优化**:通过 `no_proxy` 配置防止 `httpx` 错误引导流量。
+* **Browser 模式**:支持 `cloud` (远程) 和 `local` (本地) 模式切换。
+
+## 运行结果存储
+
+运行过程中,会自动存储以下内容:
+
+* **运行轨迹**:根目录下 `.trace/` 文件夹下的实际运行路径结果
+* **知识库**:KnowHub 服务中保存的知识条目(通过 API 访问)

+ 168 - 0
agent/README.md

@@ -0,0 +1,168 @@
+# Agent Core
+
+**Agent 核心框架**:提供单个 Agent 的执行能力
+
+## 文档维护规范
+
+0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
+1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议,或大量代码;决策依据或修改日志若有必要,可在`docs/decisions.md`另行记录
+
+---
+
+## 概述
+
+Agent Core 是一个完整的 Agent 执行框架,提供:
+- Trace、Message、Goal 管理
+- 工具系统(文件、命令、网络、浏览器)
+- LLM 集成(Gemini、OpenRouter、Yescode)
+- Skills 和 Memory(跨会话知识)
+- 子 Agent 机制
+
+**独立性**:Agent Core 不依赖任何其他模块,可以独立运行。
+
+---
+
+## 模块结构
+
+```
+agent/
+├── core/                  # 核心引擎
+│   ├── runner.py          # AgentRunner + 运行时配置
+│   └── presets.py         # Agent 预设(explore、analyst 等)
+│
+├── trace/                 # 执行追踪(含计划管理)
+│   ├── models.py          # Trace, Message
+│   ├── goal_models.py     # Goal, GoalTree, GoalStats
+│   ├── protocols.py       # TraceStore 接口
+│   ├── store.py           # FileSystemTraceStore 实现
+│   ├── goal_tool.py       # goal 工具(计划管理)
+│   ├── compaction.py      # Context 压缩
+│   ├── api.py             # REST API
+│   └── websocket.py       # WebSocket API
+│
+├── tools/                 # 外部交互工具
+│   ├── registry.py        # 工具注册表
+│   ├── schema.py          # Schema 生成器
+│   ├── models.py          # ToolResult, ToolContext
+│   └── builtin/
+│       ├── file/          # 文件操作
+│       ├── browser/       # 浏览器自动化
+│       ├── bash.py        # 命令执行
+│       ├── subagent.py    # 子 Agent 创建
+│       └── a2a_im.py      # A2A IM 工具(桥接到 Gateway)
+│
+├── memory/                # 跨会话记忆
+│   ├── models.py          # Experience, Skill
+│   ├── stores.py          # 存储实现
+│   ├── skill_loader.py    # Skill 加载器
+│   └── skills/            # 内置 Skills
+│
+└── llm/                   # LLM 集成
+    ├── gemini.py          # Gemini Provider
+    ├── openrouter.py      # OpenRouter Provider
+    └── yescode.py         # Yescode Provider
+```
+
+---
+
+## 核心概念
+
+### Trace(任务执行)
+
+一次完整的 Agent 执行。所有 Agent(主、子、人类协助)都是 Trace。
+
+**实现位置**:`agent/trace/models.py:Trace`
+
+### Goal(目标节点)
+
+计划中的一个目标,支持层级结构。
+
+**实现位置**:`agent/trace/goal_models.py:Goal`
+
+### Message(执行消息)
+
+对应 LLM API 的消息,每条 Message 关联一个 Goal。
+
+**实现位置**:`agent/trace/models.py:Message`
+
+---
+
+## 快速开始
+
+### 基础使用
+
+```python
+from agent.core.runner import AgentRunner, RunConfig
+
+# 创建 Runner
+runner = AgentRunner(
+    llm_call=create_llm_call(),
+    trace_store=FileSystemTraceStore()
+)
+
+# 运行 Agent
+async for item in runner.run(
+    messages=[{"role": "user", "content": "分析项目架构"}],
+    config=RunConfig(model="gpt-4o")
+):
+    if isinstance(item, Trace):
+        print(f"Trace: {item.trace_id}")
+    elif isinstance(item, Message):
+        print(f"Message: {item.content}")
+```
+
+### 使用工具
+
+```python
+from agent.tools import tool, ToolContext, ToolResult
+
+@tool(description="自定义工具")
+async def my_tool(arg: str, ctx: ToolContext) -> ToolResult:
+    return ToolResult(
+        title="成功",
+        output=f"处理结果: {arg}"
+    )
+```
+
+---
+
+## 文档
+
+### 模块文档(agent/docs/)
+
+- [架构设计](./docs/architecture.md):Agent Core 完整架构设计
+- [工具系统](./docs/tools.md):工具定义、注册、双层记忆
+- [Skills 指南](./docs/skills.md):Skill 分类、编写、加载
+- [Trace API](./docs/trace-api.md):Trace 模块 REST API 和 WebSocket 接口
+- [多模态支持](./docs/multimodal.md):图片、PDF 处理
+- [设计决策](./docs/decisions.md):架构决策记录
+
+### 项目级文档(../docs/)
+
+- [项目总览](../docs/README.md):完整的架构设计文档
+- [A2A IM 系统](../docs/a2a-im.md):Agent 间通讯
+- [Enterprise 层](../gateway/docs/enterprise/overview.md):组织级功能
+
+---
+
+## API
+
+### REST API
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET  | `/api/traces` | 列出 Traces |
+| GET  | `/api/traces/{id}` | 获取 Trace 详情 |
+| GET  | `/api/traces/{id}/messages` | 获取 Messages |
+| POST | `/api/traces` | 新建 Trace 并执行 |
+| POST | `/api/traces/{id}/run` | 续跑或回溯 |
+| POST | `/api/traces/{id}/stop` | 停止运行 |
+
+**实现位置**:`agent/trace/api.py`, `agent/trace/run_api.py`
+
+---
+
+## 相关项目
+
+- [Gateway](../gateway/README.md):A2A IM Gateway,提供 Agent 间通讯能力

+ 70 - 0
agent/__init__.py

@@ -0,0 +1,70 @@
+"""
+Reson Agent - 模块化、可扩展的 Agent 框架
+
+核心导出:
+- AgentRunner: Agent 执行引擎
+- RunConfig: 运行配置
+- Trace, Message, Goal: 执行追踪
+- Experience, Skill: 记忆模型
+- tool: 工具装饰器
+- TraceStore, MemoryStore: 存储接口
+"""
+
+# 核心引擎
+from agent.core.runner import AgentRunner, CallResult, RunConfig
+from agent.core.presets import AgentPreset, AGENT_PRESETS, get_preset
+
+# 执行追踪
+from agent.trace.models import Trace, Message, Step, StepType, StepStatus, ChatMessage, Messages, MessageContent
+from agent.trace.goal_models import Goal, GoalTree, GoalStatus
+from agent.trace.protocols import TraceStore
+from agent.trace.store import FileSystemTraceStore
+
+# 记忆系统
+from agent.memory.models import Experience, Skill
+from agent.memory.protocols import MemoryStore, StateStore
+from agent.memory.stores import MemoryMemoryStore, MemoryStateStore
+
+# 工具系统
+from agent.tools import tool, ToolRegistry, get_tool_registry
+from agent.tools.models import ToolResult, ToolContext
+
+__version__ = "0.3.0"
+
+__all__ = [
+    # Core
+    "AgentRunner",
+    "AgentConfig",
+    "CallResult",
+    "RunConfig",
+    "AgentPreset",
+    "AGENT_PRESETS",
+    "get_preset",
+    # Trace
+    "Trace",
+    "Message",
+    "ChatMessage",
+    "Messages",
+    "MessageContent",
+    "Step",
+    "StepType",
+    "StepStatus",
+    "Goal",
+    "GoalTree",
+    "GoalStatus",
+    "TraceStore",
+    "FileSystemTraceStore",
+    # Memory
+    "Experience",
+    "Skill",
+    "MemoryStore",
+    "StateStore",
+    "MemoryMemoryStore",
+    "MemoryStateStore",
+    # Tools
+    "tool",
+    "ToolRegistry",
+    "get_tool_registry",
+    "ToolResult",
+    "ToolContext",
+]

+ 11 - 0
agent/cli/__init__.py

@@ -0,0 +1,11 @@
+"""
+CLI 工具模块
+
+提供交互式控制等 CLI 相关功能。
+"""
+
+from .interactive import InteractiveController
+
+__all__ = [
+    "InteractiveController",
+]

+ 290 - 0
agent/cli/interactive.py

@@ -0,0 +1,290 @@
+"""
+交互式控制器
+
+提供暂停/继续、交互式菜单、经验总结等功能。
+"""
+
+import sys
+import asyncio
+from typing import Optional, Dict, Any
+from pathlib import Path
+
+from agent.core.runner import AgentRunner
+from agent.trace import TraceStore
+
+
+# ===== 非阻塞 stdin 检测 =====
+
+if sys.platform == 'win32':
+    import msvcrt
+
+
+def check_stdin() -> Optional[str]:
+    """
+    跨平台非阻塞检查 stdin 输入。
+
+    Windows: 使用 msvcrt.kbhit()
+    macOS/Linux: 使用 select.select()
+
+    Returns:
+        'pause' | 'quit' | None
+    """
+    if sys.platform == 'win32':
+        # Windows: 检查是否有按键按下
+        if msvcrt.kbhit():
+            ch = msvcrt.getwch().lower()
+            if ch == 'p':
+                return 'pause'
+            if ch == 'q':
+                return 'quit'
+        return None
+    else:
+        # Unix/Mac: 使用 select
+        import select
+        ready, _, _ = select.select([sys.stdin], [], [], 0)
+        if ready:
+            line = sys.stdin.readline().strip().lower()
+            if line in ('p', 'pause'):
+                return 'pause'
+            if line in ('q', 'quit'):
+                return 'quit'
+        return None
+
+
+def read_multiline() -> str:
+    """
+    读取多行输入,以连续两次回车(空行)结束。
+
+    Returns:
+        用户输入的多行文本
+    """
+    print("\n请输入干预消息(连续输入两次回车结束):")
+    lines = []
+    blank_count = 0
+
+    while True:
+        line = input()
+        if line == "":
+            blank_count += 1
+            if blank_count >= 2:
+                break
+            lines.append("")  # 保留单个空行
+        else:
+            blank_count = 0
+            lines.append(line)
+
+    # 去掉尾部多余空行
+    while lines and lines[-1] == "":
+        lines.pop()
+
+    return "\n".join(lines)
+
+
+# ===== 交互式控制器 =====
+
+class InteractiveController:
+    """
+    交互式控制器
+
+    管理暂停/继续、交互式菜单、经验总结等交互功能。
+    """
+
+    def __init__(
+        self,
+        runner: AgentRunner,
+        store: TraceStore,
+        enable_stdin_check: bool = True
+    ):
+        """
+        初始化交互式控制器
+
+        Args:
+            runner: Agent Runner 实例
+            store: Trace Store 实例
+            enable_stdin_check: 是否启用 stdin 检查
+        """
+        self.runner = runner
+        self.store = store
+        self.enable_stdin_check = enable_stdin_check
+
+    def check_stdin(self) -> Optional[str]:
+        """
+        检查 stdin 输入
+
+        Returns:
+            'pause' | 'quit' | None
+        """
+        if not self.enable_stdin_check:
+            return None
+        return check_stdin()
+
+    async def show_menu(
+        self,
+        trace_id: str,
+        current_sequence: int
+    ) -> Dict[str, Any]:
+        """
+        显示交互式菜单
+
+        Args:
+            trace_id: Trace ID
+            current_sequence: 当前消息序号
+
+        Returns:
+            用户选择的操作
+        """
+        print("\n" + "=" * 60)
+        print("  执行已暂停")
+        print("=" * 60)
+        print("请选择操作:")
+        print("  1. 插入干预消息并继续")
+        print("  2. 触发经验总结(reflect)")
+        print("  3. 查看当前 GoalTree")
+        print("  4. 手动压缩上下文(compact)")
+        print("  5. 继续执行")
+        print("  6. 停止执行")
+        print("=" * 60)
+
+        while True:
+            choice = input("请输入选项 (1-6): ").strip()
+
+            if choice == "1":
+                # 插入干预消息
+                text = read_multiline()
+                if not text:
+                    print("未输入任何内容,取消操作")
+                    continue
+
+                print(f"\n将插入干预消息并继续执行...")
+                # 从 store 读取实际的 last_sequence
+                live_trace = await self.store.get_trace(trace_id)
+                actual_sequence = live_trace.last_sequence if live_trace and live_trace.last_sequence else current_sequence
+
+                return {
+                    "action": "continue",
+                    "messages": [{"role": "user", "content": text}],
+                    "after_sequence": actual_sequence,
+                }
+
+            elif choice == "2":
+                # 触发经验总结
+                print("\n触发经验总结...")
+                focus = input("请输入反思重点(可选,直接回车跳过): ").strip()
+                await self.perform_reflection(trace_id, focus=focus)
+                continue
+
+            elif choice == "3":
+                # 查看 GoalTree
+                goal_tree = await self.store.get_goal_tree(trace_id)
+                if goal_tree and goal_tree.goals:
+                    print("\n当前 GoalTree:")
+                    print(goal_tree.to_prompt())
+                else:
+                    print("\n当前没有 Goal")
+                continue
+
+            elif choice == "4":
+                # 手动压缩上下文
+                await self.manual_compact(trace_id)
+                continue
+
+            elif choice == "5":
+                # 继续执行
+                print("\n继续执行...")
+                return {"action": "continue"}
+
+            elif choice == "6":
+                # 停止执行
+                print("\n停止执行...")
+                return {"action": "stop"}
+
+            else:
+                print("无效选项,请重新输入")
+
+    async def perform_reflection(
+        self,
+        trace_id: str,
+        focus: str = ""
+    ):
+        """
+        执行经验总结
+
+        Args:
+            trace_id: Trace ID
+            focus: 反思重点(可选)
+        """
+        from agent.core.prompts.knowledge import build_reflect_prompt
+        from agent.core.runner import RunConfig
+
+        trace = await self.store.get_trace(trace_id)
+        if not trace:
+            print("未找到 Trace")
+            return
+
+        saved_head = trace.head_sequence
+
+        # 构建反思 prompt
+        prompt = build_reflect_prompt()
+        if focus:
+            prompt += f"\n\n请特别关注:{focus}"
+
+        print("正在生成反思...")
+        reflect_cfg = RunConfig(trace_id=trace_id, max_iterations=1, tools=[])
+
+        try:
+            result = await self.runner.run_result(
+                messages=[{"role": "user", "content": prompt}],
+                config=reflect_cfg,
+            )
+            reflection_text = result.get("summary", "")
+
+            if reflection_text:
+                print("\n--- 反思内容 ---")
+                print(reflection_text)
+                print("--- 结束 ---\n")
+            else:
+                print("未生成反思内容")
+
+        finally:
+            # 恢复 head_sequence(反思消息成为侧枝,不污染主对话)
+            await self.store.update_trace(trace_id, head_sequence=saved_head)
+
+    async def manual_compact(self, trace_id: str):
+        """
+        手动压缩上下文
+
+        Args:
+            trace_id: Trace ID
+        """
+        from agent.core.runner import RunConfig
+
+        print("\n正在执行上下文压缩(compact)...")
+
+        try:
+            goal_tree = await self.store.get_goal_tree(trace_id)
+            trace = await self.store.get_trace(trace_id)
+
+            if not trace:
+                print("未找到 Trace,无法压缩")
+                return
+
+            # 重建当前 history
+            main_path = await self.store.get_main_path_messages(trace_id, trace.head_sequence)
+            history = [msg.to_llm_dict() for msg in main_path]
+            head_seq = main_path[-1].sequence if main_path else 0
+            next_seq = head_seq + 1
+
+            compact_config = RunConfig(trace_id=trace_id)
+            new_history, new_head, new_seq = await self.runner._compress_history(
+                trace_id=trace_id,
+                history=history,
+                goal_tree=goal_tree,
+                config=compact_config,
+                sequence=next_seq,
+                head_seq=head_seq,
+            )
+
+            print(f"\n✅ 压缩完成: {len(history)} 条消息 → {len(new_history)} 条")
+
+        except Exception as e:
+            print(f"\n❌ 压缩失败: {e}")

+ 22 - 0
agent/core/__init__.py

@@ -0,0 +1,22 @@
+"""
+Agent Core - 核心引擎模块
+
+职责:
+1. Agent 主循环逻辑(call() 和 run())
+2. 配置数据类(CallResult, RunConfig)
+3. Agent 预设(AgentPreset)
+"""
+
+from agent.core.runner import AgentRunner, BUILTIN_TOOLS, CallResult, RunConfig
+from agent.core.presets import AgentPreset, AGENT_PRESETS, get_preset, register_preset
+
+__all__ = [
+    "AgentRunner",
+    "BUILTIN_TOOLS",
+    "CallResult",
+    "RunConfig",
+    "AgentPreset",
+    "AGENT_PRESETS",
+    "get_preset",
+    "register_preset",
+]

+ 72 - 0
agent/core/presets.py

@@ -0,0 +1,72 @@
+"""
+Agent Presets - Agent 类型预设配置
+
+定义不同类型 Agent 的工具权限和运行参数。
+用户可通过 .agent/presets.json 覆盖或添加预设。
+"""
+
+from dataclasses import dataclass, field
+from typing import Optional, List
+
+
+@dataclass
+class AgentPreset:
+    """Agent 预设配置"""
+
+    # 工具权限
+    allowed_tools: Optional[List[str]] = None  # None 表示允许全部
+    denied_tools: Optional[List[str]] = None   # 黑名单
+
+    # 运行参数
+    max_iterations: int = 30
+    temperature: Optional[float] = None
+
+    # Skills(注入 system prompt 的 skill 名称列表;None = 加载全部)
+    skills: Optional[List[str]] = None
+
+    # 描述
+    description: Optional[str] = None
+
+
+# 内置预设
+_DEFAULT_SKILLS = ["planning", "research", "browser"]
+
+AGENT_PRESETS = {
+    "default": AgentPreset(
+        allowed_tools=None,
+        max_iterations=30,
+        skills=_DEFAULT_SKILLS,
+        description="默认 Agent,拥有全部工具权限",
+    ),
+    "delegate": AgentPreset(
+        allowed_tools=None,
+        max_iterations=30,
+        skills=_DEFAULT_SKILLS,
+        description="委托子 Agent,拥有全部工具权限(由 agent 工具创建)",
+    ),
+    "explore": AgentPreset(
+        allowed_tools=["read", "glob", "grep", "list_files"],
+        denied_tools=["write", "edit", "bash", "task"],
+        max_iterations=15,
+        skills=["planning"],
+        description="探索型 Agent,只读权限,用于代码分析",
+    ),
+    "evaluate": AgentPreset(
+        allowed_tools=["read_file", "grep_content", "glob_files", "goal"],
+        max_iterations=10,
+        skills=["planning"],
+        description="评估型 Agent,只读权限,用于结果评估",
+    ),
+}
+
+
+def get_preset(name: str) -> AgentPreset:
+    """获取预设配置"""
+    if name not in AGENT_PRESETS:
+        raise ValueError(f"Unknown preset: {name}. Available: {list(AGENT_PRESETS.keys())}")
+    return AGENT_PRESETS[name]
+
+
+def register_preset(name: str, preset: AgentPreset) -> None:
+    """注册自定义预设"""
+    AGENT_PRESETS[name] = preset

+ 58 - 0
agent/core/prompts/__init__.py

@@ -0,0 +1,58 @@
+"""
+agent.core.prompts - Agent 系统 Prompt 集中管理
+
+子模块:
+- runner.py     系统提示、工具中断、任务命名、经验格式
+- knowledge.py  知识反思提取(压缩时 + 任务完成后)
+- compression.py  消息压缩总结
+- subagent.py   子 Agent 评估、结果格式化、知识管理
+"""
+
+from agent.core.prompts.runner import (
+    DEFAULT_SYSTEM_PREFIX,
+    TRUNCATION_HINT,
+    TOOL_INTERRUPTED_MESSAGE,
+    AGENT_INTERRUPTED_SUMMARY,
+    AGENT_CONTINUE_HINT_TEMPLATE,
+    TASK_NAME_GENERATION_SYSTEM_PROMPT,
+    TASK_NAME_FALLBACK,
+    build_tool_interrupted_message,
+    build_agent_continue_hint,
+)
+
+from agent.core.prompts.knowledge import (
+    REFLECT_PROMPT,
+    COMPLETION_REFLECT_PROMPT,
+    build_reflect_prompt,
+)
+
+from agent.core.prompts.compression import (
+    COMPRESSION_PROMPT_TEMPLATE,
+    COMPRESSION_EVAL_PROMPT_TEMPLATE,
+    SUMMARY_HEADER_TEMPLATE,
+    build_compression_eval_prompt,
+    build_summary_header,
+)
+
+__all__ = [
+    # runner
+    "DEFAULT_SYSTEM_PREFIX",
+    "TRUNCATION_HINT",
+    "TOOL_INTERRUPTED_MESSAGE",
+    "AGENT_INTERRUPTED_SUMMARY",
+    "AGENT_CONTINUE_HINT_TEMPLATE",
+    "TASK_NAME_GENERATION_SYSTEM_PROMPT",
+    "TASK_NAME_FALLBACK",
+    "build_tool_interrupted_message",
+    "build_agent_continue_hint",
+    # knowledge
+    "REFLECT_PROMPT",
+    "COMPLETION_REFLECT_PROMPT",
+    "build_reflect_prompt",
+    # compression
+    "COMPRESSION_PROMPT_TEMPLATE",
+    "COMPRESSION_EVAL_PROMPT_TEMPLATE",
+    "SUMMARY_HEADER_TEMPLATE",
+    "build_compression_eval_prompt",
+    "build_summary_header",
+]

+ 50 - 0
agent/core/prompts/compression.py

@@ -0,0 +1,50 @@
+"""
+压缩相关 Prompt
+
+包含 Level 2 消息压缩(LLM 总结)使用的 prompt。
+"""
+
+# ===== 压缩总结 =====
+
+COMPRESSION_PROMPT_TEMPLATE = """请对以上对话历史进行压缩总结。
+
+### 摘要要求
+1. 保留关键决策、结论和产出(如创建的文件、修改的代码、得出的分析结论)
+2. 保留重要的上下文(如用户的要求、约束条件、之前的讨论结果)
+3. 省略中间探索过程、重复的工具调用细节
+4. 使用结构化格式(标题 + 要点 + 相关资源引用,若有)
+5. 控制在 2000 字以内
+
+当前 GoalTree 状态:
+{goal_tree_prompt}
+
+格式要求:
+[[SUMMARY]]
+(此处填写结构化的摘要内容)
+"""
+
+# 保留旧名以兼容 compaction.py 的调用
+COMPRESSION_EVAL_PROMPT_TEMPLATE = COMPRESSION_PROMPT_TEMPLATE
+
+SUMMARY_HEADER_TEMPLATE = """## 对话历史摘要(自动压缩)
+
+{summary_text}
+
+---
+*以上为压缩摘要,原始对话历史已归档。*
+"""
+
+# ===== 辅助函数 =====
+
+def build_compression_eval_prompt(
+    goal_tree_prompt: str,
+    ex_reference_list: str = "",
+) -> str:
+    return COMPRESSION_EVAL_PROMPT_TEMPLATE.format(
+        goal_tree_prompt=goal_tree_prompt,
+        ex_reference_list=ex_reference_list,
+    )
+
+
+def build_summary_header(summary_text: str) -> str:
+    return SUMMARY_HEADER_TEMPLATE.format(summary_text=summary_text)

+ 98 - 0
agent/core/prompts/knowledge.py

@@ -0,0 +1,98 @@
+"""
+知识提取相关 Prompt
+
+两个场景,各自独立配置:
+- REFLECT_PROMPT:            压缩时阶段性反思(消息量超阈值,对当前批历史提炼)
+- COMPLETION_REFLECT_PROMPT: 任务完成后全局复盘(对整个任务的全局视角)
+
+两个 prompt 都要求 LLM 直接调用 `knowledge_save` 工具保存经验,
+而不是输出结构化文本再由 runner 解析。
+"""
+
+# ===== 压缩时阶段性反思 =====
+
+REFLECT_PROMPT = """请回顾以上执行过程,将值得沉淀的经验直接用 `knowledge_save` 工具保存到知识库。
+
+**关注以下方面**:
+1. 人工干预:用户中途的指令说明了哪里出了问题
+2. 弯路:哪些尝试是不必要的,有没有更直接的方法
+3. 好的决策:哪些判断和选择是正确的,值得记住
+4. 工具使用:哪些工具用法是高效的,哪些可以改进
+5. **资源发现**:是否发现了有价值的资源需要保存(见下方说明)
+
+**每条经验调用一次 `knowledge_save`,参数说明**:
+- `task`: 这条经验适用的场景,格式:「在[什么情境]下,[要完成什么]」
+- `content`: 具体经验内容,格式:「当[条件]时,应该[动作](原因:[一句话])。案例:[具体案例]」
+- `types`: 选 `["strategy"]`;如果涉及工具用法也可加 `"tool"`
+- `tags`: 用 `intent`(任务意图)和 `state`(环境状态/相关工具名)标注,便于检索
+- `score`: 1-5,根据这条经验的价值评估
+- `resource_ids`: 如果关联了资源,填写资源 ID 列表(可选)
+
+**资源提取指南**:
+如果执行过程中涉及以下内容,应先用 `resource_save` 保存资源,再用 `knowledge_save` 提交相关的经验/知识:
+
+1. **复杂代码工具**(逻辑复杂、超过 100 行):
+   - 调用 `resource_save(resource_id="code/{category}/{name}", title="...", body="代码内容", content_type="code", metadata={"language": "python"})`
+   - 然后在 `knowledge_save` 中通过 `resource_ids=["code/{category}/{name}"]` 关联
+
+2. **账号密码凭证**:
+   - 调用 `resource_save(resource_id="credentials/{website}", title="...", body="使用说明", secure_body="账号:xxx\\n密码:xxx", content_type="credential", metadata={"acquired_at": "2026-03-06T10:00:00Z"})`
+   - 然后在 `knowledge_save` 中通过 `resource_ids=["credentials/{website}"]` 关联
+
+3. **Cookie 和登录态**:
+   - 调用 `resource_save(resource_id="cookies/{website}", title="...", body="获取方法", secure_body="cookie内容", content_type="cookie", metadata={"acquired_at": "...", "expires_at": "..."})`
+   - 然后在 `knowledge_save` 中通过 `resource_ids=["cookies/{website}"]` 关联
+
+4. **多资源引用**:
+   - 一个知识可以关联多个资源,如:`resource_ids=["code/selenium/login", "credentials/website_a"]`
+
+**注意**:
+- 只保存最有价值的经验,宁少勿滥;一次就成功或比较简单的经验就不要记录了,记录反复尝试或被用户指导后才成功的经验、或者是调研之后的收获。
+- 不需要输出任何文字,直接调用工具即可
+- 如果没有值得保存的经验,不调用任何工具
+"""
+
+
+# ===== 任务完成后全局复盘 =====
+
+COMPLETION_REFLECT_PROMPT = """请对整个任务进行复盘,将值得沉淀的经验直接用 `knowledge_save` 工具保存到知识库。
+
+与压缩时的阶段性反思不同,这是任务结束后的全局视角,关注:
+1. 任务整体路径:实际走的路径与最初计划的偏差
+2. 关键决策点:哪些决策显著影响了最终结果
+3. 可复用的模式:哪些做法在类似任务中可以直接复用
+4. 踩过的坑:哪些问题本可提前规避
+5. **资源沉淀**:任务中产生或发现的有价值资源(见下方说明)
+
+**每条经验调用一次 `knowledge_save`,参数说明**:
+- `task`: 这条经验适用的场景,格式:「在[什么情境]下,[要完成什么]」
+- `content`: 具体经验内容,格式:「当[条件]时,应该[动作](原因:[一句话])。案例:[具体案例]」
+- `types`: 选 `["strategy"]`;如果涉及工具用法也可加 `"tool"`
+- `tags`: 用 `intent`(任务意图)和 `state`(环境状态/相关工具名)标注,便于检索
+- `score`: 1-5,根据这条经验的价值评估
+- `resource_ids`: 如果关联了资源,填写资源 ID 列表(可选)
+
+**资源提取指南**:
+如果任务中涉及以下内容,应先用 `resource_save` 保存资源,再用 `knowledge_save` 关联:
+
+1. **复杂代码工具**(逻辑复杂、超过 20 行、可复用):
+   - 调用 `resource_save(resource_id="code/{category}/{name}", title="...", body="代码内容", content_type="code", metadata={"language": "python"})`
+   - 然后在 `knowledge_save` 中通过 `resource_id` 关联
+
+2. **账号密码凭证**:
+   - 调用 `resource_save(resource_id="credentials/{website}", title="...", body="使用说明", secure_body="账号:xxx\\n密码:xxx", content_type="credential", metadata={"acquired_at": "2026-03-06T10:00:00Z"})`
+   - 然后在 `knowledge_save` 中通过 `secure_resource_id` 关联
+
+3. **Cookie 和登录态**:
+   - 调用 `resource_save(resource_id="cookies/{website}", title="...", body="获取方法", secure_body="cookie内容", content_type="cookie", metadata={"acquired_at": "...", "expires_at": "..."})`
+   - 然后在 `knowledge_save` 中通过 `secure_resource_id` 关联
+
+**注意**:
+- 只保存最有价值的经验,宁少勿滥;一次就成功或比较简单的经验就不要记录了,记录反复尝试或被用户指导后才成功的经验、或者是调研之后的收获。
+- 不需要输出任何文字,直接调用工具即可
+- 如果没有值得保存的经验,不调用任何工具
+"""
+
+
+def build_reflect_prompt() -> str:
+    return REFLECT_PROMPT

+ 39 - 0
agent/core/prompts/runner.py

@@ -0,0 +1,39 @@
+"""
+Runner 相关 Prompt
+
+包含 AgentRunner 主循环使用的 prompt:
+- 系统提示前缀
+- 工具执行中断提示
+- 任务名称生成
+- 经验条目格式
+"""
+
+# ===== 系统提示 =====
+
+DEFAULT_SYSTEM_PREFIX = "你是最顶尖的AI助手,可以拆分并调用工具逐步解决复杂问题。"
+
+# ===== 工具执行 =====
+
+TRUNCATION_HINT = """你的响应因为 max_tokens 限制被截断,tool call 参数不完整,未执行。请将大内容拆分为多次小的工具调用(例如用 write_file 的 append 模式分批写入)。"""
+
+TOOL_INTERRUPTED_MESSAGE = """⚠️ 工具 {tool_name} 执行被中断(进程异常退出),未获得执行结果。请根据需要重新调用。"""
+
+AGENT_INTERRUPTED_SUMMARY = "⚠️ 子Agent执行被中断(进程异常退出)"
+
+AGENT_CONTINUE_HINT_TEMPLATE = '使用 continue_from="{sub_trace_id}" 可继续执行,保留已有进度'
+
+# ===== 任务命名 =====
+
+TASK_NAME_GENERATION_SYSTEM_PROMPT = "用中文为以下任务生成一个简短标题(10-30字),只输出标题本身:"
+
+TASK_NAME_FALLBACK = "未命名任务"
+
+# ===== 辅助函数 =====
+
+def build_tool_interrupted_message(tool_name: str) -> str:
+    return TOOL_INTERRUPTED_MESSAGE.format(tool_name=tool_name)
+
+
+def build_agent_continue_hint(sub_trace_id: str) -> str:
+    return AGENT_CONTINUE_HINT_TEMPLATE.format(sub_trace_id=sub_trace_id)
+

+ 1863 - 0
agent/core/runner.py

@@ -0,0 +1,1863 @@
+"""
+Agent Runner - Agent 执行引擎
+
+核心职责:
+1. 执行 Agent 任务(循环调用 LLM + 工具)
+2. 记录执行轨迹(Trace + Messages + GoalTree)
+3. 加载和注入技能(Skill)
+4. 管理执行计划(GoalTree)
+5. 支持续跑(continue)和回溯重跑(rewind)
+
+参数分层:
+- Infrastructure: AgentRunner 构造时设置(trace_store, llm_call 等)
+- RunConfig: 每次 run 时指定(model, trace_id, after_sequence 等)
+- Messages: OpenAI SDK 格式的任务消息
+"""
+
+import asyncio
+import json
+import logging
+import os
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import AsyncIterator, Optional, Dict, Any, List, Callable, Literal, Tuple, Union
+
+from agent.trace.models import Trace, Message
+from agent.trace.protocols import TraceStore
+from agent.trace.goal_models import GoalTree
+from agent.trace.compaction import (
+    CompressionConfig,
+    filter_by_goal_status,
+    estimate_tokens,
+    needs_level2_compression,
+    build_compression_prompt,
+    build_reflect_prompt,
+)
+from agent.memory.models import Skill
+from agent.memory.skill_loader import load_skills_from_dir
+from agent.tools import ToolRegistry, get_tool_registry
+from agent.core.prompts import (
+    DEFAULT_SYSTEM_PREFIX,
+    TRUNCATION_HINT,
+    TOOL_INTERRUPTED_MESSAGE,
+    AGENT_INTERRUPTED_SUMMARY,
+    AGENT_CONTINUE_HINT_TEMPLATE,
+    TASK_NAME_GENERATION_SYSTEM_PROMPT,
+    TASK_NAME_FALLBACK,
+    SUMMARY_HEADER_TEMPLATE,
+    COMPLETION_REFLECT_PROMPT,
+    build_summary_header,
+    build_tool_interrupted_message,
+    build_agent_continue_hint,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# ===== 知识管理配置 =====
+
+@dataclass
+class KnowledgeConfig:
+    """知识提取与注入的配置"""
+
+    # 压缩时提取(消息量超阈值触发压缩时,在 Level 1 过滤前用完整 history 反思)
+    enable_extraction: bool = True         # 是否在压缩触发时提取知识
+    reflect_prompt: str = ""               # 自定义反思 prompt;空则使用默认,见 agent/core/prompts/knowledge.py:REFLECT_PROMPT
+
+    # agent运行完成后提取(不代表任务完成,agent 可能中途退出等待人工评估)
+    enable_completion_extraction: bool = True      # 是否在运行完成后提取知识
+    completion_reflect_prompt: str = ""            # 自定义复盘 prompt;空则使用默认,见 agent/core/prompts/knowledge.py:COMPLETION_REFLECT_PROMPT
+
+    # 知识注入(agent切换当前工作的goal时,自动注入相关知识)
+    enable_injection: bool = True          # 是否在 focus goal 时自动注入相关知识
+
+    # 默认字段(保存/搜索时自动注入)
+    owner: str = ""                            # 所有者(空则尝试从 git config user.email 获取,再空则用 agent:{agent_id})
+    default_tags: Optional[Dict[str, str]] = None      # 默认 tags(会与工具调用参数合并)
+    default_scopes: Optional[List[str]] = None         # 默认 scopes(空则用 ["org:cybertogether"])
+    default_search_types: Optional[List[str]] = None   # 默认搜索类型过滤
+    default_search_owner: str = ""                     # 默认搜索 owner 过滤(空则不过滤)
+
+    def get_reflect_prompt(self) -> str:
+        """压缩时反思 prompt"""
+        return self.reflect_prompt if self.reflect_prompt else build_reflect_prompt()
+
+    def get_completion_reflect_prompt(self) -> str:
+        """任务完成后复盘 prompt"""
+        return self.completion_reflect_prompt if self.completion_reflect_prompt else COMPLETION_REFLECT_PROMPT
+
+    def get_owner(self, agent_id: str = "agent") -> str:
+        """获取 owner(优先级:配置 > git email > agent:{agent_id})"""
+        if self.owner:
+            return self.owner
+
+        # 尝试从 git config 获取
+        try:
+            import subprocess
+            result = subprocess.run(
+                ["git", "config", "user.email"],
+                capture_output=True,
+                text=True,
+                timeout=2,
+            )
+            if result.returncode == 0 and result.stdout.strip():
+                return result.stdout.strip()
+        except Exception:
+            pass
+
+        return f"agent:{agent_id}"
+
+
+@dataclass
+class ContextUsage:
+    """Context 使用情况"""
+    trace_id: str
+    message_count: int
+    token_count: int
+    max_tokens: int
+    usage_percent: float
+    image_count: int = 0
+
+
+# ===== 运行配置 =====
+
+@dataclass
+class RunConfig:
+    """
+    运行参数 — 控制 Agent 如何执行
+
+    分为模型层参数(由上游 agent 或用户决定)和框架层参数(由系统注入)。
+    """
+    # --- 模型层参数 ---
+    model: str = "gpt-4o"
+    temperature: float = 0.3
+    max_iterations: int = 200
+    tools: Optional[List[str]] = None          # None = 全部已注册工具
+
+    # --- 框架层参数 ---
+    agent_type: str = "default"
+    uid: Optional[str] = None
+    system_prompt: Optional[str] = None        # None = 从 skills 自动构建
+    skills: Optional[List[str]] = None         # 注入 system prompt 的 skill 名称列表;None = 按 preset 决定
+    enable_memory: bool = True
+    auto_execute_tools: bool = True
+    name: Optional[str] = None                 # 显示名称(空则由 utility_llm 自动生成)
+    enable_prompt_caching: bool = True         # 启用 Anthropic Prompt Caching(仅 Claude 模型有效)
+
+    # --- Trace 控制 ---
+    trace_id: Optional[str] = None             # None = 新建
+    parent_trace_id: Optional[str] = None      # 子 Agent 专用
+    parent_goal_id: Optional[str] = None
+
+    # --- 续跑控制 ---
+    after_sequence: Optional[int] = None       # 从哪条消息后续跑(message sequence)
+
+    # --- 额外 LLM 参数(传给 llm_call 的 **kwargs)---
+    extra_llm_params: Dict[str, Any] = field(default_factory=dict)
+
+    # --- 知识管理配置 ---
+    knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
+
+
+    # 内置工具列表(始终自动加载)
+BUILTIN_TOOLS = [
+    # 文件操作工具
+    "read_file",
+    "edit_file",
+    "write_file",
+    "glob_files",
+    "grep_content",
+
+    # 系统工具
+    "bash_command",
+
+    # 技能和目标管理
+    "skill",
+    "list_skills",
+    "goal",
+    "agent",
+    "evaluate",
+
+    # 搜索工具
+    "search_posts",
+    "get_search_suggestions",
+
+    # 知识管理工具
+    "knowledge_search",
+    "knowledge_save",
+    "knowledge_update",
+    "knowledge_batch_update",
+    "knowledge_list",
+    "knowledge_slim",
+
+
+    # 沙箱工具
+    # "sandbox_create_environment",
+    # "sandbox_run_shell",
+    # "sandbox_rebuild_with_ports",
+    # "sandbox_destroy_environment",
+
+    # 浏览器工具
+    "browser_navigate_to_url",
+    "browser_search_web",
+    "browser_go_back",
+    "browser_wait",
+    "browser_click_element",
+    "browser_input_text",
+    "browser_send_keys",
+    "browser_upload_file",
+    "browser_scroll_page",
+    "browser_find_text",
+    "browser_screenshot",
+    "browser_switch_tab",
+    "browser_close_tab",
+    "browser_get_dropdown_options",
+    "browser_select_dropdown_option",
+    "browser_extract_content",
+    "browser_read_long_content",
+    "browser_download_direct_url",
+    "browser_get_page_html",
+    "browser_get_visual_selector_map",
+    "browser_evaluate",
+    "browser_ensure_login_with_cookies",
+    "browser_wait_for_user_action",
+    "browser_done",
+    "browser_export_cookies",
+    "browser_load_cookies"
+]
+
+
+@dataclass
+class CallResult:
+    """单次调用结果"""
+    reply: str
+    tool_calls: Optional[List[Dict]] = None
+    trace_id: Optional[str] = None
+    step_id: Optional[str] = None
+    tokens: Optional[Dict[str, int]] = None
+    cost: float = 0.0
+
+
+# ===== 执行引擎 =====
+
+CONTEXT_INJECTION_INTERVAL = 10  # 每 N 轮注入一次 GoalTree + Collaborators
+
+
+class AgentRunner:
+    """
+    Agent 执行引擎
+
+    支持三种运行模式(通过 RunConfig 区分):
+    1. 新建:trace_id=None
+    2. 续跑:trace_id=已有ID, after_sequence=None 或 == head
+    3. 回溯:trace_id=已有ID, after_sequence=N(N < head_sequence)
+    """
+
+    def __init__(
+        self,
+        trace_store: Optional[TraceStore] = None,
+        tool_registry: Optional[ToolRegistry] = None,
+        llm_call: Optional[Callable] = None,
+        utility_llm_call: Optional[Callable] = None,
+        skills_dir: Optional[str] = None,
+        goal_tree: Optional[GoalTree] = None,
+        debug: bool = False,
+    ):
+        """
+        初始化 AgentRunner
+
+        Args:
+            trace_store: Trace 存储
+            tool_registry: 工具注册表(默认使用全局注册表)
+            llm_call: 主 LLM 调用函数
+            utility_llm_call: 轻量 LLM(用于生成任务标题等),可选
+            skills_dir: Skills 目录路径
+            goal_tree: 初始 GoalTree(可选)
+            debug: 保留参数(已废弃)
+        """
+        self.trace_store = trace_store
+        self.tools = tool_registry or get_tool_registry()
+        self.llm_call = llm_call
+        self.utility_llm_call = utility_llm_call
+        self.skills_dir = skills_dir
+        self.goal_tree = goal_tree
+        self.debug = debug
+        self._cancel_events: Dict[str, asyncio.Event] = {}  # trace_id → cancel event
+
+        # 知识保存跟踪(每个 trace 独立)
+        self._saved_knowledge_ids: Dict[str, List[str]] = {}  # trace_id → [knowledge_ids]
+
+        # Context 使用跟踪
+        self._context_warned: Dict[str, set] = {}  # trace_id → {30, 50, 80} 已警告过的阈值
+        self._context_usage: Dict[str, ContextUsage] = {}  # trace_id → 当前用量快照
+
+    # ===== 核心公开方法 =====
+
+    def get_context_usage(self, trace_id: str) -> Optional[ContextUsage]:
+        """获取指定 trace 的 context 使用情况"""
+        return self._context_usage.get(trace_id)
+
+    async def run(
+        self,
+        messages: List[Dict],
+        config: Optional[RunConfig] = None,
+    ) -> AsyncIterator[Union[Trace, Message]]:
+        """
+        Agent 模式执行(核心方法)
+
+        Args:
+            messages: OpenAI SDK 格式的输入消息
+                新建: 初始任务消息 [{"role": "user", "content": "..."}]
+                续跑: 追加的新消息
+                回溯: 在插入点之后追加的消息
+            config: 运行配置
+
+        Yields:
+            Union[Trace, Message]: Trace 对象(状态变化)或 Message 对象(执行过程)
+        """
+        if not self.llm_call:
+            raise ValueError("llm_call function not provided")
+
+        config = config or RunConfig()
+        trace = None
+
+        try:
+            # Phase 1: PREPARE TRACE
+            trace, goal_tree, sequence = await self._prepare_trace(messages, config)
+            # 注册取消事件
+            self._cancel_events[trace.trace_id] = asyncio.Event()
+            yield trace
+
+            # Phase 2: BUILD HISTORY
+            history, sequence, created_messages, head_seq = await self._build_history(
+                trace.trace_id, messages, goal_tree, config, sequence
+            )
+            # Update trace's head_sequence in memory
+            trace.head_sequence = head_seq
+            for msg in created_messages:
+                yield msg
+
+            # Phase 3: AGENT LOOP
+            async for event in self._agent_loop(trace, history, goal_tree, config, sequence):
+                yield event
+
+        except Exception as e:
+            logger.error(f"Agent run failed: {e}")
+            tid = config.trace_id or (trace.trace_id if trace else None)
+            if self.trace_store and tid:
+                # 读取当前 last_sequence 作为 head_sequence,确保续跑时能加载完整历史
+                current = await self.trace_store.get_trace(tid)
+                head_seq = current.last_sequence if current else None
+                await self.trace_store.update_trace(
+                    tid,
+                    status="failed",
+                    head_sequence=head_seq,
+                    error_message=str(e),
+                    completed_at=datetime.now()
+                )
+                trace_obj = await self.trace_store.get_trace(tid)
+                if trace_obj:
+                    yield trace_obj
+            raise
+        finally:
+            # 清理取消事件
+            if trace:
+                self._cancel_events.pop(trace.trace_id, None)
+
+    async def run_result(
+        self,
+        messages: List[Dict],
+        config: Optional[RunConfig] = None,
+        on_event: Optional[Callable] = None,
+    ) -> Dict[str, Any]:
+        """
+        结果模式 — 消费 run(),返回结构化结果。
+
+        主要用于 agent/evaluate 工具内部。
+
+        Args:
+            on_event: 可选回调,每个 Trace/Message 事件触发一次,用于实时输出子 Agent 执行过程。
+        """
+        last_assistant_text = ""
+        final_trace: Optional[Trace] = None
+
+        async for item in self.run(messages=messages, config=config):
+            if on_event:
+                on_event(item)
+            if isinstance(item, Message) and item.role == "assistant":
+                content = item.content
+                text = ""
+                if isinstance(content, dict):
+                    text = content.get("text", "") or ""
+                elif isinstance(content, str):
+                    text = content
+                if text and text.strip():
+                    last_assistant_text = text
+            elif isinstance(item, Trace):
+                final_trace = item
+
+        config = config or RunConfig()
+        if not final_trace and config.trace_id and self.trace_store:
+            final_trace = await self.trace_store.get_trace(config.trace_id)
+
+        status = final_trace.status if final_trace else "unknown"
+        error = final_trace.error_message if final_trace else None
+        summary = last_assistant_text
+
+        if not summary:
+            status = "failed"
+            error = error or "Agent 没有产生 assistant 文本结果"
+
+        # 获取保存的知识 ID
+        trace_id = final_trace.trace_id if final_trace else config.trace_id
+        saved_knowledge_ids = self._saved_knowledge_ids.get(trace_id, [])
+
+        return {
+            "status": status,
+            "summary": summary,
+            "trace_id": trace_id,
+            "error": error,
+            "saved_knowledge_ids": saved_knowledge_ids,  # 新增:返回保存的知识 ID
+            "stats": {
+                "total_messages": final_trace.total_messages if final_trace else 0,
+                "total_tokens": final_trace.total_tokens if final_trace else 0,
+                "total_cost": final_trace.total_cost if final_trace else 0.0,
+            },
+        }
+
+    async def stop(self, trace_id: str) -> bool:
+        """
+        停止运行中的 Trace
+
+        设置取消信号,agent loop 在下一个 LLM 调用前检查并退出。
+        Trace 状态置为 "stopped"。
+
+        Returns:
+            True 如果成功发送停止信号,False 如果该 trace 不在运行中
+        """
+        cancel_event = self._cancel_events.get(trace_id)
+        if cancel_event is None:
+            return False
+        cancel_event.set()
+        return True
+
+    # ===== 单次调用(保留)=====
+
+    async def call(
+        self,
+        messages: List[Dict],
+        model: str = "gpt-4o",
+        tools: Optional[List[str]] = None,
+        uid: Optional[str] = None,
+        trace: bool = True,
+        **kwargs
+    ) -> CallResult:
+        """
+        单次 LLM 调用(无 Agent Loop)
+        """
+        if not self.llm_call:
+            raise ValueError("llm_call function not provided")
+
+        trace_id = None
+        message_id = None
+
+        tool_schemas = self._get_tool_schemas(tools)
+
+        if trace and self.trace_store:
+            trace_obj = Trace.create(mode="call", uid=uid, model=model, tools=tool_schemas, llm_params=kwargs)
+            trace_id = await self.trace_store.create_trace(trace_obj)
+
+        result = await self.llm_call(messages=messages, model=model, tools=tool_schemas, **kwargs)
+
+        if trace and self.trace_store and trace_id:
+            msg = Message.create(
+                trace_id=trace_id, role="assistant", sequence=1, goal_id=None,
+                content={"text": result.get("content", ""), "tool_calls": result.get("tool_calls")},
+                prompt_tokens=result.get("prompt_tokens", 0),
+                completion_tokens=result.get("completion_tokens", 0),
+                finish_reason=result.get("finish_reason"),
+                cost=result.get("cost", 0),
+            )
+            message_id = await self.trace_store.add_message(msg)
+            await self.trace_store.update_trace(trace_id, status="completed", completed_at=datetime.now())
+
+        return CallResult(
+            reply=result.get("content", ""),
+            tool_calls=result.get("tool_calls"),
+            trace_id=trace_id,
+            step_id=message_id,
+            tokens={"prompt": result.get("prompt_tokens", 0), "completion": result.get("completion_tokens", 0)},
+            cost=result.get("cost", 0)
+        )
+
+    # ===== Phase 1: PREPARE TRACE =====
+
+    async def _prepare_trace(
+        self,
+        messages: List[Dict],
+        config: RunConfig,
+    ) -> Tuple[Trace, Optional[GoalTree], int]:
+        """
+        准备 Trace:创建新的或加载已有的
+
+        Returns:
+            (trace, goal_tree, next_sequence)
+        """
+        if config.trace_id:
+            return await self._prepare_existing_trace(config)
+        else:
+            return await self._prepare_new_trace(messages, config)
+
+    async def _prepare_new_trace(
+        self,
+        messages: List[Dict],
+        config: RunConfig,
+    ) -> Tuple[Trace, Optional[GoalTree], int]:
+        """创建新 Trace"""
+        trace_id = str(uuid.uuid4())
+
+        # 生成任务名称
+        task_name = config.name or await self._generate_task_name(messages)
+
+        # 准备工具 Schema
+        tool_schemas = self._get_tool_schemas(config.tools)
+
+        trace_obj = Trace(
+            trace_id=trace_id,
+            mode="agent",
+            task=task_name,
+            agent_type=config.agent_type,
+            parent_trace_id=config.parent_trace_id,
+            parent_goal_id=config.parent_goal_id,
+            uid=config.uid,
+            model=config.model,
+            tools=tool_schemas,
+            llm_params={"temperature": config.temperature, **config.extra_llm_params},
+            status="running",
+        )
+
+        goal_tree = self.goal_tree or GoalTree(mission=task_name)
+
+        if self.trace_store:
+            await self.trace_store.create_trace(trace_obj)
+            await self.trace_store.update_goal_tree(trace_id, goal_tree)
+
+        return trace_obj, goal_tree, 1
+
+    async def _prepare_existing_trace(
+        self,
+        config: RunConfig,
+    ) -> Tuple[Trace, Optional[GoalTree], int]:
+        """加载已有 Trace(续跑或回溯)"""
+        if not self.trace_store:
+            raise ValueError("trace_store required for continue/rewind")
+
+        trace_obj = await self.trace_store.get_trace(config.trace_id)
+        if not trace_obj:
+            raise ValueError(f"Trace not found: {config.trace_id}")
+
+        goal_tree = await self.trace_store.get_goal_tree(config.trace_id)
+        if goal_tree is None:
+            # 防御性兜底:trace 存在但 goal.json 丢失时,创建空树
+            goal_tree = GoalTree(mission=trace_obj.task or "Agent task")
+            await self.trace_store.update_goal_tree(config.trace_id, goal_tree)
+
+        # 自动判断行为:after_sequence 为 None 或 == head → 续跑;< head → 回溯
+        after_seq = config.after_sequence
+
+        # 如果 after_seq > head_sequence,说明 generator 被强制关闭时 store 的
+        # head_sequence 未来得及更新(仍停在 Phase 2 写入的初始值)。
+        # 用 last_sequence 修正 head_sequence,确保续跑时能看到完整历史。
+        if after_seq is not None and after_seq > trace_obj.head_sequence:
+            trace_obj.head_sequence = trace_obj.last_sequence
+            await self.trace_store.update_trace(
+                config.trace_id, head_sequence=trace_obj.head_sequence
+            )
+
+        if after_seq is not None and after_seq < trace_obj.head_sequence:
+            # 回溯模式
+            sequence = await self._rewind(config.trace_id, after_seq, goal_tree)
+        else:
+            # 续跑模式:从 last_sequence + 1 开始
+            sequence = trace_obj.last_sequence + 1
+
+        # 状态置为 running
+        await self.trace_store.update_trace(
+            config.trace_id,
+            status="running",
+            completed_at=None,
+        )
+        trace_obj.status = "running"
+
+        return trace_obj, goal_tree, sequence
+
+    # ===== Phase 2: BUILD HISTORY =====
+   
+    async def _build_history(
+        self,
+        trace_id: str,
+        new_messages: List[Dict],
+        goal_tree: Optional[GoalTree],
+        config: RunConfig,
+        sequence: int,
+    ) -> Tuple[List[Dict], int, List[Message]]:
+        """
+        构建完整的 LLM 消息历史
+
+        1. 从 head_sequence 沿 parent chain 加载主路径消息(续跑/回溯场景)
+        2. 构建 system prompt(新建时注入 skills)
+        3. 新建时:在第一条 user message 末尾注入当前经验
+        4. 追加 input messages(设置 parent_sequence 链接到当前 head)
+
+        Returns:
+            (history, next_sequence, created_messages, head_sequence)
+            created_messages: 本次新创建并持久化的 Message 列表,供 run() yield 给调用方
+            head_sequence: 当前主路径头节点的 sequence
+        """
+        history: List[Dict] = []
+        created_messages: List[Message] = []
+        head_seq: Optional[int] = None  # 当前主路径的头节点 sequence
+
+        # 1. 加载已有 messages(通过主路径遍历)
+        if config.trace_id and self.trace_store:
+            trace_obj = await self.trace_store.get_trace(trace_id)
+            if trace_obj and trace_obj.head_sequence > 0:
+                main_path = await self.trace_store.get_main_path_messages(
+                    trace_id, trace_obj.head_sequence
+                )
+
+                # 修复 orphaned tool_calls(中断导致的 tool_call 无 tool_result)
+                main_path, sequence = await self._heal_orphaned_tool_calls(
+                    main_path, trace_id, goal_tree, sequence,
+                )
+
+                history = [msg.to_llm_dict() for msg in main_path]
+                if main_path:
+                    head_seq = main_path[-1].sequence
+
+        # 2. 构建/注入 skills 到 system prompt
+        has_system = any(m.get("role") == "system" for m in history)
+        has_system_in_new = any(m.get("role") == "system" for m in new_messages)
+
+        if not has_system:
+            if has_system_in_new:
+                # 入参消息已含 system,将 skills 注入其中(在 step 4 持久化之前)
+                augmented = []
+                for msg in new_messages:
+                    if msg.get("role") == "system":
+                        base = msg.get("content") or ""
+                        enriched = await self._build_system_prompt(config, base_prompt=base)
+                        augmented.append({**msg, "content": enriched or base})
+                    else:
+                        augmented.append(msg)
+                new_messages = augmented
+            else:
+                # 没有 system,自动构建并插入历史
+                system_prompt = await self._build_system_prompt(config)
+                if system_prompt:
+                    history = [{"role": "system", "content": system_prompt}] + history
+
+                    if self.trace_store:
+                        system_msg = Message.create(
+                            trace_id=trace_id, role="system", sequence=sequence,
+                            goal_id=None, content=system_prompt,
+                            parent_sequence=None,  # system message 是 root
+                        )
+                        await self.trace_store.add_message(system_msg)
+                        created_messages.append(system_msg)
+                        head_seq = sequence
+                        sequence += 1
+
+        # 3. 追加新 messages(设置 parent_sequence 链接到当前 head)
+        for msg_dict in new_messages:
+            history.append(msg_dict)
+
+            if self.trace_store:
+                stored_msg = Message.from_llm_dict(
+                    msg_dict, trace_id=trace_id, sequence=sequence,
+                    goal_id=None, parent_sequence=head_seq,
+                )
+                await self.trace_store.add_message(stored_msg)
+                created_messages.append(stored_msg)
+                head_seq = sequence
+                sequence += 1
+
+        # 5. 更新 trace 的 head_sequence
+        if self.trace_store and head_seq is not None:
+            await self.trace_store.update_trace(trace_id, head_sequence=head_seq)
+
+        return history, sequence, created_messages, head_seq or 0
+
+    # ===== Phase 3: AGENT LOOP =====
+
+    async def _agent_loop(
+        self,
+        trace: Trace,
+        history: List[Dict],
+        goal_tree: Optional[GoalTree],
+        config: RunConfig,
+        sequence: int,
+    ) -> AsyncIterator[Union[Trace, Message]]:
+        """ReAct 循环"""
+        trace_id = trace.trace_id
+        tool_schemas = self._get_tool_schemas(config.tools)
+
+        # 当前主路径头节点的 sequence(用于设置 parent_sequence)
+        head_seq = trace.head_sequence
+
+        for iteration in range(config.max_iterations):
+            # 检查取消信号
+            cancel_event = self._cancel_events.get(trace_id)
+            if cancel_event and cancel_event.is_set():
+                logger.info(f"Trace {trace_id} stopped by user")
+                if self.trace_store:
+                    await self.trace_store.update_trace(
+                        trace_id,
+                        status="stopped",
+                        head_sequence=head_seq,
+                        completed_at=datetime.now(),
+                    )
+                    trace_obj = await self.trace_store.get_trace(trace_id)
+                    if trace_obj:
+                        yield trace_obj
+                return
+
+            # Level 1 压缩:GoalTree 过滤(当消息超过阈值时触发)
+            compression_config = CompressionConfig()
+            token_count = estimate_tokens(history)
+            max_tokens = compression_config.get_max_tokens(config.model)
+
+            # 计算使用率
+            progress_pct = (token_count / max_tokens * 100) if max_tokens > 0 else 0
+            msg_count = len(history)
+            img_count = sum(
+                1 for msg in history
+                if isinstance(msg.get("content"), list)
+                for part in msg["content"]
+                if isinstance(part, dict) and part.get("type") in ("image", "image_url")
+            )
+
+            # 更新 context usage 快照
+            self._context_usage[trace_id] = ContextUsage(
+                trace_id=trace_id,
+                message_count=msg_count,
+                token_count=token_count,
+                max_tokens=max_tokens,
+                usage_percent=progress_pct,
+                image_count=img_count,
+            )
+
+            # 阈值警告(30%, 50%, 80%)
+            if trace_id not in self._context_warned:
+                self._context_warned[trace_id] = set()
+
+            for threshold in [30, 50, 80]:
+                if progress_pct >= threshold and threshold not in self._context_warned[trace_id]:
+                    self._context_warned[trace_id].add(threshold)
+                    logger.warning(
+                        f"Context 使用率达到 {threshold}%: {token_count:,} / {max_tokens:,} tokens ({msg_count} 条消息)"
+                    )
+
+            # 检查是否需要压缩(token 或消息数量超限)
+            needs_compression_by_tokens = token_count > max_tokens
+            needs_compression_by_count = (
+                compression_config.max_messages > 0 and
+                msg_count > compression_config.max_messages
+            )
+            needs_compression = needs_compression_by_tokens or needs_compression_by_count
+
+            # 知识提取:在任何压缩发生前,用完整 history 做反思
+            if needs_compression and config.knowledge.enable_extraction:
+                await self._run_reflect(
+                    trace_id, history, config,
+                    reflect_prompt=config.knowledge.get_reflect_prompt(),
+                    source_name="compression_reflection",
+                )
+
+            # Level 1 压缩:GoalTree 过滤
+            if needs_compression and self.trace_store and goal_tree:
+                if head_seq > 0:
+                    main_path_msgs = await self.trace_store.get_main_path_messages(
+                        trace_id, head_seq
+                    )
+                    filtered_msgs = filter_by_goal_status(main_path_msgs, goal_tree)
+                    if len(filtered_msgs) < len(main_path_msgs):
+                        logger.info(
+                            "Level 1 压缩: %d -> %d 条消息",
+                            len(main_path_msgs), len(filtered_msgs),
+                        )
+                        history = [msg.to_llm_dict() for msg in filtered_msgs]
+                    else:
+                        logger.info(
+                            "Level 1 压缩: 无可过滤消息 (%d 条全部保留)",
+                            len(main_path_msgs),
+                        )
+            elif needs_compression:
+                logger.warning(
+                    "消息数 (%d) 或 token 数 (%d) 超过阈值,但无法执行 Level 1 压缩(缺少 store 或 goal_tree)",
+                    msg_count, token_count,
+                )
+
+            # Level 2 压缩:LLM 总结(Level 1 后仍超阈值时触发)
+            token_count_after = estimate_tokens(history)
+            msg_count_after = len(history)
+            needs_level2_by_tokens = token_count_after > max_tokens
+            needs_level2_by_count = (
+                compression_config.max_messages > 0 and
+                msg_count_after > compression_config.max_messages
+            )
+            needs_level2 = needs_level2_by_tokens or needs_level2_by_count
+
+            if needs_level2:
+                logger.info(
+                    "Level 1 后仍超阈值 (消息数=%d/%d, token=%d/%d),触发 Level 2 压缩",
+                    msg_count_after, compression_config.max_messages, token_count_after, max_tokens,
+                )
+                history, head_seq, sequence = await self._compress_history(
+                    trace_id, history, goal_tree, config, sequence, head_seq,
+                )
+
+            # 压缩完成后,输出最终发给模型的消息列表
+            if needs_compression:
+                logger.info("压缩完成,发送给模型的消息列表:")
+                for idx, msg in enumerate(history):
+                    role = msg.get("role", "unknown")
+                    content = msg.get("content", "")
+                    if isinstance(content, str):
+                        preview = content[:100] + ("..." if len(content) > 100 else "")
+                    elif isinstance(content, list):
+                        preview = f"[{len(content)} blocks]"
+                    else:
+                        preview = str(content)[:100]
+                    logger.info(f"  [{idx}] {role}: {preview}")
+
+            # 构建 LLM messages(注入上下文)
+            llm_messages = list(history)
+
+            # 对历史消息应用 Prompt Caching
+            llm_messages = self._add_cache_control(
+                llm_messages,
+                config.model,
+                config.enable_prompt_caching
+            )
+
+            # 周期性注入 GoalTree + Collaborators(动态内容追加在缓存点之后)
+            if iteration % CONTEXT_INJECTION_INTERVAL == 0:
+                context_injection = self._build_context_injection(trace, goal_tree)
+                if context_injection:
+                    system_msg = {"role": "system", "content": context_injection}
+                    llm_messages.append(system_msg)
+
+                    # 持久化上下文注入消息
+                    if self.trace_store:
+                        current_goal_id = goal_tree.current_id if (goal_tree and goal_tree.current_id) else None
+                        system_message = Message.create(
+                            trace_id=trace_id,
+                            role="system",
+                            sequence=sequence,
+                            goal_id=current_goal_id,
+                            parent_sequence=head_seq if head_seq > 0 else None,
+                            content=f"[上下文注入]\n{context_injection}",
+                        )
+                        await self.trace_store.add_message(system_message)
+                        history.append(system_msg)
+                        head_seq = sequence
+                        sequence += 1
+
+
+            # 调用 LLM
+            result = await self.llm_call(
+                messages=llm_messages,
+                model=config.model,
+                tools=tool_schemas,
+                temperature=config.temperature,
+                **config.extra_llm_params,
+            )
+
+            response_content = result.get("content", "")
+            tool_calls = result.get("tool_calls")
+            finish_reason = result.get("finish_reason")
+            prompt_tokens = result.get("prompt_tokens", 0)
+            completion_tokens = result.get("completion_tokens", 0)
+            step_cost = result.get("cost", 0)
+            cache_creation_tokens = result.get("cache_creation_tokens")
+            cache_read_tokens = result.get("cache_read_tokens")
+
+            # 按需自动创建 root goal
+            if goal_tree and not goal_tree.goals and tool_calls:
+                has_goal_call = any(
+                    tc.get("function", {}).get("name") == "goal"
+                    for tc in tool_calls
+                )
+                logger.debug(f"[Auto Root Goal] Before tool execution: goal_tree.goals={len(goal_tree.goals)}, has_goal_call={has_goal_call}, tool_calls={[tc.get('function', {}).get('name') for tc in tool_calls]}")
+                if not has_goal_call:
+                    mission = goal_tree.mission
+                    root_desc = mission[:200] if len(mission) > 200 else mission
+                    goal_tree.add_goals(
+                        descriptions=[root_desc],
+                        reasons=["系统自动创建:Agent 未显式创建目标"],
+                        parent_id=None
+                    )
+                    goal_tree.focus(goal_tree.goals[0].id)
+                    if self.trace_store:
+                        await self.trace_store.add_goal(trace_id, goal_tree.goals[0])
+                        await self.trace_store.update_goal_tree(trace_id, goal_tree)
+                    logger.info(f"自动创建 root goal: {goal_tree.goals[0].id}")
+                else:
+                    logger.debug(f"[Auto Root Goal] 检测到 goal 工具调用,跳过自动创建")
+
+            # 获取当前 goal_id
+            current_goal_id = goal_tree.current_id if (goal_tree and goal_tree.current_id) else None
+
+            # 记录 assistant Message(parent_sequence 指向当前 head)
+            assistant_msg = Message.create(
+                trace_id=trace_id,
+                role="assistant",
+                sequence=sequence,
+                goal_id=current_goal_id,
+                parent_sequence=head_seq if head_seq > 0 else None,
+                content={"text": response_content, "tool_calls": tool_calls},
+                prompt_tokens=prompt_tokens,
+                completion_tokens=completion_tokens,
+                cache_creation_tokens=cache_creation_tokens,
+                cache_read_tokens=cache_read_tokens,
+                finish_reason=finish_reason,
+                cost=step_cost,
+            )
+
+            if self.trace_store:
+                await self.trace_store.add_message(assistant_msg)
+                # 记录模型使用
+                await self.trace_store.record_model_usage(
+                    trace_id=trace_id,
+                    sequence=sequence - 1,  # assistant_msg的sequence
+                    role="assistant",
+                    model=config.model,
+                    prompt_tokens=prompt_tokens,
+                    completion_tokens=completion_tokens,
+                    cache_read_tokens=cache_read_tokens or 0,
+                )
+
+            yield assistant_msg
+            head_seq = sequence
+            sequence += 1
+
+            # 处理工具调用
+            # 截断兜底:finish_reason == "length" 说明响应被 max_tokens 截断,
+            # tool call 参数很可能不完整,不应执行,改为提示模型分批操作
+            if tool_calls and finish_reason == "length":
+                logger.warning(
+                    "[Runner] 响应被 max_tokens 截断,跳过 %d 个不完整的 tool calls",
+                    len(tool_calls),
+                )
+                truncation_hint = TRUNCATION_HINT
+                history.append({
+                    "role": "assistant",
+                    "content": response_content,
+                    "tool_calls": tool_calls,
+                })
+                # 为每个被截断的 tool call 返回错误结果
+                for tc in tool_calls:
+                    history.append({
+                        "role": "tool",
+                        "tool_call_id": tc["id"],
+                        "content": truncation_hint,
+                    })
+                continue
+
+            if tool_calls and config.auto_execute_tools:
+                history.append({
+                    "role": "assistant",
+                    "content": response_content,
+                    "tool_calls": tool_calls,
+                })
+
+                for tc in tool_calls:
+                    current_goal_id = goal_tree.current_id if (goal_tree and goal_tree.current_id) else None
+
+                    tool_name = tc["function"]["name"]
+                    tool_args = tc["function"]["arguments"]
+
+                    if isinstance(tool_args, str):
+                        tool_args = json.loads(tool_args) if tool_args.strip() else {}
+                    elif tool_args is None:
+                        tool_args = {}
+
+                    # 注入知识管理工具的默认字段
+                    if tool_name == "knowledge_save":
+                        tool_args.setdefault("owner", config.knowledge.get_owner(config.agent_id))
+                        if config.knowledge.default_tags:
+                            existing_tags = tool_args.get("tags") or {}
+                            merged_tags = {**config.knowledge.default_tags, **existing_tags}
+                            tool_args["tags"] = merged_tags
+                        if config.knowledge.default_scopes:
+                            existing_scopes = tool_args.get("scopes") or []
+                            tool_args["scopes"] = existing_scopes + config.knowledge.default_scopes
+                    elif tool_name == "knowledge_search":
+                        if config.knowledge.default_search_types and "types" not in tool_args:
+                            tool_args["types"] = config.knowledge.default_search_types
+                        if config.knowledge.default_search_owner and "owner" not in tool_args:
+                            tool_args["owner"] = config.knowledge.default_search_owner
+
+                    # 记录工具调用(INFO 级别,显示参数)
+                    args_str = json.dumps(tool_args, ensure_ascii=False)
+                    args_display = args_str[:100] + "..." if len(args_str) > 100 else args_str
+                    logger.info(f"[Tool Call] {tool_name}({args_display})")
+
+                    tool_result = await self.tools.execute(
+                        tool_name,
+                        tool_args,
+                        uid=config.uid or "",
+                        context={
+                            "store": self.trace_store,
+                            "trace_id": trace_id,
+                            "goal_id": current_goal_id,
+                            "runner": self,
+                            "goal_tree": goal_tree,
+                        }
+                    )
+
+                    # 如果是 goal 工具,记录执行后的状态
+                    if tool_name == "goal" and goal_tree:
+                        logger.debug(f"[Goal Tool] After execution: goal_tree.goals={len(goal_tree.goals)}, current_id={goal_tree.current_id}")
+
+                    # 跟踪保存的知识 ID
+                    if tool_name == "knowledge_save" and isinstance(tool_result, dict):
+                        metadata = tool_result.get("metadata", {})
+                        knowledge_id = metadata.get("knowledge_id")
+                        if knowledge_id:
+                            if trace_id not in self._saved_knowledge_ids:
+                                self._saved_knowledge_ids[trace_id] = []
+                            self._saved_knowledge_ids[trace_id].append(knowledge_id)
+                            logger.info(f"[Knowledge Tracking] 记录保存的知识 ID: {knowledge_id}")
+
+                    # --- 支持多模态工具反馈 ---
+                    # execute() 返回 dict{"text","images","tool_usage"} 或 str
+                    # 统一为dict格式
+                    if isinstance(tool_result, str):
+                        tool_result = {"text": tool_result}
+
+                    tool_text = tool_result.get("text", str(tool_result))
+                    tool_images = tool_result.get("images", [])
+                    tool_usage = tool_result.get("tool_usage")  # 新增:提取tool_usage
+
+                    # 处理多模态消息
+                    if tool_images:
+                        tool_result_text = tool_text
+                        # 构建多模态消息格式
+                        tool_content_for_llm = [{"type": "text", "text": tool_text}]
+                        for img in tool_images:
+                            if img.get("type") == "base64" and img.get("data"):
+                                media_type = img.get("media_type", "image/png")
+                                tool_content_for_llm.append({
+                                    "type": "image_url",
+                                    "image_url": {
+                                        "url": f"data:{media_type};base64,{img['data']}"
+                                    }
+                                })
+                        img_count = len(tool_content_for_llm) - 1  # 减去 text 块
+                        print(f"[Runner] 多模态工具反馈: tool={tool_name}, images={img_count}, text_len={len(tool_result_text)}")
+                    else:
+                        tool_result_text = tool_text
+                        tool_content_for_llm = tool_text
+
+                    tool_msg = Message.create(
+                        trace_id=trace_id,
+                        role="tool",
+                        sequence=sequence,
+                        goal_id=current_goal_id,
+                        parent_sequence=head_seq,
+                        tool_call_id=tc["id"],
+                        # 存储完整内容:有图片时保留 list(含 image_url),纯文本时存字符串
+                        content={"tool_name": tool_name, "result": tool_content_for_llm},
+                    )
+
+                    if self.trace_store:
+                        await self.trace_store.add_message(tool_msg)
+                        # 记录工具的模型使用
+                        if tool_usage:
+                            await self.trace_store.record_model_usage(
+                                trace_id=trace_id,
+                                sequence=sequence,
+                                role="tool",
+                                tool_name=tool_name,
+                                model=tool_usage.get("model"),
+                                prompt_tokens=tool_usage.get("prompt_tokens", 0),
+                                completion_tokens=tool_usage.get("completion_tokens", 0),
+                                cache_read_tokens=tool_usage.get("cache_read_tokens", 0),
+                            )
+                        # 截图单独存为同名 PNG 文件
+                        if tool_images:
+                            import base64 as b64mod
+                            for img in tool_images:
+                                if img.get("data"):
+                                    png_path = self.trace_store._get_messages_dir(trace_id) / f"{tool_msg.message_id}.png"
+                                    png_path.write_bytes(b64mod.b64decode(img["data"]))
+                                    print(f"[Runner] 截图已保存: {png_path.name}")
+                                    break  # 只存第一张
+
+                    yield tool_msg
+                    head_seq = sequence
+                    sequence += 1
+
+                    history.append({
+                        "role": "tool",
+                        "tool_call_id": tc["id"],
+                        "name": tool_name,
+                        "content": tool_content_for_llm,
+                    })
+
+                continue  # 继续循环
+
+            # 无工具调用,任务完成
+            break
+
+        # 任务完成后复盘提取
+        if config.knowledge.enable_completion_extraction:
+            await self._extract_knowledge_on_completion(trace_id, history, config)
+
+        # 清理 trace 相关的跟踪数据
+        self._context_warned.pop(trace_id, None)
+        self._context_usage.pop(trace_id, None)
+        self._saved_knowledge_ids.pop(trace_id, None)
+
+        # 更新 head_sequence 并完成 Trace
+        if self.trace_store:
+            await self.trace_store.update_trace(
+                trace_id,
+                status="completed",
+                head_sequence=head_seq,
+                completed_at=datetime.now(),
+            )
+            trace_obj = await self.trace_store.get_trace(trace_id)
+            if trace_obj:
+                yield trace_obj
+
+    # ===== Level 2: LLM 压缩 =====
+
+    async def _compress_history(
+        self,
+        trace_id: str,
+        history: List[Dict],
+        goal_tree: Optional[GoalTree],
+        config: RunConfig,
+        sequence: int,
+        head_seq: int,
+    ) -> Tuple[List[Dict], int, int]:
+        """
+        Level 2 压缩:LLM 总结
+
+        Step 1: 压缩总结 — LLM 生成 summary
+        Step 2: 存储 summary 为新消息,parent_sequence 跳到 system msg
+        Step 3: 重建 history
+
+        Returns:
+            (new_history, new_head_seq, next_sequence)
+        """
+        logger.info("Level 2 压缩开始: trace=%s, 当前 history 长度=%d", trace_id, len(history))
+
+        # 找到 system message 的 sequence(主路径第一条消息)
+        system_msg_seq = None
+        system_msg_dict = None
+        if self.trace_store:
+            trace_obj = await self.trace_store.get_trace(trace_id)
+            if trace_obj and trace_obj.head_sequence > 0:
+                main_path = await self.trace_store.get_main_path_messages(
+                    trace_id, trace_obj.head_sequence
+                )
+                for msg in main_path:
+                    if msg.role == "system":
+                        system_msg_seq = msg.sequence
+                        system_msg_dict = msg.to_llm_dict()
+                        break
+
+        # Fallback: 从 history 中找 system message
+        if system_msg_dict is None:
+            for msg_dict in history:
+                if msg_dict.get("role") == "system":
+                    system_msg_dict = msg_dict
+                    break
+
+        if system_msg_dict is None:
+            logger.warning("Level 2 压缩跳过:未找到 system message")
+            return history, head_seq, sequence
+
+        # --- Step 1: 压缩总结 ---
+        compress_prompt = build_compression_prompt(goal_tree)
+        compress_messages = list(history) + [{"role": "user", "content": compress_prompt}]
+
+        # 应用 Prompt Caching
+        compress_messages = self._add_cache_control(
+            compress_messages,
+            config.model,
+            config.enable_prompt_caching
+        )
+
+        compress_result = await self.llm_call(
+            messages=compress_messages,
+            model=config.model,
+            tools=[],
+            temperature=config.temperature,
+            **config.extra_llm_params,
+        )
+
+        raw_output = compress_result.get("content", "").strip()
+        if not raw_output:
+            logger.warning("Level 2 压缩跳过:LLM 未返回内容")
+            return history, head_seq, sequence
+
+        # 提取 [[SUMMARY]] 块
+        summary_text = raw_output
+        if "[[SUMMARY]]" in raw_output:
+            summary_text = raw_output[raw_output.index("[[SUMMARY]]") + len("[[SUMMARY]]"):].strip()
+
+        if not summary_text:
+            logger.warning("Level 2 压缩跳过:LLM 未返回 summary")
+            return history, head_seq, sequence
+
+        # --- Step 3: 存储 summary 消息 ---
+        summary_with_header = build_summary_header(summary_text)
+
+        summary_msg = Message.create(
+            trace_id=trace_id,
+            role="user",
+            sequence=sequence,
+            goal_id=None,
+            parent_sequence=system_msg_seq,  # 跳到 system msg,跳过所有中间消息
+            content=summary_with_header,
+        )
+
+        if self.trace_store:
+            await self.trace_store.add_message(summary_msg)
+
+        new_head_seq = sequence
+        sequence += 1
+
+        # --- Step 4: 重建 history ---
+        new_history = [system_msg_dict, summary_msg.to_llm_dict()]
+
+        # 更新 trace head_sequence
+        if self.trace_store:
+            await self.trace_store.update_trace(
+                trace_id,
+                head_sequence=new_head_seq,
+            )
+
+        logger.info(
+            "Level 2 压缩完成: 旧 history %d 条 → 新 history %d 条, summary 长度=%d",
+            len(history), len(new_history), len(summary_text),
+        )
+
+        return new_history, new_head_seq, sequence
+
+    async def _run_reflect(
+        self,
+        trace_id: str,
+        history: List[Dict],
+        config: RunConfig,
+        reflect_prompt: str,
+        source_name: str,
+    ) -> None:
+        """
+        执行反思提取:LLM 对历史消息进行反思,直接调用 knowledge_save 工具保存经验。
+
+        Args:
+            trace_id: Trace ID(作为知识的 message_id)
+            history: 当前对话历史
+            config: 运行配置
+            reflect_prompt: 反思 prompt
+            source_name: 来源名称(用于区分压缩时/完成时)
+        """
+        try:
+            reflect_messages = list(history) + [{"role": "user", "content": reflect_prompt}]
+            reflect_messages = self._add_cache_control(
+                reflect_messages, config.model, config.enable_prompt_caching
+            )
+
+            # 只暴露 knowledge_save 工具,让 LLM 直接调用
+            knowledge_save_schema = self._get_tool_schemas(["knowledge_save"])
+
+            reflect_result = await self.llm_call(
+                messages=reflect_messages,
+                model=config.model,
+                tools=knowledge_save_schema,
+                temperature=0.2,
+                **config.extra_llm_params,
+            )
+
+            tool_calls = reflect_result.get("tool_calls") or []
+            if not tool_calls:
+                logger.info("反思阶段无经验保存 (source=%s)", source_name)
+                return
+
+            saved_count = 0
+            for tc in tool_calls:
+                tool_name = tc.get("function", {}).get("name")
+                if tool_name != "knowledge_save":
+                    continue
+
+                tool_args = tc.get("function", {}).get("arguments") or {}
+                if isinstance(tool_args, str):
+                    tool_args = json.loads(tool_args) if tool_args.strip() else {}
+
+                # 注入来源信息(LLM 不需要填写这些字段)
+                tool_args.setdefault("source_name", source_name)
+                tool_args.setdefault("source_category", "exp")
+                tool_args.setdefault("message_id", trace_id)
+
+                # 注入知识管理默认字段
+                tool_args.setdefault("owner", config.knowledge.get_owner(config.agent_id))
+                if config.knowledge.default_tags:
+                    existing_tags = tool_args.get("tags") or {}
+                    merged_tags = {**config.knowledge.default_tags, **existing_tags}
+                    tool_args["tags"] = merged_tags
+                if config.knowledge.default_scopes:
+                    tool_args.setdefault("scopes", config.knowledge.default_scopes)
+
+                try:
+                    await self.tools.execute(
+                        "knowledge_save",
+                        tool_args,
+                        uid=config.uid or "",
+                        context={"store": self.trace_store, "trace_id": trace_id},
+                    )
+                    saved_count += 1
+                except Exception as e:
+                    logger.warning("保存经验失败: %s", e)
+
+            logger.info("已提取并保存 %d 条经验 (source=%s)", saved_count, source_name)
+
+        except Exception as e:
+            logger.error("知识反思提取失败 (source=%s): %s", source_name, e)
+
+    async def _extract_knowledge_on_completion(
+        self,
+        trace_id: str,
+        history: List[Dict],
+        config: RunConfig,
+    ) -> None:
+        """任务完成后执行全局复盘,提取经验保存到知识库。"""
+        logger.info("任务完成后复盘提取: trace=%s", trace_id)
+        await self._run_reflect(
+            trace_id, history, config,
+            reflect_prompt=config.knowledge.get_completion_reflect_prompt(),
+            source_name="completion_reflection",
+        )
+
+    # ===== 回溯(Rewind)=====
+
+    async def _rewind(
+        self,
+        trace_id: str,
+        after_sequence: int,
+        goal_tree: Optional[GoalTree],
+    ) -> int:
+        """
+        执行回溯:快照 GoalTree,重建干净树,设置 head_sequence
+
+        新消息的 parent_sequence 将指向 rewind 点,旧消息通过树结构自然脱离主路径。
+
+        Returns:
+            下一个可用的 sequence 号
+        """
+        if not self.trace_store:
+            raise ValueError("trace_store required for rewind")
+
+        # 1. 加载所有 messages(用于 safe cutoff 和 max sequence)
+        all_messages = await self.trace_store.get_trace_messages(trace_id)
+
+        if not all_messages:
+            return 1
+
+        # 2. 找到安全截断点(确保不截断在 tool_call 和 tool response 之间)
+        cutoff = self._find_safe_cutoff(all_messages, after_sequence)
+
+        # 3. 快照并重建 GoalTree
+        if goal_tree:
+            # 获取截断点消息的 created_at 作为时间界限
+            cutoff_msg = None
+            for msg in all_messages:
+                if msg.sequence == cutoff:
+                    cutoff_msg = msg
+                    break
+
+            cutoff_time = cutoff_msg.created_at if cutoff_msg else datetime.now()
+
+            # 快照到 events(含 head_sequence 供前端感知分支切换)
+            await self.trace_store.append_event(trace_id, "rewind", {
+                "after_sequence": cutoff,
+                "head_sequence": cutoff,
+                "goal_tree_snapshot": goal_tree.to_dict(),
+            })
+
+            # 按时间重建干净的 GoalTree
+            new_tree = goal_tree.rebuild_for_rewind(cutoff_time)
+            await self.trace_store.update_goal_tree(trace_id, new_tree)
+
+            # 更新内存中的引用
+            goal_tree.goals = new_tree.goals
+            goal_tree.current_id = new_tree.current_id
+
+        # 4. 更新 head_sequence 到 rewind 点
+        await self.trace_store.update_trace(trace_id, head_sequence=cutoff)
+
+        # 5. 返回 next sequence(全局递增,不复用)
+        max_seq = max((m.sequence for m in all_messages), default=0)
+        return max_seq + 1
+
+    def _find_safe_cutoff(self, messages: List[Message], after_sequence: int) -> int:
+        """
+        找到安全的截断点。
+
+        如果 after_sequence 指向一条带 tool_calls 的 assistant message,
+        则自动扩展到其所有对应的 tool response 之后。
+        """
+        cutoff = after_sequence
+
+        # 找到 after_sequence 对应的 message
+        target_msg = None
+        for msg in messages:
+            if msg.sequence == after_sequence:
+                target_msg = msg
+                break
+
+        if not target_msg:
+            return cutoff
+
+        # 如果是 assistant 且有 tool_calls,找到所有对应的 tool responses
+        if target_msg.role == "assistant":
+            content = target_msg.content
+            if isinstance(content, dict) and content.get("tool_calls"):
+                tool_call_ids = set()
+                for tc in content["tool_calls"]:
+                    if isinstance(tc, dict) and tc.get("id"):
+                        tool_call_ids.add(tc["id"])
+
+                # 找到这些 tool_call 对应的 tool messages
+                for msg in messages:
+                    if (msg.role == "tool" and msg.tool_call_id
+                            and msg.tool_call_id in tool_call_ids):
+                        cutoff = max(cutoff, msg.sequence)
+
+        return cutoff
+
+    async def _heal_orphaned_tool_calls(
+        self,
+        messages: List[Message],
+        trace_id: str,
+        goal_tree: Optional[GoalTree],
+        sequence: int,
+    ) -> tuple:
+        """
+        检测并修复消息历史中的 orphaned tool_calls。
+
+        当 agent 被 stop/crash 中断时,可能有 assistant 的 tool_calls 没有对应的
+        tool results(包括多 tool_call 部分完成的情况)。直接发给 LLM 会导致 400。
+
+        修复策略:为每个缺失的 tool_result 插入合成的"中断通知"消息,而非裁剪。
+        - 普通工具:简短中断提示
+        - agent/evaluate:包含 sub_trace_id、执行统计、continue_from 指引
+
+        合成消息持久化到 store,确保幂等(下次续跑不再触发)。
+
+        Returns:
+            (healed_messages, next_sequence)
+        """
+        if not messages:
+            return messages, sequence
+
+        # 收集所有 tool_call IDs → (assistant_msg, tool_call_dict)
+        tc_map: Dict[str, tuple] = {}
+        result_ids: set = set()
+
+        for msg in messages:
+            if msg.role == "assistant":
+                content = msg.content
+                if isinstance(content, dict) and content.get("tool_calls"):
+                    for tc in content["tool_calls"]:
+                        tc_id = tc.get("id")
+                        if tc_id:
+                            tc_map[tc_id] = (msg, tc)
+            elif msg.role == "tool" and msg.tool_call_id:
+                result_ids.add(msg.tool_call_id)
+
+        orphaned_ids = [tc_id for tc_id in tc_map if tc_id not in result_ids]
+        if not orphaned_ids:
+            return messages, sequence
+
+        logger.info(
+            "检测到 %d 个 orphaned tool_calls,生成合成中断通知",
+            len(orphaned_ids),
+        )
+
+        healed = list(messages)
+        head_seq = messages[-1].sequence
+
+        for tc_id in orphaned_ids:
+            assistant_msg, tc = tc_map[tc_id]
+            tool_name = tc.get("function", {}).get("name", "unknown")
+
+            if tool_name in ("agent", "evaluate"):
+                result_text = self._build_agent_interrupted_result(
+                    tc, goal_tree, assistant_msg,
+                )
+            else:
+                result_text = build_tool_interrupted_message(tool_name)
+
+            synthetic_msg = Message.create(
+                trace_id=trace_id,
+                role="tool",
+                sequence=sequence,
+                goal_id=assistant_msg.goal_id,
+                parent_sequence=head_seq,
+                tool_call_id=tc_id,
+                content={"tool_name": tool_name, "result": result_text},
+            )
+
+            if self.trace_store:
+                await self.trace_store.add_message(synthetic_msg)
+
+            healed.append(synthetic_msg)
+            head_seq = sequence
+            sequence += 1
+
+        # 更新 trace head/last sequence
+        if self.trace_store:
+            await self.trace_store.update_trace(
+                trace_id,
+                head_sequence=head_seq,
+                last_sequence=max(head_seq, sequence - 1),
+            )
+
+        return healed, sequence
+
+    def _build_agent_interrupted_result(
+        self,
+        tc: Dict,
+        goal_tree: Optional[GoalTree],
+        assistant_msg: Message,
+    ) -> str:
+        """为中断的 agent/evaluate 工具调用构建合成结果(对齐正常返回值格式)"""
+        args_str = tc.get("function", {}).get("arguments", "{}")
+        try:
+            args = json.loads(args_str) if isinstance(args_str, str) else args_str
+        except json.JSONDecodeError:
+            args = {}
+
+        task = args.get("task", "未知任务")
+        if isinstance(task, list):
+            task = "; ".join(task)
+
+        tool_name = tc.get("function", {}).get("name", "agent")
+        mode = "evaluate" if tool_name == "evaluate" else "delegate"
+
+        # 从 goal_tree 查找 sub_trace 信息
+        sub_trace_id = None
+        stats = None
+        if goal_tree and assistant_msg.goal_id:
+            goal = goal_tree.find(assistant_msg.goal_id)
+            if goal and goal.sub_trace_ids:
+                first = goal.sub_trace_ids[0]
+                if isinstance(first, dict):
+                    sub_trace_id = first.get("trace_id")
+                elif isinstance(first, str):
+                    sub_trace_id = first
+                if goal.cumulative_stats:
+                    s = goal.cumulative_stats
+                    if s.message_count > 0:
+                        stats = {
+                            "message_count": s.message_count,
+                            "total_tokens": s.total_tokens,
+                            "total_cost": round(s.total_cost, 4),
+                        }
+
+        result: Dict[str, Any] = {
+            "mode": mode,
+            "status": "interrupted",
+            "summary": AGENT_INTERRUPTED_SUMMARY,
+            "task": task,
+        }
+        if sub_trace_id:
+            result["sub_trace_id"] = sub_trace_id
+            result["hint"] = build_agent_continue_hint(sub_trace_id)
+        if stats:
+            result["stats"] = stats
+
+        return json.dumps(result, ensure_ascii=False, indent=2)
+
+    # ===== 上下文注入 =====
+
+    def _build_context_injection(
+        self,
+        trace: Trace,
+        goal_tree: Optional[GoalTree],
+    ) -> str:
+        """构建周期性注入的上下文(GoalTree + Active Collaborators + Focus 提醒)"""
+        parts = []
+
+        # GoalTree
+        if goal_tree and goal_tree.goals:
+            parts.append(f"## Current Plan\n\n{goal_tree.to_prompt()}")
+
+            # 检测 focus 在有子节点的父目标上:提醒模型 focus 到具体子目标
+            if goal_tree.current_id:
+                children = goal_tree.get_children(goal_tree.current_id)
+                pending_children = [c for c in children if c.status in ("pending", "in_progress")]
+                if pending_children:
+                    child_ids = ", ".join(
+                        goal_tree._generate_display_id(c) for c in pending_children[:3]
+                    )
+                    parts.append(
+                        f"**提醒**:当前焦点在父目标上,建议用 `goal(focus=\"...\")` "
+                        f"切换到具体子目标(如 {child_ids})再执行。"
+                    )
+
+        # Active Collaborators
+        collaborators = trace.context.get("collaborators", [])
+        if collaborators:
+            lines = ["## Active Collaborators"]
+            for c in collaborators:
+                status_str = c.get("status", "unknown")
+                ctype = c.get("type", "agent")
+                summary = c.get("summary", "")
+                name = c.get("name", "unnamed")
+                lines.append(f"- {name} [{ctype}, {status_str}]: {summary}")
+            parts.append("\n".join(lines))
+
+        return "\n\n".join(parts)
+
+    # ===== 辅助方法 =====
+
+    def _add_cache_control(
+        self,
+        messages: List[Dict],
+        model: str,
+        enable: bool
+    ) -> List[Dict]:
+        """
+        为支持的模型添加 Prompt Caching 标记
+
+        策略:固定位置 + 延迟查找
+        1. system message 添加缓存(如果足够长)
+        2. 固定位置缓存点(20, 40, 60, 80),确保每个缓存点间隔 >= 1024 tokens
+        3. 最多使用 4 个缓存点(含 system)
+
+        Args:
+            messages: 原始消息列表
+            model: 模型名称
+            enable: 是否启用缓存
+
+        Returns:
+            添加了 cache_control 的消息列表(深拷贝)
+        """
+        if not enable:
+            return messages
+
+        # 只对 Claude 模型启用
+        if "claude" not in model.lower():
+            return messages
+
+        # 深拷贝避免修改原始数据
+        import copy
+        messages = copy.deepcopy(messages)
+
+        # 策略 1: 为 system message 添加缓存
+        system_cached = False
+        for msg in messages:
+            if msg.get("role") == "system":
+                content = msg.get("content", "")
+                if isinstance(content, str) and len(content) > 1000:
+                    msg["content"] = [{
+                        "type": "text",
+                        "text": content,
+                        "cache_control": {"type": "ephemeral"}
+                    }]
+                    system_cached = True
+                    logger.debug(f"[Cache] 为 system message 添加缓存标记 (len={len(content)})")
+                break
+
+        # 策略 2: 固定位置缓存点
+        CACHE_INTERVAL = 20
+        MAX_POINTS = 3 if system_cached else 4
+        MIN_TOKENS = 1024
+        AVG_TOKENS_PER_MSG = 70
+
+        total_msgs = len(messages)
+        if total_msgs == 0:
+            return messages
+
+        cache_positions = []
+        last_cache_pos = 0
+
+        for i in range(1, MAX_POINTS + 1):
+            target_pos = i * CACHE_INTERVAL - 1  # 19, 39, 59, 79
+
+            if target_pos >= total_msgs:
+                break
+
+            # 从目标位置开始查找合适的 user/assistant 消息
+            for j in range(target_pos, total_msgs):
+                msg = messages[j]
+
+                if msg.get("role") not in ("user", "assistant"):
+                    continue
+
+                content = msg.get("content", "")
+                if not content:
+                    continue
+
+                # 检查 content 是否非空
+                is_valid = False
+                if isinstance(content, str):
+                    is_valid = len(content) > 0
+                elif isinstance(content, list):
+                    is_valid = any(
+                        isinstance(block, dict) and
+                        block.get("type") == "text" and
+                        len(block.get("text", "")) > 0
+                        for block in content
+                    )
+
+                if not is_valid:
+                    continue
+
+                # 检查 token 距离
+                msg_count = j - last_cache_pos
+                estimated_tokens = msg_count * AVG_TOKENS_PER_MSG
+
+                if estimated_tokens >= MIN_TOKENS:
+                    cache_positions.append(j)
+                    last_cache_pos = j
+                    logger.debug(f"[Cache] 在位置 {j} 添加缓存点 (估算 {estimated_tokens} tokens)")
+                    break
+
+        # 应用缓存标记
+        for idx in cache_positions:
+            msg = messages[idx]
+            content = msg.get("content", "")
+
+            if isinstance(content, str):
+                msg["content"] = [{
+                    "type": "text",
+                    "text": content,
+                    "cache_control": {"type": "ephemeral"}
+                }]
+                logger.debug(f"[Cache] 为 message[{idx}] ({msg.get('role')}) 添加缓存标记")
+            elif isinstance(content, list):
+                # 在最后一个 text block 添加 cache_control
+                for block in reversed(content):
+                    if isinstance(block, dict) and block.get("type") == "text":
+                        block["cache_control"] = {"type": "ephemeral"}
+                        logger.debug(f"[Cache] 为 message[{idx}] ({msg.get('role')}) 添加缓存标记")
+                        break
+
+        logger.debug(
+            f"[Cache] 总消息: {total_msgs}, "
+            f"缓存点: {len(cache_positions)} at {cache_positions}"
+        )
+        return messages
+
+    def _get_tool_schemas(self, tools: Optional[List[str]]) -> List[Dict]:
+        """
+        获取工具 Schema
+
+        - tools=None: 使用 registry 中全部已注册工具(含内置 + 外部注册的)
+        - tools=["a", "b"]: 在 BUILTIN_TOOLS 基础上追加指定工具
+        """
+        if tools is None:
+            # 全部已注册工具
+            tool_names = self.tools.get_tool_names()
+        else:
+            # BUILTIN_TOOLS + 显式指定的额外工具
+            tool_names = BUILTIN_TOOLS.copy()
+            for t in tools:
+                if t not in tool_names:
+                    tool_names.append(t)
+        return self.tools.get_schemas(tool_names)
+
+    # 默认 system prompt 前缀(当 config.system_prompt 和前端都未提供 system message 时使用)
+    # 注意:此常量已迁移到 agent.core.prompts,这里保留引用以保持向后兼容
+
+    async def _build_system_prompt(self, config: RunConfig, base_prompt: Optional[str] = None) -> Optional[str]:
+        """构建 system prompt(注入 skills)
+
+        优先级:
+        1. config.skills 显式指定 → 按名称过滤
+        2. config.skills 为 None → 查 preset 的默认 skills 列表
+        3. preset 也无 skills(None)→ 加载全部(向后兼容)
+
+        Args:
+            base_prompt: 已有 system 内容(来自消息或 config.system_prompt),
+                         None 时使用 config.system_prompt
+        """
+        from agent.core.presets import AGENT_PRESETS
+
+        system_prompt = base_prompt if base_prompt is not None else config.system_prompt
+
+        # 确定要加载哪些 skills
+        skills_filter: Optional[List[str]] = config.skills
+        if skills_filter is None:
+            preset = AGENT_PRESETS.get(config.agent_type)
+            if preset is not None:
+                skills_filter = preset.skills  # 可能仍为 None(加载全部)
+
+        # 加载并过滤
+        all_skills = load_skills_from_dir(self.skills_dir)
+        if skills_filter is not None:
+            skills = [s for s in all_skills if s.name in skills_filter]
+        else:
+            skills = all_skills
+
+        skills_text = self._format_skills(skills) if skills else ""
+
+        if system_prompt:
+            if skills_text:
+                system_prompt += f"\n\n## Skills\n{skills_text}"
+        else:
+            system_prompt = DEFAULT_SYSTEM_PREFIX
+            if skills_text:
+                system_prompt += f"\n\n## Skills\n{skills_text}"
+
+        return system_prompt
+
+    async def _generate_task_name(self, messages: List[Dict]) -> str:
+        """生成任务名称:优先使用 utility_llm,fallback 到文本截取"""
+        # 提取 messages 中的文本内容
+        text_parts = []
+        for msg in messages:
+            content = msg.get("content", "")
+            if isinstance(content, str):
+                text_parts.append(content)
+            elif isinstance(content, list):
+                for part in content:
+                    if isinstance(part, dict) and part.get("type") == "text":
+                        text_parts.append(part.get("text", ""))
+        raw_text = " ".join(text_parts).strip()
+
+        if not raw_text:
+            return TASK_NAME_FALLBACK
+
+        # 尝试使用 utility_llm 生成标题
+        if self.utility_llm_call:
+            try:
+                result = await self.utility_llm_call(
+                    messages=[
+                        {"role": "system", "content": TASK_NAME_GENERATION_SYSTEM_PROMPT},
+                        {"role": "user", "content": raw_text[:2000]},
+                    ],
+                    model="gpt-4o-mini",  # 使用便宜模型
+                )
+                title = result.get("content", "").strip()
+                if title and len(title) < 100:
+                    return title
+            except Exception:
+                pass
+
+        # Fallback: 截取前 50 字符
+        return raw_text[:50] + ("..." if len(raw_text) > 50 else "")
+
+    def _format_skills(self, skills: List[Skill]) -> str:
+        if not skills:
+            return ""
+        return "\n\n".join(s.to_prompt_text() for s in skills)

+ 1400 - 0
agent/docs/architecture.md

@@ -0,0 +1,1400 @@
+# Agent Core 架构设计
+
+本文档描述 Agent Core 模块的完整架构设计。
+
+## 文档维护规范
+
+0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
+1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议,或大量代码;决策依据或修改日志若有必要,可在 `decisions.md` 另行记录
+
+---
+
+## 系统概览
+
+**核心理念:所有 Agent 都是 Trace**
+
+| 类型 | 创建方式 | 父子关系 | 状态 |
+|------|---------|---------|------|
+| 主 Agent | 直接调用 `runner.run()` | 无 parent | 正常执行 |
+| 子 Agent | 通过 `agent` 工具 | `parent_trace_id` / `parent_goal_id` 指向父 | 正常执行 |
+| 人类协助 | 通过 `ask_human` 工具 | `parent_trace_id` 指向父 | 阻塞等待 |
+
+---
+
+## 核心架构
+
+### 模块结构
+
+```
+agent/
+├── core/                  # 核心引擎
+│   ├── runner.py          # AgentRunner + 运行时配置
+│   └── presets.py         # Agent 预设(explore、analyst 等)
+│
+├── trace/                 # 执行追踪(含计划管理)
+│   ├── models.py          # Trace, Message
+│   ├── goal_models.py     # Goal, GoalTree, GoalStats
+│   ├── protocols.py       # TraceStore 接口
+│   ├── store.py           # FileSystemTraceStore 实现
+│   ├── goal_tool.py       # goal 工具(计划管理)
+│   ├── compaction.py      # Context 压缩
+│   ├── api.py             # REST API
+│   ├── websocket.py       # WebSocket API
+│   └── trace_id.py        # Trace ID 生成工具
+│
+├── tools/                 # 外部交互工具
+│   ├── registry.py        # 工具注册表
+│   ├── schema.py          # Schema 生成器
+│   ├── models.py          # ToolResult, ToolContext
+│   └── builtin/
+│       ├── file/          # 文件操作(read, write, edit, glob, grep)
+│       ├── browser/       # 浏览器自动化
+│       ├── bash.py        # 命令执行
+│       ├── sandbox.py     # 沙箱环境
+│       ├── search.py      # 网络搜索
+│       ├── webfetch.py    # 网页抓取
+│       ├── skill.py       # 技能加载
+│       └── subagent.py    # agent / evaluate 工具(子 Agent 创建与评估)
+│
+├── memory/                # 跨会话记忆
+│   ├── models.py          # Experience, Skill
+│   ├── protocols.py       # MemoryStore 接口
+│   ├── stores.py          # 存储实现
+│   ├── skill_loader.py    # Skill 加载器
+│   └── skills/            # 内置 Skills(自动注入 system prompt)
+│       ├── planning.md    # 计划与 Goal 工具使用
+│       ├── research.md    # 搜索与内容研究
+│       └── browser.md     # 浏览器自动化
+│
+├── llm/                   # LLM 集成
+│   ├── gemini.py          # Gemini Provider
+│   ├── openrouter.py      # OpenRouter Provider(OpenAI 兼容格式)
+│   ├── yescode.py         # Yescode Provider(Anthropic 原生 Messages API)
+│   └── prompts/           # Prompt 工具
+```
+
+### 职责划分
+
+| 模块 | 职责 |
+|-----|------|
+| **core/** | Agent 执行引擎 + 预设配置 |
+| **trace/** | 执行追踪 + 计划管理 |
+| **tools/** | 与外部世界交互(文件、命令、网络、浏览器) |
+| **memory/** | 跨会话知识(Skills、Experiences) |
+| **llm/** | LLM Provider 适配 |
+
+### 三层记忆模型
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 3: Skills(技能库)                                     │
+│ - Markdown 文件,存储领域知识和能力描述                        │
+│ - 通过 skill 工具按需加载到对话历史                            │
+└─────────────────────────────────────────────────────────────┘
+                              ▲
+                              │ 归纳
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 2: Experience(经验库)                                 │
+│ - 数据库存储,条件 + 规则 + 证据                              │
+│ - 向量检索,注入到 system prompt                              │
+└─────────────────────────────────────────────────────────────┘
+                              ▲
+                              │ 提取
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 1: Trace(任务状态)                                    │
+│ - 当前任务的工作记忆                                          │
+│ - Trace + Messages 记录执行过程                               │
+│ - Goals 管理执行计划                                          │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### LLM Provider 适配
+
+#### 内部格式
+
+框架内部统一使用 OpenAI 兼容格式(`List[Dict]`)存储和传递消息。各 Provider 负责双向转换:
+
+| 方向 | 说明 |
+|------|------|
+| 入(LLM 响应 → 框架) | 提取 content、tool_calls、usage,转换为统一 Dict |
+| 出(框架 → LLM 请求) | OpenAI 格式消息列表 → 各 API 原生格式 |
+
+#### 工具消息分组
+
+存储层每个 tool result 独立一条 Message(OpenAI 格式最大公约数)。各 Provider 在出方向按 API 要求自行分组:
+
+| Provider | 分组方式 |
+|----------|---------|
+| OpenRouter | 无需分组(OpenAI 原生支持独立 tool 消息) |
+| Yescode | `_convert_messages_to_anthropic` 合并连续 tool 消息为单个 user message |
+| Gemini | `_convert_messages_to_gemini` 通过 buffer 合并连续 tool 消息 |
+
+#### 跨 Provider 续跑:tool_call_id 规范化
+
+不同 Provider 生成的 tool_call_id 格式不同(OpenAI: `call_xxx`,Anthropic: `toolu_xxx`,Gemini: 合成 `call_0`)。存储层按原样保存,不做规范化。
+
+跨 Provider 续跑时,出方向转换前检测历史中的 tool_call_id 格式,不兼容时统一重写为目标格式(保持 tool_use / tool_result 配对一致)。同格式跳过,零开销。Gemini 按 function name 匹配,无需重写。
+
+**实现**:`agent/llm/openrouter.py:_normalize_tool_call_ids`, `agent/llm/yescode.py:_normalize_tool_call_ids`
+
+---
+
+## 核心流程:Agent Loop
+
+### 参数分层
+
+```
+Layer 1: Infrastructure(基础设施,AgentRunner 构造时设置)
+  trace_store, memory_store, tool_registry, llm_call, skills_dir, utility_llm_call
+
+Layer 2: RunConfig(运行参数,每次 run 时指定)
+  ├─ 模型层:model, temperature, max_iterations, tools
+  └─ 框架层:trace_id, agent_type, uid, system_prompt, parent_trace_id, ...
+
+Layer 3: Messages(任务消息,OpenAI SDK 格式 List[Dict])
+  [{"role": "user", "content": "分析这张图的构图"}]
+```
+
+### RunConfig
+
+```python
+@dataclass
+class RunConfig:
+    # 模型层参数
+    model: str = "gpt-4o"
+    temperature: float = 0.3
+    max_iterations: int = 200
+    tools: Optional[List[str]] = None          # None = 全部已注册工具
+
+    # 框架层参数
+    agent_type: str = "default"
+    uid: Optional[str] = None
+    system_prompt: Optional[str] = None        # None = 从 skills 自动构建
+    skills: Optional[List[str]] = None         # 注入 system prompt 的 skill 名称列表;None = 按 preset 决定
+    enable_memory: bool = True
+    auto_execute_tools: bool = True
+    name: Optional[str] = None                 # 显示名称(空则由 utility_llm 自动生成)
+
+    # Trace 控制
+    trace_id: Optional[str] = None             # None = 新建
+    parent_trace_id: Optional[str] = None      # 子 Agent 专用
+    parent_goal_id: Optional[str] = None
+
+    # 续跑控制
+    after_sequence: Optional[int] = None       # 从哪条消息后续跑(message sequence)
+```
+
+**实现**:`agent/core/runner.py:RunConfig`
+
+### 三种运行模式
+
+通过 RunConfig 参数自然区分,统一入口 `run(messages, config)`:
+
+| 模式 | trace_id | after_sequence | messages 含义 | API 端点 |
+|------|----------|---------------|--------------|----------|
+| 新建 | None | - | 初始任务消息 | `POST /api/traces` |
+| 续跑 | 已有 ID | None 或 == head | 追加到末尾的新消息 | `POST /api/traces/{id}/run` |
+| 回溯 | 已有 ID | 主路径上 < head | 在插入点之后追加的新消息 | `POST /api/traces/{id}/run` |
+
+Runner 根据 `after_sequence` 与当前 `head_sequence` 的关系自动判断行为,前端无需指定模式。
+
+### 执行流程
+
+```python
+async def run(messages: List[Dict], config: RunConfig = None) -> AsyncIterator[Union[Trace, Message]]:
+    # Phase 1: PREPARE TRACE
+    #   无 trace_id → 创建新 Trace(生成 name,初始化 GoalTree)
+    #   有 trace_id + after_sequence 为 None 或 == head → 加载已有 Trace,状态置为 running
+    #   有 trace_id + after_sequence < head → 加载 Trace,执行 rewind(快照 GoalTree,重建,设 parent_sequence)
+    trace = await _prepare_trace(config)
+    yield trace
+
+    # Phase 2: BUILD HISTORY
+    #   从 head_sequence 沿 parent chain 回溯构建主路径消息
+    #   构建 system prompt(新建时注入 skills/experiences;续跑时复用已有)
+    #   追加 input messages(设置 parent_sequence 指向当前 head)
+    history, sequence = await _build_history(trace, messages, config)
+
+    # Phase 3: AGENT LOOP
+    for iteration in range(config.max_iterations):
+        # 周期性注入 GoalTree + Active Collaborators(每 10 轮)
+        if iteration % 10 == 0:
+            inject_context(goal_tree, collaborators)
+
+        response = await llm_call(messages=history, model=config.model, tools=tool_schemas)
+
+        # 按需自动创建 root goal(兜底)
+        # 记录 assistant Message
+        # 执行工具,记录 tool Messages
+        # 无 tool_calls 则 break
+
+    # Phase 4: COMPLETE
+    #   更新 Trace 状态 (completed/failed)
+    trace.status = "completed"
+    yield trace
+```
+
+**实现**:`agent/core/runner.py:AgentRunner`
+
+### 回溯(Rewind)
+
+回溯通过 `RunConfig(trace_id=..., after_sequence=N)` 触发(N 在主路径上且 < head_sequence),在 Phase 1 中执行:
+
+1. **验证插入点**:确保不截断在 assistant(tool_calls) 和 tool response 之间
+2. **快照 GoalTree**:将当前完整 GoalTree 存入 `events.jsonl`(rewind 事件的 `goal_tree_snapshot` 字段)
+3. **按时间重建 GoalTree**:以截断点消息的 `created_at` 为界,保留 `created_at <= cutoff_time` 的所有 goals(无论状态),丢弃 cutoff 之后创建的 goals,清空 `current_id`。将被保留的 `in_progress` goal 重置为 `pending`
+4. **设置 parent_sequence**:新消息的 `parent_sequence` 指向 rewind 点,旧消息自动脱离主路径
+5. **更新 Trace**:`head_sequence` 更新为新消息的 sequence,status 改回 running
+
+新消息的 sequence 从 `last_sequence + 1` 开始(全局递增,不复用)。旧消息无需标记 abandoned,通过消息树结构自然隔离。
+
+### 调用接口
+
+三种模式共享同一入口 `run(messages, config)`:
+
+```python
+# 新建
+async for item in runner.run(
+    messages=[{"role": "user", "content": "分析项目架构"}],
+    config=RunConfig(model="gpt-4o"),
+):
+    ...
+
+# 续跑:在已有 trace 末尾追加消息继续执行
+async for item in runner.run(
+    messages=[{"role": "user", "content": "继续"}],
+    config=RunConfig(trace_id="existing-trace-id"),
+):
+    ...
+
+# 回溯:从指定 sequence 处切断,插入新消息重新执行
+# after_sequence=5 表示新消息的 parent_sequence=5,从此处开始
+async for item in runner.run(
+    messages=[{"role": "user", "content": "换一个方案试试"}],
+    config=RunConfig(trace_id="existing-trace-id", after_sequence=5),
+):
+    ...
+
+# 重新生成:回溯后不插入新消息,直接基于已有消息重跑
+async for item in runner.run(
+    messages=[],
+    config=RunConfig(trace_id="existing-trace-id", after_sequence=5),
+):
+    ...
+```
+
+`after_sequence` 的值是 message 的 `sequence` 号,可通过 `GET /api/traces/{trace_id}/messages` 查看。如果指定的 sequence 是一条带 `tool_calls` 的 assistant 消息,系统会自动将截断点扩展到其所有对应的 tool response 之后(安全截断)。
+
+**停止运行**:
+
+```python
+# 停止正在运行的 Trace
+await runner.stop(trace_id)
+```
+
+调用后 agent loop 在下一个检查点退出,Trace 状态置为 `stopped`,同时保存当前 `head_sequence`(确保续跑时能正确加载完整历史)。
+
+**消息完整性保护(orphaned tool_call 修复)**:续跑加载历史时,`_build_history` 自动检测并修复 orphaned tool_calls(`_heal_orphaned_tool_calls`)。当 agent 被 stop/crash 中断时,可能存在 assistant 的 tool_calls 没有对应的 tool results(包括部分完成的情况:3 个 tool_call 只有 1 个 tool_result)。直接发给 LLM 会导致 400 错误。
+
+修复策略:为每个缺失的 tool_result **插入合成的中断通知**(而非裁剪 assistant 消息):
+
+| 工具类型 | 合成 tool_result 内容 |
+|----------|---------------------|
+| 普通工具 | 简短中断提示,建议重新调用 |
+| agent/evaluate | 结构化中断信息,包含 `sub_trace_id`、执行统计、`continue_from` 用法指引 |
+
+agent 工具的合成结果对齐正常返回值格式(含 `sub_trace_id` 字段),主 Agent 可直接使用 `agent(task=..., continue_from=sub_trace_id)` 续跑被中断的子 Agent。合成消息持久化存储,确保幂等。
+
+**实现**:`agent/core/runner.py:AgentRunner._heal_orphaned_tool_calls`
+
+- `run(messages, config)`:**核心方法**,流式返回 `AsyncIterator[Union[Trace, Message]]`
+- `run_result(messages, config, on_event=None)`:便利方法,内部消费 `run()`,返回结构化结果。`on_event` 回调可实时接收每个 Trace/Message 事件(用于调试时输出子 Agent 执行过程)。主要用于 `agent`/`evaluate` 工具内部
+
+### REST API
+
+#### 查询端点
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET  | `/api/traces` | 列出 Traces |
+| GET  | `/api/traces/{id}` | 获取 Trace 详情(含 GoalTree、Sub-Traces) |
+| GET  | `/api/traces/{id}/messages` | 获取 Messages(支持 mode=main_path/all) |
+| GET  | `/api/traces/running` | 列出正在运行的 Trace |
+| WS   | `/api/traces/{id}/watch` | 实时事件推送 |
+
+**实现**:`agent/trace/api.py`, `agent/trace/websocket.py`
+
+#### 控制端点
+
+需在 `api_server.py` 中配置 Runner。执行在后台异步进行,通过 WebSocket 监听进度。
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | `/api/traces` | 新建 Trace 并执行 |
+| POST | `/api/traces/{id}/run` | 运行(统一续跑 + 回溯) |
+| POST | `/api/traces/{id}/stop` | 停止运行中的 Trace |
+| POST | `/api/traces/{id}/reflect` | 触发反思,从执行历史中提取经验 |
+
+```bash
+# 新建
+curl -X POST http://localhost:8000/api/traces \
+  -H "Content-Type: application/json" \
+  -d '{"messages": [{"role": "user", "content": "分析项目架构"}], "model": "gpt-4o"}'
+
+# 续跑(after_sequence 为 null 或省略)
+curl -X POST http://localhost:8000/api/traces/{trace_id}/run \
+  -d '{"messages": [{"role": "user", "content": "继续深入分析"}]}'
+
+# 回溯:从 sequence 5 处截断,插入新消息重新执行
+curl -X POST http://localhost:8000/api/traces/{trace_id}/run \
+  -d '{"after_sequence": 5, "messages": [{"role": "user", "content": "换一个方案"}]}'
+
+# 重新生成:回溯到 sequence 5,不插入新消息,直接重跑
+curl -X POST http://localhost:8000/api/traces/{trace_id}/run \
+  -d '{"after_sequence": 5, "messages": []}'
+
+# 停止
+curl -X POST http://localhost:8000/api/traces/{trace_id}/stop
+
+# 反思:追加反思 prompt 运行,结果追加到 experiences 文件
+curl -X POST http://localhost:8000/api/traces/{trace_id}/reflect \
+  -d '{"focus": "为什么第三步选择了错误的方案"}'
+```
+
+响应立即返回 `{"trace_id": "...", "status": "started"}`,通过 `WS /api/traces/{trace_id}/watch` 监听实时事件。
+
+**实现**:`agent/trace/run_api.py`
+
+#### 经验端点
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET  | `/api/experiences` | 读取经验文件内容 |
+
+**实现**:`agent/trace/run_api.py`
+
+---
+
+## 数据模型
+
+### Trace(任务执行)
+
+一次完整的 Agent 执行。所有 Agent(主、子、人类协助)都是 Trace。
+
+```python
+@dataclass
+class Trace:
+    trace_id: str
+    mode: Literal["call", "agent"]           # 单次调用 or Agent 模式
+
+    # Prompt 标识
+    prompt_name: Optional[str] = None
+
+    # Agent 模式特有
+    task: Optional[str] = None
+    agent_type: Optional[str] = None
+
+    # 父子关系(Sub-Trace 特有)
+    parent_trace_id: Optional[str] = None    # 父 Trace ID
+    parent_goal_id: Optional[str] = None     # 哪个 Goal 启动的
+
+    # 状态
+    status: Literal["running", "completed", "failed", "stopped"] = "running"
+
+    # 统计
+    total_messages: int = 0
+    total_tokens: int = 0                    # 总 tokens(prompt + completion)
+    total_prompt_tokens: int = 0
+    total_completion_tokens: int = 0
+    total_cost: float = 0.0
+    total_duration_ms: int = 0
+
+    # 进度追踪
+    last_sequence: int = 0                   # 最新 message 的 sequence(全局递增,不复用)
+    head_sequence: int = 0                   # 当前主路径的头节点 sequence(用于 build_llm_messages)
+    last_event_id: int = 0                   # 最新事件 ID(用于 WS 续传)
+
+    # 配置
+    uid: Optional[str] = None
+    model: Optional[str] = None              # 默认模型
+    tools: Optional[List[Dict]] = None       # 工具定义(OpenAI 格式)
+    llm_params: Dict[str, Any] = {}          # LLM 参数(temperature 等)
+    context: Dict[str, Any] = {}             # 元数据(含 collaborators 列表)
+
+    # 当前焦点
+    current_goal_id: Optional[str] = None
+
+    # 结果
+    result_summary: Optional[str] = None
+    error_message: Optional[str] = None
+
+    # 时间
+    created_at: datetime
+    completed_at: Optional[datetime] = None
+```
+
+**实现**:`agent/trace/models.py`
+
+### Goal(目标节点)
+
+计划中的一个目标,支持层级结构。单独存储于 `goal.json`。
+
+```python
+@dataclass
+class Goal:
+    id: str                                  # 内部 ID("1", "2"...)
+    description: str
+    reason: str = ""                         # 创建理由
+    parent_id: Optional[str] = None          # 父 Goal ID
+    type: GoalType = "normal"                # normal | agent_call
+    status: GoalStatus = "pending"           # pending | in_progress | completed | abandoned
+    summary: Optional[str] = None            # 完成/放弃时的总结
+
+    # agent_call 特有(启动 Sub-Trace)
+    sub_trace_ids: Optional[List[str]] = None
+    agent_call_mode: Optional[str] = None    # explore | delegate | evaluate
+    sub_trace_metadata: Optional[Dict] = None
+
+    # 统计
+    self_stats: GoalStats                    # 自身 Messages 统计
+    cumulative_stats: GoalStats              # 包含子孙的累计统计
+
+    created_at: datetime
+```
+
+**Goal 类型**:
+- `normal` - 普通目标,由 Agent 直接执行
+- `agent_call` - 通过 `agent`/`evaluate` 工具创建的目标,会启动 Sub-Trace
+
+**agent_call 类型的 Goal**:
+- 调用 `agent`/`evaluate` 工具时自动设置
+- `agent_call_mode` 记录使用的模式(explore/delegate/evaluate)
+- `sub_trace_ids` 记录创建的所有 Sub-Trace ID
+- 状态转换:pending → in_progress(Sub-Trace 启动)→ completed(Sub-Trace 完成)
+- `summary` 包含格式化的汇总结果(explore 模式会汇总所有分支)
+
+**Goal 操作**(通过 goal 工具):
+- `add` - 添加顶层目标
+- `under` - 在指定目标下添加子目标
+- `after` - 在指定目标后添加兄弟目标
+- `focus` - 切换焦点到指定目标
+- `done` - 完成当前目标(附带 summary)
+- `abandon` - 放弃当前目标(附带原因)
+
+**实现**:`agent/trace/goal_models.py`, `agent/trace/goal_tool.py`
+
+### Message(执行消息)
+
+对应 LLM API 的消息,每条 Message 关联一个 Goal。消息通过 `parent_sequence` 形成树结构。
+
+```python
+@dataclass
+class Message:
+    message_id: str                          # 格式:{trace_id}-{sequence:04d}
+    trace_id: str
+    role: Literal["system", "user", "assistant", "tool"]
+    sequence: int                            # 全局顺序(递增,不复用)
+    parent_sequence: Optional[int] = None    # 父消息的 sequence(构成消息树)
+    goal_id: Optional[str] = None            # 关联的 Goal ID(初始消息为 None,系统会按需自动创建 root goal 兜底)
+    description: str = ""                    # 系统自动生成的摘要
+    tool_call_id: Optional[str] = None
+    content: Any = None
+
+    # 统计
+    prompt_tokens: Optional[int] = None
+    completion_tokens: Optional[int] = None
+    cost: Optional[float] = None
+    duration_ms: Optional[int] = None
+
+    # LLM 响应信息(仅 role="assistant")
+    finish_reason: Optional[str] = None
+
+    created_at: datetime
+
+    # [已弃用] 由 parent_sequence 树结构替代
+    status: Literal["active", "abandoned"] = "active"
+    abandoned_at: Optional[datetime] = None
+```
+
+**消息树(Message Tree)**:
+
+消息通过 `parent_sequence` 形成树。主路径 = 从 `trace.head_sequence` 沿 parent chain 回溯到 root。
+
+```
+正常对话:1 → 2 → 3 → 4 → 5       (每条的 parent 指向前一条)
+Rewind 到 3:3 → 6(parent=3) → 7   (新主路径,4-5 自动脱离)
+压缩 1-3:   8(summary, parent=None) → 6 → 7  (summary 跳过被压缩的消息)
+反思分支:   5 → 9(reflect, parent=5) → 10     (侧枝,不在主路径上)
+```
+
+`build_llm_messages` = 从 head 沿 parent_sequence 链回溯到 root,反转后返回。
+
+Message 提供格式转换方法:
+- `to_llm_dict()` → OpenAI 格式 Dict(用于 LLM 调用)
+- `from_llm_dict(d, trace_id, sequence, goal_id)` → 从 OpenAI 格式创建 Message
+
+**实现**:`agent/trace/models.py`
+
+---
+
+## Agent 预设
+
+不同类型 Agent 的配置模板,控制工具权限和参数。
+
+```python
+@dataclass
+class AgentPreset:
+    allowed_tools: Optional[List[str]] = None  # None 表示允许全部
+    denied_tools: Optional[List[str]] = None   # 黑名单
+    max_iterations: int = 30
+    temperature: Optional[float] = None
+    skills: Optional[List[str]] = None         # 注入 system prompt 的 skill 名称列表;None = 加载全部
+    description: Optional[str] = None
+
+
+_DEFAULT_SKILLS = ["planning", "research", "browser"]
+
+AGENT_PRESETS = {
+    "default": AgentPreset(
+        allowed_tools=None,
+        max_iterations=30,
+        skills=_DEFAULT_SKILLS,
+        description="默认 Agent,拥有全部工具权限",
+    ),
+    "explore": AgentPreset(
+        allowed_tools=["read", "glob", "grep", "list_files"],
+        denied_tools=["write", "edit", "bash", "task"],
+        max_iterations=15,
+        skills=["planning"],
+        description="探索型 Agent,只读权限,用于代码分析",
+    ),
+    "analyst": AgentPreset(
+        allowed_tools=["read", "glob", "grep", "web_search", "webfetch"],
+        denied_tools=["write", "edit", "bash", "task"],
+        temperature=0.3,
+        max_iterations=25,
+        skills=["planning", "research"],
+        description="分析型 Agent,用于深度分析和研究",
+    ),
+}
+```
+
+**实现**:`agent/core/presets.py`
+
+**用户自定义**:项目级配置文件(如 `examples/how/presets.json`)可通过 `register_preset()` 注册额外预设。项目专用的 Agent 类型建议放在项目目录下,而非内置预设。
+
+---
+
+## 子 Trace 机制
+
+通过 `agent` 工具创建子 Agent 执行任务。`task` 参数为字符串时为单任务(delegate),为列表时并行执行多任务(explore)。支持通过 `messages` 参数预置消息,通过 `continue_from` 参数续跑已有 Sub-Trace。
+
+`agent` 工具负责创建 Sub-Trace 和初始化 GoalTree(因为需要设置自定义 context 元数据和命名规则),创建完成后将 `trace_id` 传给 `RunConfig`,由 Runner 接管后续执行。工具同时维护父 Trace 的 `context["collaborators"]` 列表。
+
+### 跨设备 Agent 通信
+
+支持跨设备的 Agent 间持续对话,通过远程 Trace ID 实现:
+
+**Trace ID 格式**:
+- 本地 Trace:`abc-123`
+- 远程 Trace:`agent://terminal-agent-456/abc-123`(协议 + Agent 地址 + 本地 ID)
+
+**使用方式**:
+```python
+# 调用远程 Agent
+result = agent(task="分析本地项目", agent_url="https://terminal-agent.local")
+# 返回: {"sub_trace_id": "agent://terminal-agent.local/abc-123"}
+
+# 续跑远程 Trace(持续对话)
+result2 = agent(
+    task="重点分析core模块",
+    continue_from="agent://terminal-agent.local/abc-123",
+    agent_url="https://terminal-agent.local"
+)
+```
+
+**实现**:`HybridTraceStore` 自动路由到本地或远程存储,远程访问通过 HTTP API 实现。
+
+**实现位置**:`agent/trace/hybrid_store.py`(规划中)
+
+### agent 工具
+
+```python
+@tool(description="创建 Agent 执行任务")
+async def agent(
+    task: Union[str, List[str]],
+    messages: Optional[Union[Messages, List[Messages]]] = None,
+    continue_from: Optional[str] = None,
+    agent_type: Optional[str] = None,
+    skills: Optional[List[str]] = None,
+    agent_url: Optional[str] = None,  # 远程 Agent 地址(跨设备)
+    context: Optional[dict] = None,
+) -> Dict[str, Any]:
+```
+
+**参数**:
+- `agent_type`: 子 Agent 类型,决定工具权限和默认 skills(对应 `AgentPreset` 名称)
+- `skills`: 覆盖 preset 默认值,显式指定注入 system prompt 的 skill 列表
+- `agent_url`: 远程 Agent 地址,用于跨设备调用(返回远程 Trace ID)
+- `continue_from`: 支持本地或远程 Trace ID
+
+**单任务(delegate)**:`task: str`
+- 创建单个 Sub-Trace
+- 完整工具权限(除 agent/evaluate 外,防止递归)
+- 支持 `continue_from` 续跑已有 Sub-Trace(本地或远程)
+- 支持 `messages` 预置上下文消息
+
+**多任务(explore)**:`task: List[str]`
+- 使用 `asyncio.gather()` 并行执行所有任务
+- 每个任务创建独立的 Sub-Trace
+- 只读工具权限(read_file, grep_content, glob_files, goal)
+- `messages` 支持 1D(共享)或 2D(per-agent)
+- 不支持 `continue_from`
+- 汇总所有分支结果返回
+
+### evaluate 工具
+
+```python
+@tool(description="评估目标执行结果是否满足要求")
+async def evaluate(
+    messages: Optional[Messages] = None,
+    target_goal_id: Optional[str] = None,
+    continue_from: Optional[str] = None,
+    context: Optional[dict] = None,
+) -> Dict[str, Any]:
+```
+
+- 代码自动从 GoalTree 注入目标描述(无需 criteria 参数)
+- 模型把执行结果和上下文放在 `messages` 中
+- `target_goal_id` 默认为当前 goal_id
+- 只读工具权限
+- 返回评估结论和改进建议
+
+### 消息类型别名
+
+定义在 `agent/trace/models.py`,用于工具参数和 runner/LLM API 接口:
+
+```python
+ChatMessage = Dict[str, Any]                          # 单条 OpenAI 格式消息
+Messages = List[ChatMessage]                          # 消息列表
+MessageContent = Union[str, List[Dict[str, str]]]     # content 字段(文本或多模态)
+```
+
+**实现位置**:`agent/tools/builtin/subagent.py`
+
+**详细文档**:[工具系统 - Agent/Evaluate 工具](../agent/docs/tools.md#agent-工具)
+
+### ask_human 工具
+
+创建阻塞式 Trace,等待人类通过 IM/邮件等渠道回复。
+
+**注意**:此功能规划中,暂未实现。
+
+---
+
+## Active Collaborators(活跃协作者)
+
+任务执行中与模型密切协作的实体(子 Agent 或人类),按 **与当前任务的关系** 分类,而非按 human/agent 分类:
+
+| | 持久存在(外部可查) | 任务内活跃(需要注入) |
+|---|---|---|
+| Agent | 专用 Agent(代码审查等) | 当前任务创建的子 Agent |
+| Human | 飞书通讯录 | 当前任务中正在对接的人 |
+
+### 数据模型
+
+活跃协作者存储在 `trace.context["collaborators"]`:
+
+```python
+{
+    "name": "researcher",            # 名称(模型可见)
+    "type": "agent",                 # agent | human
+    "trace_id": "abc-@delegate-001", # trace_id(agent 场景)
+    "status": "completed",           # running | waiting | completed | failed
+    "summary": "方案A最优",          # 最近状态摘要
+}
+```
+
+### 注入方式
+
+与 GoalTree 一同周期性注入(每 10 轮),渲染为 Markdown:
+
+```markdown
+## Active Collaborators
+- researcher [agent, completed]: 方案A最优
+- 谭景玉 [human, waiting]: 已发送方案确认,等待回复
+- coder [agent, running]: 正在实现特征提取模块
+```
+
+列表为空时不注入。
+
+### 维护
+
+各工具负责更新 collaborators 列表(通过 `context["store"]` 写入 trace.context):
+- `agent` 工具:创建/续跑子 Agent 时更新
+- `feishu` 工具:发送消息/收到回复时更新
+- Runner 只负责读取和注入
+
+**持久联系人/Agent**:通过工具按需查询(如 `feishu_get_contact_list`),不随任务注入。
+
+**实现**:`agent/core/runner.py:AgentRunner._build_context_injection`, `agent/tools/builtin/subagent.py`
+
+---
+
+## Context Injection Hooks(上下文注入钩子)
+
+### 概述
+
+Context Injection Hooks 是一个可扩展机制,允许外部模块(如 A2A IM、监控系统)向 Agent 的周期性上下文注入中添加自定义内容。
+
+### 设计理念
+
+- **周期性注入**:每 10 轮自动注入,不打断执行
+- **可扩展**:通过 hook 函数注册,无需修改 Runner 核心代码
+- **轻量提醒**:只注入摘要/提醒,详细内容通过工具获取
+- **LLM 自主决策**:由 LLM 决定何时响应提醒
+
+### 架构
+
+```
+Runner Loop (每 10 轮)
+    ↓
+_build_context_injection()
+    ├─ GoalTree (内置)
+    ├─ Active Collaborators (内置)
+    └─ Context Hooks (可扩展)
+         ├─ A2A IM Hook → "💬 3 条新消息"
+         ├─ Monitor Hook → "⚠️ 内存使用 85%"
+         └─ Custom Hook → 自定义内容
+    ↓
+注入为 system message
+    ↓
+LLM 看到提醒 → 决定是否调用工具
+```
+
+### Hook 接口
+
+```python
+# Hook 函数签名
+def context_hook(trace: Trace, goal_tree: Optional[GoalTree]) -> Optional[str]:
+    """
+    生成要注入的上下文内容
+
+    Args:
+        trace: 当前 Trace
+        goal_tree: 当前 GoalTree
+
+    Returns:
+        要注入的 Markdown 内容,None 表示无内容
+    """
+    return "## Custom Section\n\n内容..."
+```
+
+### 注册 Hook
+
+```python
+# 创建 Runner 时注册
+runner = AgentRunner(
+    llm_call=llm_call,
+    trace_store=trace_store,
+    context_hooks=[hook1, hook2, hook3]  # 按顺序注入
+)
+```
+
+### 实现
+
+**Runner 修改**:
+
+```python
+# agent/core/runner.py
+
+class AgentRunner:
+    def __init__(
+        self,
+        # ... 现有参数
+        context_hooks: Optional[List[Callable]] = None
+    ):
+        self.context_hooks = context_hooks or []
+
+    def _build_context_injection(
+        self,
+        trace: Trace,
+        goal_tree: Optional[GoalTree],
+    ) -> str:
+        """构建周期性注入的上下文(GoalTree + Active Collaborators + Hooks)"""
+        parts = []
+
+        # GoalTree(现有)
+        if goal_tree and goal_tree.goals:
+            parts.append(f"## Current Plan\n\n{goal_tree.to_prompt()}")
+            # ... focus 提醒
+
+        # Active Collaborators(现有)
+        collaborators = trace.context.get("collaborators", [])
+        if collaborators:
+            lines = ["## Active Collaborators"]
+            for c in collaborators:
+                # ... 现有逻辑
+            parts.append("\n".join(lines))
+
+        # Context Hooks(新增)
+        for hook in self.context_hooks:
+            try:
+                hook_content = hook(trace, goal_tree)
+                if hook_content:
+                    parts.append(hook_content)
+            except Exception as e:
+                logger.error(f"Context hook error: {e}")
+
+        return "\n\n".join(parts)
+```
+
+**实现位置**:`agent/core/runner.py:AgentRunner._build_context_injection`(待实现)
+
+### 示例:A2A IM Hook
+
+```python
+# agent/tools/builtin/a2a_im.py
+
+class A2AMessageQueue:
+    """A2A IM 消息队列"""
+
+    def __init__(self):
+        self._messages: List[Dict] = []
+
+    def push(self, message: Dict):
+        """Gateway 推送消息时调用"""
+        self._messages.append(message)
+
+    def pop_all(self) -> List[Dict]:
+        """check_messages 工具调用时清空"""
+        messages = self._messages
+        self._messages = []
+        return messages
+
+    def get_summary(self) -> Optional[str]:
+        """获取消息摘要(用于 context injection)"""
+        if not self._messages:
+            return None
+
+        count = len(self._messages)
+        latest = self._messages[-1]
+        from_agent = latest.get("from_agent_id", "unknown")
+
+        if count == 1:
+            return f"💬 来自 {from_agent} 的 1 条新消息(使用 check_messages 工具查看)"
+        else:
+            return f"💬 {count} 条新消息,最新来自 {from_agent}(使用 check_messages 工具查看)"
+
+
+def create_a2a_context_hook(message_queue: A2AMessageQueue):
+    """创建 A2A IM 的 context hook"""
+
+    def a2a_context_hook(trace: Trace, goal_tree: Optional[GoalTree]) -> Optional[str]:
+        """注入 A2A IM 消息提醒"""
+        summary = message_queue.get_summary()
+        if not summary:
+            return None
+
+        return f"## Messages\n\n{summary}"
+
+    return a2a_context_hook
+
+
+@tool(description="检查来自其他 Agent 的新消息")
+async def check_messages(ctx: ToolContext) -> ToolResult:
+    """检查并获取来自其他 Agent 的新消息"""
+    message_queue: A2AMessageQueue = ctx.context.get("a2a_message_queue")
+    if not message_queue:
+        return ToolResult(title="消息队列未初始化", output="")
+
+    messages = message_queue.pop_all()
+
+    if not messages:
+        return ToolResult(title="无新消息", output="")
+
+    # 格式化消息
+    lines = [f"收到 {len(messages)} 条新消息:\n"]
+    for i, msg in enumerate(messages, 1):
+        from_agent = msg.get("from_agent_id", "unknown")
+        content = msg.get("content", "")
+        conv_id = msg.get("conversation_id", "")
+        lines.append(f"{i}. 来自 {from_agent}")
+        lines.append(f"   对话 ID: {conv_id}")
+        lines.append(f"   内容: {content}")
+        lines.append("")
+
+    return ToolResult(
+        title=f"收到 {len(messages)} 条新消息",
+        output="\n".join(lines)
+    )
+```
+
+**实现位置**:`agent/tools/builtin/a2a_im.py`(待实现)
+
+### 配置示例
+
+```python
+# api_server.py
+
+from agent.tools.builtin.a2a_im import (
+    A2AMessageQueue,
+    create_a2a_context_hook,
+    check_messages
+)
+
+# 创建消息队列
+message_queue = A2AMessageQueue()
+
+# 创建 context hook
+a2a_hook = create_a2a_context_hook(message_queue)
+
+# 创建 Runner 时注入 hook
+runner = AgentRunner(
+    llm_call=llm_call,
+    trace_store=trace_store,
+    context_hooks=[a2a_hook]
+)
+
+# 注册 check_messages 工具
+tool_registry.register(check_messages)
+
+# 启动 Gateway webhook 端点
+@app.post("/webhook/a2a-messages")
+async def receive_a2a_message(message: dict):
+    """接收来自 Gateway 的消息"""
+    message_queue.push(message)
+    return {"status": "received"}
+```
+
+### 注入效果
+
+```markdown
+## Current Plan
+1. [in_progress] 分析代码架构
+   1.1. [completed] 读取项目结构
+   1.2. [in_progress] 分析核心模块
+
+## Active Collaborators
+- researcher [agent, completed]: 已完成调研
+
+## Messages
+💬 来自 code-reviewer 的 1 条新消息(使用 check_messages 工具查看)
+```
+
+### 其他应用场景
+
+**监控告警**:
+
+```python
+def create_monitor_hook(monitor):
+    def monitor_hook(trace, goal_tree):
+        alerts = monitor.get_alerts()
+        if not alerts:
+            return None
+        return f"## System Alerts\n\n⚠️ {len(alerts)} 条告警(使用 check_alerts 工具查看)"
+    return monitor_hook
+```
+
+**定时提醒**:
+
+```python
+def create_timer_hook(timer):
+    def timer_hook(trace, goal_tree):
+        if timer.should_remind():
+            return "## Reminder\n\n⏰ 任务已执行 30 分钟,建议检查进度"
+        return None
+    return timer_hook
+```
+
+**实现位置**:各模块自行实现 hook 函数
+
+---
+
+## Active Collaborators(活跃协作者)
+
+任务执行中与模型密切协作的实体(子 Agent 或人类),按 **与当前任务的关系** 分类,而非按 human/agent 分类:
+
+| | 持久存在(外部可查) | 任务内活跃(需要注入) |
+|---|---|---|
+| Agent | 专用 Agent(代码审查等) | 当前任务创建的子 Agent |
+| Human | 飞书通讯录 | 当前任务中正在对接的人 |
+
+### 数据模型
+
+活跃协作者存储在 `trace.context["collaborators"]`:
+
+```python
+{
+    "name": "researcher",            # 名称(模型可见)
+    "type": "agent",                 # agent | human
+    "trace_id": "abc-@delegate-001", # trace_id(agent 场景)
+    "status": "completed",           # running | waiting | completed | failed
+    "summary": "方案A最优",          # 最近状态摘要
+}
+```
+
+### 注入方式
+
+与 GoalTree 一同周期性注入(每 10 轮),渲染为 Markdown:
+
+```markdown
+## Active Collaborators
+- researcher [agent, completed]: 方案A最优
+- 谭景玉 [human, waiting]: 已发送方案确认,等待回复
+- coder [agent, running]: 正在实现特征提取模块
+```
+
+列表为空时不注入。
+
+### 维护
+
+各工具负责更新 collaborators 列表(通过 `context["store"]` 写入 trace.context):
+- `agent` 工具:创建/续跑子 Agent 时更新
+- `feishu` 工具:发送消息/收到回复时更新
+- Runner 只负责读取和注入
+
+**持久联系人/Agent**:通过工具按需查询(如 `feishu_get_contact_list`),不随任务注入。
+
+**实现**:`agent/core/runner.py:AgentRunner._build_context_injection`, `agent/tools/builtin/subagent.py`
+
+---
+
+## 工具系统
+
+### 核心概念
+
+```python
+@tool()
+async def my_tool(arg: str, ctx: ToolContext) -> ToolResult:
+    return ToolResult(
+        title="Success",
+        output="Result content",
+        long_term_memory="Short summary"  # 可选:压缩后保留的摘要
+    )
+```
+
+| 类型 | 作用 |
+|------|------|
+| `@tool` | 装饰器,自动注册工具并生成 Schema |
+| `ToolResult` | 工具执行结果,支持双层记忆 |
+| `ToolContext` | 工具执行上下文,依赖注入 |
+
+### 工具分类
+
+| 目录 | 工具 | 说明 |
+|-----|------|------|
+| `trace/` | goal | Agent 内部计划管理 |
+| `builtin/` | agent, evaluate | 子 Agent 创建与评估 |
+| `builtin/file/` | read, write, edit, glob, grep | 文件操作 |
+| `builtin/browser/` | browser actions | 浏览器自动化 |
+| `builtin/` | bash, sandbox, search, webfetch, skill, ask_human | 其他工具 |
+
+### 双层记忆管理
+
+大输出(如网页抓取)只传给 LLM 一次,之后用摘要替代:
+
+```python
+ToolResult(
+    output="<10K tokens 的完整内容>",
+    long_term_memory="Extracted 10000 chars from amazon.com",
+    include_output_only_once=True
+)
+```
+
+**详细文档**:[工具系统](../agent/docs/tools.md)
+
+---
+
+## Skills 系统
+
+### 分类
+
+| 类型 | 加载位置 | 加载时机 |
+|------|---------|---------|
+| **内置 Skill** | System Prompt | Agent 启动时自动注入 |
+| **项目 Skill** | System Prompt | Agent 启动时按 preset/call-site 过滤后注入 |
+| **普通 Skill** | 对话消息 | 模型调用 `skill` 工具时 |
+
+### 目录结构
+
+```
+agent/memory/skills/         # 内置 Skills(始终加载)
+├── planning.md              # 计划与 Goal 工具使用
+├── research.md              # 搜索与内容研究
+└── browser.md               # 浏览器自动化
+
+./skills/                    # 项目自定义 Skills
+```
+
+### Skills 过滤(call-site 选择)
+
+不同 Agent 类型所需的 skills 不同。过滤优先级:
+
+1. `agent()` 工具的 `skills` 参数(显式指定,最高优先级)
+2. `AgentPreset.skills`(preset 默认值)
+3. `None`(加载全部,向后兼容)
+
+示例:调用子 Agent 时只注入解构相关 skill:
+```python
+agent(task="...", agent_type="deconstruct", skills=["planning", "deconstruct"])
+```
+
+**实现**:`agent/memory/skill_loader.py`
+
+**详细文档**:[Skills 使用指南](../agent/docs/skills.md)
+
+---
+
+## Experiences 系统
+
+从执行历史中提取的经验规则,用于指导未来任务。
+
+### 存储规范
+
+经验以 Markdown 文件存储(默认 `./.cache/experiences.md`),人类可读、可编辑、可版本控制。
+
+文件格式:
+
+```markdown
+---
+id: ex_001
+trace_id: trace-xxx
+category: tool_usage
+tags: {state: ["large_file", "dirty_repo"], intent: ["batch_edit", "safe_modify"]}
+metrics: {helpful: 12, harmful: 0}
+created_at: 2026-02-12 15:30
+---
+
+---
+id: ex_002
+...
+```
+---
+
+
+### 反思机制(Reflect)
+
+通过 POST /api/traces/{id}/reflect 触发,旨在将原始执行历史提炼为可复用的知识。
+    1. 分叉反思:在 trace 末尾追加 user message(含反思与打标 Prompt),作为侧枝执行。
+    2. 结构化生成:
+        ·归类:将经验分配至 tool_usage(工具)、logic_flow(逻辑)、environment(环境)等。
+        ·打标:提取 state(环境状态)与 intent(用户意图)语义标签。
+        ·量化:初始 helpful 设为 1。
+    3. 持久化:将带有元数据的 Markdown 块追加至 experiences.md。
+
+实现:agent/trace/run_api.py:reflect_trace
+
+### 语义注入与匹配流程
+新建 Trace 时,Runner 采用“分析-检索-注入”三阶段策略,实现精准经验推荐。
+    1. 意图预分析
+    Runner 调用 utility_llm 对初始任务进行语义提取:
+        -输入:"优化这个项目的 Docker 构建速度"
+        -输出:{state: ["docker", "ci"], intent: ["optimization"]}
+    2. 语义检索
+        在 _load_experiences 中根据标签进行语义匹配(优先匹配 intent,其次是 state),筛选出相关度最高的 Top-K 条经验。
+    3. 精准注入
+        将匹配到的经验注入第一条 user message 末尾:
+```python
+# _build_history 中(仅新建模式):
+if not config.trace_id:
+    relevant_ex = self.experience_retriever.search(task_tags)
+    if relevant_ex:
+        formatted_ex = "\n".join([f"- [{e.id}] {e.content} (Helpful: {e.helpful})" for e in relevant_ex])
+        first_user_msg["content"] += f"\n\n## 参考经验\n\n{formatted_ex}"
+```
+实现:agent/core/runner.py:AgentRunner._build_history
+
+### 经验获取工具
+不再仅限于启动时自动注入,而是通过内置工具供 Agent 在需要时主动调用。当执行结果不符合预期或进入未知领域时,Agent 应优先使用此工具。
+工具定义:
+
+```python
+@tool(description="根据当前任务状态和意图,从经验库中检索相关的历史经验")
+async def get_experience(
+    intent: Optional[str] = None, 
+    state: Optional[str] = None
+) -> Dict[str, Any]:
+    """
+    参数:
+        intent: 想要达成的目标意图 (如 "optimization", "debug")
+        state: 当前环境或遇到的问题状态 (如 "docker_build_fail", "permission_denied")
+    """
+```
+实现: agent/tools/builtin/experience.py
+
+- 语义匹配与应用流程
+    当 Agent 调用 get_experience 时,系统执行以下逻辑:
+    1. 语义检索:根据传入的 intent 或 state 标签,在 experiences.md 中进行匹配。匹配权重:intent > state > helpful 评分。
+    2. 动态注入:工具返回匹配到的 Top-K 条经验(含 ID 和内容)。
+    3. 策略应用:Agent 接收到工具返回的经验后,需在后续 thought 中声明所选用的策略 ID(如 [ex_001]),并据此调整 goal_tree 或工具调用序列。
+
+## Context 压缩
+
+### 两级压缩策略
+
+#### Level 1:GoalTree 过滤(确定性,零成本)
+
+每轮 agent loop 构建 `llm_messages` 时自动执行:
+- 始终保留:system prompt、第一条 user message(含 GoalTree 精简视图)、当前 focus goal 的消息
+- 跳过 completed/abandoned goals 的消息(信息已在 GoalTree summary 中)
+- 通过 Message Tree 的 parent_sequence 实现跳过
+
+大多数情况下 Level 1 足够。
+
+#### Level 2:LLM 总结(仅在 Level 1 后仍超限时触发)
+
+触发条件:Level 1 之后 token 数仍超过阈值(默认 `max_tokens × 0.8`)。
+
+流程:
+1. **经验提取**:先在消息列表末尾追加反思 prompt → 主模型回复 → 追加到 `./.cache/experiences.md`。反思消息为侧枝(parent_sequence 分叉,不在主路径上)
+2. **压缩**:在消息列表末尾追加压缩 prompt(含 GoalTree 完整视图) → 主模型回复 → summary 存为新消息,其 `parent_sequence` 跳过被压缩的范围
+
+### GoalTree 双视图
+
+`to_prompt()` 支持两种模式:
+- `include_summary=False`(默认):精简视图,用于日常周期性注入
+- `include_summary=True`:含所有 completed goals 的 summary,用于 Level 2 压缩时提供上下文
+
+### 压缩存储
+
+- 原始消息永远保留在 `messages/`
+- 压缩 summary 作为普通 Message 存储
+- 通过 `parent_sequence` 树结构实现跳过,无需 compression events 或 skip list
+- Rewind 到压缩区域内时,summary 脱离主路径,原始消息自动恢复
+
+**实现**:`agent/trace/compaction.py`, `agent/trace/goal_models.py`
+
+**详细文档**:[Context 管理](./context-management.md)
+
+---
+
+## 存储接口
+
+```python
+class TraceStore(Protocol):
+    async def create_trace(self, trace: Trace) -> None: ...
+    async def get_trace(self, trace_id: str) -> Trace: ...
+    async def update_trace(self, trace_id: str, **updates) -> None: ...
+    async def add_message(self, message: Message) -> None: ...
+    async def get_trace_messages(self, trace_id: str) -> List[Message]: ...
+    async def get_main_path_messages(self, trace_id: str, head_sequence: int) -> List[Message]: ...
+    async def get_messages_by_goal(self, trace_id: str, goal_id: str) -> List[Message]: ...
+    async def append_event(self, trace_id: str, event_type: str, payload: Dict) -> int: ...
+```
+
+`get_main_path_messages` 从 `head_sequence` 沿 `parent_sequence` 链回溯,返回主路径上的有序消息列表。
+
+**实现**:
+- 协议定义:`agent/trace/protocols.py`
+- 本地存储:`agent/trace/store.py:FileSystemTraceStore`
+- 远程存储:`agent/trace/remote_store.py:RemoteTraceStore`(规划中)
+- 混合存储:`agent/trace/hybrid_store.py:HybridTraceStore`(规划中)
+
+### 跨设备存储
+
+**HybridTraceStore** 根据 Trace ID 自动路由到本地或远程存储:
+
+| Trace ID 格式 | 存储位置 | 访问方式 |
+|--------------|---------|---------|
+| `abc-123` | 本地文件系统 | `FileSystemTraceStore` |
+| `agent://host/abc-123` | 远程 Agent | HTTP API(`RemoteTraceStore`) |
+
+**RemoteTraceStore** 通过 HTTP API 访问远程 Trace:
+- `GET /api/traces/{trace_id}` - 获取 Trace 元数据
+- `GET /api/traces/{trace_id}/messages` - 获取消息历史
+- `POST /api/traces/{trace_id}/run` - 续跑(追加消息并执行)
+
+**认证**:通过 API Key 认证,配置在 `config/agents.yaml`。
+
+**实现位置**:`agent/trace/hybrid_store.py`, `agent/trace/remote_store.py`(规划中)
+
+### 存储结构
+
+```
+.trace/
+├── {trace_id}/
+│   ├── meta.json        # Trace 元数据(含 tools 定义)
+│   ├── goal.json        # GoalTree(mission + goals 列表)
+│   ├── events.jsonl     # 事件流(goal 变更、sub_trace 生命周期等)
+│   └── messages/        # Messages
+│       ├── {trace_id}-0001.json
+│       └── ...
+│
+└── {trace_id}@explore-{序号}-{timestamp}-001/  # 子 Trace
+    └── ...
+```
+
+**events.jsonl 说明**:
+- 记录 Trace 执行过程中的关键事件
+- 每行一个 JSON 对象,包含 event_id、event 类型、时间戳等
+- 主要事件类型:goal_added, goal_updated, sub_trace_started, sub_trace_completed, rewind
+- 用于实时监控和历史回放
+
+**Sub-Trace 目录命名**:
+- Explore: `{parent}@explore-{序号:03d}-{timestamp}-001`
+- Delegate: `{parent}@delegate-{timestamp}-001`
+- Evaluate: `{parent}@evaluate-{timestamp}-001`
+
+**meta.json 示例**:
+```json
+{
+  "trace_id": "0415dc38-...",
+  "mode": "agent",
+  "task": "分析代码结构",
+  "agent_type": "default",
+  "status": "running",
+  "model": "google/gemini-2.5-flash",
+  "tools": [...],
+  "llm_params": {"temperature": 0.3},
+  "context": {
+    "collaborators": [
+      {"name": "researcher", "type": "agent", "trace_id": "...", "status": "completed", "summary": "方案A最优"}
+    ]
+  },
+  "current_goal_id": "3"
+}
+```
+
+---
+
+## 设计决策
+
+详见 [设计决策文档](./decisions.md)
+
+**核心决策**:
+
+1. **所有 Agent 都是 Trace** - 主 Agent、子 Agent、人类协助统一为 Trace,通过 `parent_trace_id` 和 `spawn_tool` 区分
+
+2. **trace/ 模块统一管理执行状态** - 合并原 execution/ 和 goal/,包含计划管理和 Agent 内部控制工具
+
+3. **tools/ 专注外部交互** - 文件、命令、网络、浏览器等与外部世界的交互
+
+4. **Agent 预设替代 Sub-Agent 配置** - 通过 `core/presets.py` 定义不同类型 Agent 的工具权限和参数
+
+---
+
+## 相关文档
+
+| 文档 | 内容 |
+|-----|------|
+| [Context 管理](./context-management.md) | Goals、压缩、Plan 注入策略 |
+| [工具系统](../agent/docs/tools.md) | 工具定义、注册、双层记忆 |
+| [Skills 指南](../agent/docs/skills.md) | Skill 分类、编写、加载 |
+| [多模态支持](../agent/docs/multimodal.md) | 图片、PDF 处理 |
+| [知识管理](./knowledge.md) | 知识结构、检索、提取机制 |
+| [Scope 设计](./scope-design.md) | 知识可见性和权限控制 |
+| [Agent 设计决策](../agent/docs/decisions.md) | Agent Core 架构决策记录 |
+| [Gateway 设计决策](../gateway/docs/decisions.md) | Gateway 架构决策记录 |
+| [组织级概览](../gateway/docs/enterprise/overview.md) | 组织级 Agent 系统架构和规划 |
+| [Enterprise 实现](../gateway/docs/enterprise/implementation.md) | 认证、审计、多租户技术实现 |
+| [测试指南](./testing.md) | 测试策略和命令 |
+| [A2A 协议调研](./research/a2a-protocols.md) | 行业 A2A 通信协议和框架对比 |
+| [A2A 跨设备通信](./research/a2a-cross-device.md) | 跨设备 Agent 通信方案(内部) |
+| [A2A Trace 存储](./research/a2a-trace-storage.md) | 跨设备 Trace 存储方案详细设计 |
+| [MAMP 协议](./research/a2a-mamp-protocol.md) | 与外部 Agent 系统的通用交互协议 |
+| [A2A IM 系统](./a2a-im.md) | Agent 即时通讯系统架构和实现 |
+| [Gateway 架构](../gateway/docs/architecture.md) | Gateway 三层架构和设计决策 |
+| [Gateway 部署](../gateway/docs/deployment.md) | Gateway 部署模式和配置 |
+| [Gateway API](../gateway/docs/api.md) | Gateway API 完整参考 |

+ 1159 - 0
agent/docs/decisions.md

@@ -0,0 +1,1159 @@
+# 设计决策
+
+> 记录系统设计中的关键决策、权衡和理由。
+
+---
+
+## 1. Skills 通过工具加载 vs 预先注入
+
+### 问题
+Skills 包含大量能力描述,如何提供给 Agent?
+
+### 方案对比
+
+| 方案 | 优点 | 缺点 |
+|------|------|------|
+| **预先注入到 system prompt** | 实现简单 | 浪费 token,Agent 无法选择需要的 skill |
+| **作为工具动态加载** | 按需加载,Agent 自主选择 | 需要实现 skill 工具 |
+
+### 决策
+**选择:作为工具动态加载**
+
+**理由**:
+1. **Token 效率**:只加载需要的 skill,避免浪费 context
+2. **Agent 自主性**:LLM 根据任务决定需要哪些 skill
+3. **可扩展性**:可以有数百个 skills,不影响单次调用的 token 消耗
+4. **业界参考**:OpenCode 和 Claude API 文档都采用此方式
+
+**参考**:
+- OpenCode 的 skill 系统
+- Claude API 文档中的工具使用模式
+
+---
+
+## 2. Skills 用文件系统 vs 数据库
+
+### 问题
+Skills 如何存储?
+
+### 方案对比
+
+| 方案 | 优点 | 缺点 |
+|------|------|------|
+| **文件系统(Markdown)** | 易于编辑,支持版本控制,零依赖 | 搜索能力弱 |
+| **数据库** | 搜索强大,支持元数据 | 编辑困难,需要额外服务 |
+
+### 决策
+**选择:文件系统(Markdown)**
+
+**理由**:
+1. **易于编辑**:直接用文本编辑器或 IDE 编辑
+2. **版本控制**:通过 Git 管理 skill 的历史变更
+3. **零依赖**:不需要数据库服务
+4. **人类可读**:Markdown 格式,便于人工审查和修改
+5. **搜索需求低**:Skill 数量有限(几十到几百个),文件扫描足够快
+
+**实现**:
+```
+~/.reson/skills/           # 全局 skills
+└── error-handling/SKILL.md
+
+./project/.reson/skills/   # 项目级 skills
+└── api-integration/SKILL.md
+```
+
+---
+
+## 3. Experiences 用数据库 vs 文件
+
+### 问题
+Experiences 如何存储?
+
+### 方案对比
+
+| 方案 | 优点 | 缺点 |
+|------|------|------|
+| **文件系统** | 简单,零依赖 | 搜索慢,不支持向量检索 |
+| **数据库(PostgreSQL + pgvector)** | 向量检索,统计分析,高性能 | 需要数据库服务 |
+
+### 决策
+**选择:数据库(PostgreSQL + pgvector)**
+
+**理由**:
+1. **向量检索必需**:Experiences 需要根据任务语义匹配,文件系统无法支持
+2. **统计分析**:需要追踪 success_rate, usage_count 等指标
+3. **数量大**:Experiences 会随着使用不断增长(数千到数万条)
+4. **动态更新**:每次执行后可能更新统计信息,数据库更适合
+
+**实现**:
+```sql
+CREATE TABLE experiences (
+    exp_id TEXT PRIMARY KEY,
+    scope TEXT,
+    condition TEXT,
+    rule TEXT,
+    evidence JSONB,
+
+    confidence FLOAT,
+    usage_count INT,
+    success_rate FLOAT,
+
+    embedding vector(1536),  -- 向量检索
+
+    created_at TIMESTAMP,
+    updated_at TIMESTAMP
+);
+
+CREATE INDEX ON experiences USING ivfflat (embedding vector_cosine_ops);
+```
+
+---
+
+## 4. 不需要事件系统
+
+### 问题
+是否需要事件总线(EventBus)来通知任务状态变化?
+
+### 决策
+**选择:不需要事件系统**
+
+**理由**:
+1. **后台场景**:Agent 主要在后台运行,不需要实时通知
+2. **已有追踪**:Trace/Step 已完整记录所有信息
+3. **按需查询**:需要监控时,查询 Trace 即可
+4. **简化架构**:避免引入额外的复杂性
+
+**替代方案**:
+- 需要告警时,直接在 AgentRunner 中调用通知函数
+- 需要实时监控时,轮询 TraceStore
+
+---
+
+## 5. Trace/Step 用文件系统 vs 数据库
+
+### 问题
+Trace 和 Step 如何存储?
+
+### 方案对比
+
+| 方案 | 优点 | 缺点 |
+|------|------|------|
+| **文件系统(JSON)** | 简单,易于调试,可直接查看 | 搜索和分析能力弱 |
+| **数据库** | 搜索强大,支持复杂查询 | 初期复杂,调试困难 |
+
+### 决策
+**选择:文件系统(JSON)用于 MVP,后期可选数据库**
+
+**理由(MVP阶段)**:
+1. **快速迭代**:JSON 文件易于查看和调试
+2. **零依赖**:不需要数据库服务
+3. **数据量小**:单个项目的 traces 数量有限
+
+**后期迁移到数据库的时机**:
+- Traces 数量超过 1 万条
+- 需要复杂的查询和分析(如"查找所有失败的 traces")
+- 需要聚合统计(如"Agent 的平均成功率")
+
+**实现接口保持一致**:
+```python
+class TraceStore(Protocol):
+    async def save(self, trace: Trace) -> None: ...
+    async def get(self, trace_id: str) -> Trace: ...
+    # ...
+```
+
+通过 Protocol 定义,可以无缝切换实现。
+
+---
+
+## 6. 工具系统的双层记忆管理
+
+### 问题
+工具返回的数据可能很大(如 Browser-Use 的 extract 返回 10K tokens),如何避免占用过多 context?
+
+### 方案对比
+
+| 方案 | 优点 | 缺点 |
+|------|------|------|
+| **单一输出** | 简单 | 大数据会持续占用 context |
+| **双层记忆**(output + long_term_memory) | 节省 context,避免重复传输 | 稍微复杂 |
+
+### 决策
+**选择:双层记忆管理**
+
+**设计**:
+```python
+@dataclass
+class ToolResult:
+    title: str
+    output: str                           # 临时内容(可能很长)
+    long_term_memory: Optional[str]       # 永久记忆(简短摘要)
+    include_output_only_once: bool        # output 是否只给 LLM 看一次
+```
+
+**效果**:
+```
+[User] 提取 amazon.com 的商品价格
+[Assistant] 调用 extract_page_data(url="amazon.com")
+[Tool]
+# Extracted page data
+
+<完整的 10K tokens 数据...>
+
+Summary: Extracted 10000 chars from amazon.com
+
+[User] 现在保存到文件
+[Assistant] 调用 write_file(content="...")
+[Tool] (此时不再包含 10K tokens,只有摘要)
+Summary: Extracted 10000 chars from amazon.com
+```
+
+**理由**:
+1. **Context 效率**:大量数据只传输一次
+2. **保留关键信息**:摘要永久保留在对话历史中
+3. **Browser-Use 兼容**:直接映射到 Browser-Use 的 ActionResult 设计
+
+**参考**:Browser-Use 的 ActionResult.extracted_content 和 long_term_memory
+
+---
+
+## 7. 工具的域名过滤
+
+### 问题
+某些工具只在特定网站可用(如 Google 搜索技巧),是否需要域名过滤?
+
+### 决策
+**选择:支持域名过滤(可选)**
+
+**设计**:
+```python
+@tool(url_patterns=["*.google.com", "www.google.*"])
+async def google_advanced_search(...):
+    """仅在 Google 页面可用的工具"""
+    ...
+```
+
+**理由**:
+1. **减少 context**:在 Google 页面,35 工具 → 20 工具(节省 40%)
+2. **减少 LLM 困惑**:工具数量少了,LLM 更容易选择正确工具
+3. **灵活性**:默认 `url_patterns=None`,所有页面可用
+
+**实现**:
+- URL 模式匹配引擎(`tools/url_matcher.py`)
+- 动态工具过滤(`registry.get_schemas_for_url()`)
+
+---
+
+## 8. 敏感数据处理
+
+### 问题
+浏览器自动化需要输入密码、Token,但不想在对话历史中显示明文,如何处理?
+
+### 决策
+**选择:占位符替换机制**
+
+**设计**:
+```python
+# LLM 输出占位符
+arguments = {
+    "password": "<secret>github_password</secret>",
+    "totp": "<secret>github_2fa_bu_2fa_code</secret>"
+}
+
+# 执行前自动替换
+sensitive_data = {
+    "*.github.com": {
+        "github_password": "secret123",
+        "github_2fa_bu_2fa_code": "JBSWY3DPEHPK3PXP"  # TOTP secret
+    }
+}
+```
+
+**理由**:
+1. **保护隐私**:对话历史中只有占位符,不泄露实际密码
+2. **域名匹配**:不同网站使用不同密钥,防止密钥泄露
+3. **TOTP 支持**:自动生成 2FA 验证码,无需手动输入
+4. **Browser-Use 兼容**:直接映射到 Browser-Use 的敏感数据处理
+
+**实现**:
+- 递归替换(`tools/sensitive.py`)
+- 支持嵌套结构(dict, list, str)
+- 自动 TOTP 生成(pyotp)
+
+**参考**:Browser-Use 的 _replace_sensitive_data
+
+---
+
+## 9. 工具使用统计
+
+### 问题
+是否需要记录工具调用统计(调用次数、成功率、执行时间)?
+
+### 决策
+**选择:内建统计支持**
+
+**设计**:
+```python
+class ToolStats:
+    call_count: int
+    success_count: int
+    failure_count: int
+    total_duration: float
+    last_called: Optional[float]
+```
+
+**理由**:
+1. **监控健康**:识别失败率高的工具
+2. **性能优化**:识别执行慢的工具
+3. **优化排序**:高频工具排前面,减少 LLM 选择时间
+4. **零成本**:自动记录,性能影响 <0.01ms
+
+**用途**:
+- 监控工具健康状况(失败率、延迟)
+- 优化工具顺序(高频工具排前面)
+- 识别问题工具(低成功率、高延迟)
+
+---
+
+## 10. 工具参数的可编辑性
+
+### 问题
+LLM 生成的工具参数是否允许用户编辑?
+
+### 决策
+**选择:支持可选的参数编辑**
+
+**设计**:
+```python
+@tool(editable_params=["query", "filters"])
+async def advanced_search(
+    query: str,
+    filters: Optional[Dict] = None,
+    uid: str = ""
+) -> ToolResult:
+    """高级搜索(用户可编辑 query 和 filters)"""
+    ...
+```
+
+**理由**:
+1. **人类监督**:Agent 生成的参数可能不准确,允许人工微调
+2. **灵活性**:大多数工具不需要编辑(默认 `editable_params=[]`)
+3. **UI 集成**:前端可以展示可编辑的参数供用户修改
+
+**适用场景**:
+- 搜索查询
+- 内容创建
+- 需要人工微调的参数
+
+---
+
+## 11. Context 管理方案选择
+
+**日期**: 2026-02-04
+
+### 问题
+自主长程 Agent(非交互式工具)如何有效管理 Context?
+
+### 决策
+**选择:基于 OpenCode 方案,增强计划管理和回溯能力**
+
+**核心设计**:
+- 简单的工具接口(goal, explore)
+- 复杂逻辑由系统处理(分支管理、context 压缩)
+
+**工具**:
+- `goal`:线性计划管理(add, done, abandon, focus)
+- `explore`:并行探索-合并(系统管理分支 msg list 和结果汇总)
+
+**回溯机制**:
+- 未执行的步骤:直接修改 plan
+- 已执行的步骤:移除原始信息,替换为简短 Summary
+
+**详细设计**:见 [`docs/context-management.md`](./context-management.md)
+
+---
+
+## 12. 计划管理:独立 Goal Tree vs 统一到 Step
+
+**日期**: 2026-02-04(更新)
+
+### 决策
+**选择:独立的 Goal Tree + 线性 Message List**
+
+- **Goal Tree**:结构化的目标/计划(goal.json)
+- **Message List**:线性的执行记录
+- **关联**:每条 message 标记 goal_id
+
+**理由**:
+- 概念清晰:Plan 是"要做什么",Message 是"怎么做的"
+- 压缩精确:基于 goal 完成状态压缩对应的 messages
+
+---
+
+## 13. Summary 生成策略
+
+**日期**: 2026-02-04(更新)
+
+### 决策
+**选择:Goal 完成或放弃时生成 summary**
+
+- `goal(done="summary")` - 正常完成
+- `goal(abandon="原因")` - 放弃(包含失败原因,避免重蹈覆辙)
+
+---
+
+## 14. Context 压缩策略
+
+**日期**: 2026-02-04(更新)
+
+### 决策
+**选择:基于 Goal 状态的增量压缩**
+
+- Message 关联 goal_id
+- Goal 完成/放弃时,将详细 messages 替换为 summary message
+
+---
+
+## 15. 并行探索机制
+
+**日期**: 2026-02-04
+
+### 决策
+**选择:explore 工具,基于 Sub-Trace 机制**
+
+**设计**:
+- `background`:LLM 概括的背景(可选,为空则继承全部历史)
+- `branches`:具体探索方向列表(每个方向创建独立的 Sub-Trace)
+
+**执行**:
+- 为每个探索方向创建独立的 Sub-Trace(完整的 Trace 结构)
+- 并行执行所有 Sub-Traces(使用 asyncio.gather)
+- 汇总所有 Sub-Trace 的结果返回
+
+---
+
+## 16. Skill 分层:Core Skill vs 普通 Skill
+
+### 问题
+Step 工具等核心功能如何让 Agent 知道?
+
+### 方案对比
+
+| 方案 | 优点 | 缺点 |
+|------|------|------|
+| **写在 System Prompt** | 始终可见 | 每次消耗 token,内容膨胀 |
+| **作为普通 Skill** | 按需加载 | 模型不知道存在就不会加载 |
+| **分层:Core + 普通** | 核心功能始终可见,其他按需 | 需要区分两类 |
+
+### 决策
+**选择:Skill 分层**
+
+**设计**:
+- **Core Skill**:`agent/skills/core.md`,自动注入到 System Prompt
+- **普通 Skill**:`agent/skills/{name}/`,通过 `skill` 工具加载到对话消息
+
+**理由**:
+1. **核心功能必须可见**:Step 管理等功能,模型需要始终知道
+2. **避免 System Prompt 膨胀**:只有核心内容在 System Prompt
+3. **普通 Skill 按需加载**:领域知识在需要时才加载,节省 token
+
+**实现**:
+- Core Skill:框架在 `build_system_prompt()` 时自动读取并拼接
+- 普通 Skill:模型调用 `skill` 工具时返回内容到对话消息
+
+---
+
+## 10. 删除未使用的结构化错误功能
+
+**日期**: 2026-02-03
+
+### 问题
+在 execution trace v2.0 开发中引入了 `ErrorCode`、`StepError` 和 feedback 机制,但代码审查发现这些功能完全未被使用。
+
+### 决策
+**选择:删除未使用的代码**
+
+**理由**:
+1. **YAGNI 原则**:不应该维护未使用的功能(You Aren't Gonna Need It)
+2. **减少复杂度**:
+   - ErrorCode 枚举难以穷举,且 Python 无法强制约束
+   - StepError 与 ToolResult.error 存在设计重复
+   - feedback 机制缺少使用场景和配套接口
+3. **可恢复性**:需要时可以从 git 历史恢复
+4. **向后兼容**:这些功能从未被使用,删除不影响现有代码
+
+**影响**:
+- ✅ 代码更简洁
+- ✅ 减少维护负担
+- ✅ 不影响现有功能
+- ⚠️ 未来需要结构化错误时需要重新设计
+
+---
+
+---
+
+## 11. Step 关联统一使用 parent_id
+
+**日期**: 2026-02-03
+
+### 问题
+execution trace v2.0 设计中引入了多个跨节点关联字段(`tool_call_id`、`paired_action_id`、`span_id`),但实际分析后发现这些字段存在冗余。
+
+### 现状分析
+
+**字段使用情况**:
+- `tool_call_id` - Step 对象中未使用,只在 messages 中使用
+- `paired_action_id` - 完全未使用
+- `span_id` - 完全未使用
+
+**关联需求**:
+1. **Action/Result 配对** - 通过 parent_id 已经建立(result.parent_id = action.step_id)
+2. **与 messages 对应** - messages 中包含完整对话历史(含 tool_call_id)
+3. **重试追踪** - 同一个 action 下的多个 result,通过 parent_id 即可
+
+### 设计决策
+
+**保留**:
+- ✅ `parent_id` - 唯一的树结构关联字段
+
+**删除**:
+- ❌ `tool_call_id` - messages 中已包含,Step 不需要重复
+- ❌ `paired_action_id` - 与 parent_id 冗余
+- ❌ `span_id` - 分布式追踪功能,当前用不到
+
+---
+
+## 12. 删除 Blob 存储系统
+
+**日期**: 2026-02-03
+
+### 问题
+execution trace v2.0 引入了 Blob 存储系统用于处理大输出和图片,但实际分析后发现该系统过度设计且与 Agent 现有架构冗余。
+
+### 架构分析
+
+**Agent 的文件处理方式**:
+1. **用户输入**:提供文件路径(不是 base64)
+2. **工具处理**:内置工具直接读写文件系统
+3. **LLM 调用**:Runner 在调用 LLM 时才将文件路径转换为 base64
+4. **Trace 存储**:Step 中存储的是 messages(已包含 base64)
+
+**Blob 系统的问题**:
+1. **冗余提取**:从 messages 中提取 base64 再存储,而 messages 已经在 Step.data 中
+2. **功能重叠**:Agent 内置工具已经提供文件读写能力
+3. **过度设计**:引入 content-addressed storage、deduplication 等复杂功能,但 Agent 场景不需要
+4. **未被使用**:output_preview/blob_ref 字段从未在实际代码中使用
+
+### 设计决策
+
+**删除内容**:
+- ❌ `agent/execution/blob_store.py` 整个文件
+- ❌ `BlobStore` 协议及所有实现
+- ❌ `extract_images_from_messages()` 方法
+- ❌ `restore_images_in_messages()` 方法
+- ❌ `store_large_output()` 方法
+- ❌ Step 字段:`output_preview`、`blob_ref`
+
+**保留方案**:
+- ✅ Step 中的 `data` 字段直接存储 messages(包含 base64)
+- ✅ Agent 内置工具处理文件操作
+- ✅ 用户通过文件路径引用文件(不是 base64)
+
+### 理由
+
+1. **YAGNI 原则**:
+   - 功能从未被使用
+   - 未来需要时可以重新设计
+
+2. **架构更简洁**:
+   - 不需要额外的 blob 存储层
+   - 文件处理统一走工具系统
+
+3. **符合 Agent 场景**:
+   - Agent 运行在本地,直接访问文件系统
+   - 不需要像云服务那样做 blob 存储和 deduplication
+
+4. **数据完整性**:
+   - messages 中的 base64 已经足够
+   - 不需要拆分成 preview + ref
+
+---
+
+## 13. 架构重组:统一 Trace 模型
+
+**日期**: 2026-02-08
+
+### 问题
+
+原有架构存在以下问题:
+1. `execution/` 和 `goal/` 目录职责边界模糊
+2. `Trace.context` 命名与 `ToolContext` 等概念混淆
+3. `subagents/` 目录与 Agent 预设概念重复
+4. 文档散乱,多个重构相关文档过时
+
+### 决策
+
+**目录重组**:
+- `execution/` + `goal/` → `trace/`(统一执行追踪和计划管理)
+- 删除 `subagents/` 目录,逻辑整合到 `trace/task_tool.py`
+- 删除 `core/config.py`,合并到 `runner.py` 和 `presets.py`
+
+**命名调整**:
+- `Trace.context` → `Trace.meta`(TraceMeta 数据类)
+
+**统一 Agent 模型**:
+- 所有 Agent(主、子、人类协助)都是 Trace
+- 子 Agent = 通过 `task` 工具创建的子 Trace
+- 人类协助 = 通过 `ask_human` 工具创建的阻塞式 Trace
+
+**Agent 预设替代 Sub-Agent 配置**:
+- `core/presets.py` 定义 Agent 类型(default, explore, analyst 等)
+- 项目级 `.agent/presets.json` 可覆盖
+
+**工具目录简化**:
+- 仅对 `file/` 和 `browser/` 做子目录分类
+- 其他工具直接放 `builtin/`
+- `goal` 和 `task` 工具放 `trace/`(Agent 内部控制)
+
+### 理由
+
+1. **概念更清晰**:trace/ 统一管理"执行状态",tools/ 专注"外部交互"
+2. **减少冗余**:删除 subagents/ 目录,用统一的 Trace 机制
+3. **命名准确**:meta 比 context 更准确表达"静态元信息"
+
+---
+
+## 14. Subagent 工具设计
+
+### 决策
+
+**统一工具,三种模式**:
+- 单一工具 `subagent` 支持三种模式:`explore`(并行探索)、`delegate`(委托执行)、`evaluate`(结果评估)
+- 实现位置:`agent/tools/builtin/subagent.py`
+
+**Explore 模式的并行执行**:
+- 使用 `asyncio.gather()` 实现真并行
+- 每个 branch 创建独立的 Sub-Trace
+- 仅允许只读工具(`read_file`, `grep_content`, `glob_files`, `goal`)
+
+**权限隔离**:
+- Explore 模式:文件系统只读权限 + goal 工具,防止副作用
+- Delegate/Evaluate 模式:除 `subagent` 外的所有工具
+- 子 Agent 不能调用 `subagent`,防止无限递归
+
+**Sub-Trace 信息存储**:
+- Sub-Trace 的元信息存储在自己的 `meta.json` 中
+- 父 Trace 的 `events.jsonl` 记录 `sub_trace_started` 和 `sub_trace_completed` 事件
+- Goal 的 `sub_trace_ids` 字段记录关联关系
+
+### 理由
+
+1. **统一接口**:三种模式共享相同的 Sub-Trace 创建和管理逻辑,减少代码重复
+2. **真并行**:Explore 模式使用 `asyncio.gather()` 充分利用 I/O 等待时间,提升效率
+3. **安全性**:只读权限确保探索不会修改系统状态;禁止递归调用防止资源耗尽
+4. **可追溯**:事件记录和 Goal 关联确保完整的执行历史,支持可视化和调试
+
+---
+
+## 15. Goal 按需自动创建
+
+**日期**: 2026-02-10
+
+### 问题
+
+Agent(含 sub-agent)有时不创建 goal 就直接执行工具调用,导致 message 的 goal_id 为 null。这造成:
+1. 统计信息丢失(`_update_goal_stats` 跳过 null goal_id)
+2. 可视化缺失结构(前端降级为合成 "START" 节点)
+3. LLM 缺少 context 锚点(goals 为空时不注入计划)
+
+### 方案对比
+
+| 方案 | 优点 | 缺点 |
+|------|------|------|
+| **预创建 root goal** | 保证非 null | 复杂任务多一层无意义嵌套,需要溶解逻辑 |
+| **全面接受 null** | 无改动 | 丢失统计、可视化、context 锚点 |
+| **按需自动创建** | 仅在需要时兜底,不干扰正常规划 | 首轮含 goal() 调用时该轮消息仍为 null(可接受) |
+
+### 决策
+
+**选择:按需自动创建 + prompt 引导**
+
+**触发条件**(三个 AND):
+1. `goal_tree.goals` 为空(尚无任何目标)
+2. LLM 返回了 `tool_calls`(正在执行操作)
+3. `tool_calls` 中不包含 `goal()` 调用(LLM 未自行创建目标)
+
+**触发时机**:LLM 返回后、记录消息前(`runner.py` agent loop 中)
+
+**行为**:从 `goal_tree.mission` 截取前 200 字符作为 root goal description,创建并 focus。
+
+**Prompt 配合**:`core.md` 引导 LLM "先明确目标再行动",但不强制。
+
+**实现**:`agent/core/runner.py:AgentRunner.run`
+
+### 理由
+
+1. **不干扰 LLM 自主规划**:LLM 创建目标时,树结构完全由 LLM 控制,无多余嵌套
+2. **兜底覆盖遗漏**:LLM 跳过目标直接行动时,系统自动补位
+3. **实现简单**:无需 `is_auto_root` 标记、溶解逻辑或 display ID 特殊处理
+4. **可接受的 gap**:首轮含 `goal()` 调用时该轮消息 goal_id 为 null,仅影响一轮,属于规划阶段的过渡消息
+
+---
+
+## 16. Runner 重新设计:参数分层与统一执行入口
+
+**日期**: 2026-02-11
+
+### 问题
+
+原 `AgentRunner.run()` 存在以下问题:
+1. 签名臃肿(13 个参数),运行参数与任务内容混在一起
+2. `task` 字符串同时充当 user message、GoalTree mission、trace.task
+3. 新建/续跑逻辑通过 `if trace_id:` 分支耦合在一起
+4. `subagent.py` 越权管理 Trace 生命周期(创建 Trace、GoalTree 等本该由 Runner 处理的事务)
+5. 不支持回溯重跑(rewind)
+6. Agent 间通信的消息格式不统一
+
+### 决策
+
+**选择:参数分层 + 统一 `run(messages, config)` 入口**
+
+**参数分三层**:
+- **Infrastructure**(AgentRunner 构造时):trace_store, llm_call 等基础设施
+- **RunConfig**(每次 run 时):model, temperature, trace_id, insert_after 等运行参数
+- **Messages**(OpenAI SDK 格式):`List[Dict]` 任务消息,支持多模态
+
+**三种模式通过 RunConfig 区分**:
+- 新建:`trace_id=None`
+- 续跑:`trace_id=已有ID, insert_after=None`
+- 回溯:`trace_id=已有ID, insert_after=N`
+
+**回溯机制**:Message 新增 `status` 字段(`active`/`abandoned`),插入点之后的消息标记为 abandoned,goals 按规则 abandon/保留。
+
+**任务命名**:RunConfig.name 可选指定,未指定时由 utility_llm(小模型)自动生成标题。
+
+**活跃协作者注入**:GoalTree 和 collaborators 信息每 10 轮注入一次(非每轮),减少 context 开销。
+
+**Subagent 简化**:subagent 工具仍负责创建 Sub-Trace 和 GoalTree(需要自定义元数据和命名规则),然后将 trace_id 传给 RunConfig,由 Runner 接管执行。工具同时维护 `trace.context["collaborators"]` 列表。
+
+### 理由
+
+1. **OpenAI 格式统一**:Agent 间传递消息用标准格式,兼容各种 LLM API
+2. **职责清晰**:Runner 管 Trace 生命周期,工具只管业务逻辑
+3. **可组合**:新建/续跑/回溯共享同一个执行流水线,差异仅在 Phase 1
+4. **回溯能力**:支持从任意断点插入消息重新运行,原始数据保留(标记而非删除)
+
+**实现**:`agent/core/runner.py`, `agent/trace/models.py`, `agent/tools/builtin/subagent.py`
+
+---
+
+## 17. Active Collaborators:活跃协作者机制
+
+**日期**: 2026-02-11
+
+### 问题
+
+模型需要知道当前任务中有哪些可以交互的实体(子 Agent、正在对接的人类),但不应该把所有持久联系人都注入到 context。
+
+### 决策
+
+**选择:按任务关系分类,活跃协作者随 GoalTree 注入**
+
+按"与当前任务的关系"(而非 human/agent)分两类:
+- **持久存在**:通过工具按需查询(如 `feishu_get_contact_list`)
+- **任务内活跃**:存 `trace.context["collaborators"]`,周期性注入到 LLM 上下文
+
+各工具(subagent、feishu 等)负责维护 collaborators 列表,Runner 只负责读取和注入。
+
+### 理由
+
+1. **维度正确**:人和 Agent 都可能是持久或任务内活跃的,不应按类型一刀切
+2. **开销可控**:只注入活跃协作者(通常 2-5 个),不浪费 context
+3. **可扩展**:未来新增通信渠道只需在对应工具中更新 collaborators 即可
+
+**实现**:`agent/core/runner.py:AgentRunner._build_context_injection`
+
+---
+
+## 18. 统一 Message 类型 + 重构 Agent/Evaluate 工具
+
+**日期**: 2026-02-12
+
+### 问题
+
+原 `subagent` 工具存在几个问题:
+1. **概念冗余**:单一工具通过 `mode` 参数区分三种行为(explore/delegate/evaluate),参数组合复杂,模型容易用错
+2. **evaluate 的 criteria 参数多余**:模型既要在 `evaluation_input` 里放结果,又要在 `criteria` 里放标准,信息分散
+3. **缺少消息线格式类型**:工具参数和 runner 接口使用裸 `Dict`/`List[Dict]`/`Any`,无语义类型
+4. **SchemaGenerator 不支持 `Literal`/`Union`**:无法为新工具签名生成正确的 JSON Schema
+
+### 决策
+
+#### 18a. 拆分 `subagent` → `agent` + `evaluate` 两个独立工具
+
+- `agent(task, messages, continue_from)` — 创建 Agent 执行任务
+  - `task: str` → 单任务(delegate),全量工具(排除 agent/evaluate)
+  - `task: List[str]` → 多任务并行(explore),只读工具
+  - 通过 `isinstance(task, str)` 判断,无需 `mode` 参数
+- `evaluate(messages, target_goal_id, continue_from)` — 评估目标执行结果
+  - 代码自动从 GoalTree 注入目标描述,无 `criteria` 参数
+  - 模型把所有上下文放在 `messages` 中
+
+内部统一为 `_run_agents()` 函数,`single = len(tasks)==1` 区分 delegate/explore 行为。
+
+#### 18b. 增加消息线格式类型别名(`agent/trace/models.py`)
+
+```python
+ChatMessage = Dict[str, Any]                          # 单条 OpenAI 格式消息
+Messages = List[ChatMessage]                          # 消息列表
+MessageContent = Union[str, List[Dict[str, str]]]     # content 字段(文本或多模态)
+```
+
+放在 `models.py` 而非新文件——与存储层 `Message` dataclass 描述同一概念的不同层次。
+
+#### 18c. SchemaGenerator 支持 `Literal`/`Union`
+
+`_type_to_schema()` 新增:
+- `Literal["a", "b"]` → `{"type": "string", "enum": ["a", "b"]}`
+- `Union[str, List[str]]` → `{"oneOf": [...]}`
+- `Any` → `{}`(无约束)
+
+### 理由
+
+1. **最少概念**:两个单职责工具比一个多 mode 工具更易理解和使用
+2. **最少参数**:evaluate 无需 criteria(GoalTree 已有目标描述),agent 的 messages 支持 1D/2D 避免额外参数
+3. **模型/代码职责分离**:模型只管给 messages,代码自动注入 goal 上下文
+4. **类型安全**:`Union[str, List[str]]` 在 Schema 中生成 `oneOf`,LLM 能正确理解参数格式
+
+### 变更范围
+
+- `agent/trace/models.py` — 类型别名
+- `agent/tools/schema.py` — `Literal`/`Union` 支持
+- `agent/tools/builtin/subagent.py` — `agent` + `evaluate` 工具,`_run_agents()` 统一函数
+- `agent/tools/builtin/__init__.py`, `agent/core/runner.py` — 注册表更新
+- `agent/tools/builtin/feishu/chat.py`, `agent/tools/builtin/browser/baseClass.py` — 类型注解修正
+- `agent/__init__.py` — 导出新类型
+
+**实现**:`agent/tools/builtin/subagent.py`, `agent/trace/models.py`, `agent/tools/schema.py`
+
+---
+
+## 19. 前端控制 API:统一 run + stop + reflect
+
+**日期**: 2026-02-12
+
+### 问题
+
+需要从前端控制 Agent 的创建、启动(含从任意位置重放)、插入用户消息、打断运行。原有 API 将 `continue` 和 `rewind` 拆分为两个独立端点,但它们本质上是同一操作(在某个位置运行),仅 `insert_after` 是否为 null 的区别。此外,缺少停止和反思机制。
+
+### 决策
+
+#### 19a. 合并 `continue` + `rewind` → 统一 `run` 端点
+
+```
+POST /api/traces/{id}/run
+{
+  "messages": [...],
+  "insert_after": null | int
+}
+```
+
+- `insert_after: null` → 从末尾续跑(原 continue)
+- `insert_after: N` → 回溯到 sequence N 后运行(原 rewind)
+- `messages: []` + `insert_after: N` → 重新生成(从 N 处重跑,不插入新消息)
+
+删除 `POST /{id}/continue` 和 `POST /{id}/rewind` 两个端点。
+
+#### 19b. 新增 `stop` 端点 + Runner 取消机制
+
+```
+POST /api/traces/{id}/stop
+```
+
+Runner 内部维护 `_cancel_events: Dict[str, asyncio.Event]`,agent loop 在每次 LLM 调用前检查。`stop()` 方法设置事件,loop 退出,Trace 状态置为 `stopped`。
+
+Trace.status 新增 `"stopped"` 值。
+
+#### 19c. 新增 `reflect` 端点 — 追加反思 prompt 运行
+
+```
+POST /api/traces/{id}/reflect
+{
+  "focus": "optional, 反思重点"
+}
+```
+
+在 trace 末尾追加一条内置反思 prompt 的 user message,以续跑方式运行 agent。Agent 回顾整个执行过程后生成经验总结,结果自动追加到 `./.cache/experiences.md`。
+
+不单独调用 LLM、不解析结构化数据——反思就是一次普通的 agent 运行,只是 user message 是预置的反思 prompt。
+
+#### 19d. 经验存储简化为文件
+
+经验存储从 MemoryStore(内存/数据库)简化为 `./.cache/experiences.md` 文件:
+- 人类可读可编辑(Markdown)
+- 可版本控制(git)
+- 新建 Trace 时由 Runner 读取并注入到第一条 user message 末尾
+- `GET /api/experiences` 直接读取文件内容返回
+
+### 最终 API 设计
+
+```
+控制类(3 个端点,替代原来的 3 个):
+  POST /api/traces              → 创建并运行(不变)
+  POST /api/traces/{id}/run     → 运行(合并 continue + rewind)
+  POST /api/traces/{id}/stop    → 停止(新增)
+
+学习类(2 个端点,全新):
+  POST /api/traces/{id}/reflect → 追加反思 prompt 运行,结果追加到 experiences 文件
+  GET  /api/experiences         → 读取经验文件内容
+```
+
+### 理由
+
+1. **API 更少**:`continue` 和 `rewind` 合并后端点总数不增反减(3 → 3 控制 + 2 学习)
+2. **概念统一**:`run` 就是"在某个位置运行",`insert_after` 自然区分续跑和回溯,与 `RunConfig` 设计一致
+3. **前端简化**:`sendMessage()` 直接透传 `branchPoint` 作为 `insert_after`,无需判断调哪个 API
+4. **停止机制**:asyncio.Event 轻量可靠,每次 LLM 调用前检查,不会在工具执行中途被打断
+5. **反思闭环**:Run → Observe → Intervene → Reflect → Run,形成完整的学习循环
+6. **经验存储极简**:一个 Markdown 文件,不需要数据库,人类可读可编辑可版本控制
+
+### 变更范围
+
+- `agent/trace/models.py` — Trace.status 增加 `"stopped"`
+- `agent/core/runner.py` — `_cancel_events` 字典,`stop()` 方法,agent loop 检查取消;`experiences_path` 参数,`_load_experiences()` 方法,新建时注入经验到 user message
+- `agent/trace/run_api.py` — 合并 `continue`/`rewind` 为 `run`,新增 `stop`/`reflect` 端点,`GET /api/experiences` 读取文件
+- `api_server.py` — 注入 experiences_router
+
+**实现**:`agent/trace/run_api.py`, `agent/core/runner.py`, `agent/trace/models.py`
+
+---
+
+## 20. Message Tree:用 parent_sequence 构建消息树
+
+**日期**: 2026-02-13
+
+### 问题
+
+原有的消息管理使用线性列表 + `status=abandoned` 标记,导致:
+1. 压缩需要独立的 compression events + skip list 来标记跳过哪些消息
+2. 反思消息掺入主对话列表,需要额外过滤
+3. Rewind 需要标记 abandoned + 维护 GoalTree 快照
+4. `build_llm_messages` 逻辑复杂(过滤 abandoned + 应用 skip + 排除反思)
+
+### 决策
+
+**选择:Message 新增 `parent_sequence` 字段,消息形成树结构**
+
+核心规则:**`build_llm_messages` = 从 head 沿 parent_sequence 链回溯到 root**。
+
+**压缩**:summary 的 `parent_sequence` 指向压缩范围起点的前一条消息,旧消息自然脱离主路径。
+
+```
+压缩前主路径:1 → 2 → 3 → ... → 41 → 42 → ...
+压缩后:
+  1 → 2 → 3 → ... → 41 (旧路径,脱离主路径)
+       ↓
+  2 → 45(summary, parent=2) → 46 → ...  (新主路径)
+```
+
+**反思**:反思消息从当前消息分出侧枝,不汇入主路径,天然隔离。
+
+**Rewind**:新消息的 `parent_sequence` 指向 rewind 点,旧路径自动变成死胡同。
+
+```
+Rewind 到 seq 20:
+  主路径原本:1 → ... → 20 → 21 → ... → 50
+  Rewind 后:20 → 51(新, parent=20) → 52 → ...
+  新主路径:1 → ... → 20 → 51 → 52 → ...
+  旧消息 21-50 脱离主路径,无需标记 abandoned
+```
+
+**build_llm_messages**:
+
+```python
+def build_llm_messages(head_sequence, messages_by_seq):
+    path = []
+    seq = head_sequence
+    while seq is not None:
+        msg = messages_by_seq[seq]
+        path.append(msg)
+        seq = msg.parent_sequence
+    path.reverse()
+    return [m.to_llm_dict() for m in path]
+```
+
+### 不再需要的机制
+
+- ~~Message.status (abandoned)~~ → 树结构替代
+- ~~Message.abandoned_at~~ → 树结构替代
+- ~~compression events in events.jsonl~~ → summary.parent_sequence 替代
+- ~~abandon_messages_after()~~ → 新消息设 parent_sequence 即可
+- ~~skip list / 过滤逻辑~~ → parent chain 遍历替代
+
+### 变更范围
+
+- `agent/trace/models.py` — Message 新增 `parent_sequence`,`status`/`abandoned_at` 保留但标记弃用
+- `agent/trace/store.py` — 新增 `get_main_path_messages()`,Trace 追踪 `head_sequence`
+- `agent/trace/protocols.py` — 新增 `get_main_path_messages()` 接口
+- `agent/core/runner.py` — agent loop 中设置 parent_sequence,rewind 使用新模型
+
+**实现**:`agent/trace/models.py`, `agent/trace/store.py`, `agent/core/runner.py`
+
+---
+
+## 21. GoalTree Rewind:快照 + 重建
+
+**日期**: 2026-02-13
+
+### 问题
+
+Message Tree 解决了消息层面的分支问题,但 GoalTree 是独立的状态,不适合从消息树派生(压缩会使目标创建消息脱离主路径,但目标应该保留)。
+
+### 决策
+
+**选择:GoalTree 保持独立管理,rewind 时快照 + 重建**
+
+**Rewind 流程**:
+1. 把当前完整 GoalTree 快照存入 `events.jsonl`(rewind 事件的 `goal_tree_snapshot` 字段)
+2. 重建干净的 GoalTree:保留 rewind 点之前已 completed 的 goals,丢弃其余
+3. 清空 `current_id`,让 Agent 重新选择焦点
+
+**快照用途**:仅用于非运行态下查看历史版本,运行时和前端展示只使用当前干净的 goal.json。
+
+**Agent 自主废弃**:Agent 调用 `goal(abandon=...)` 时,abandoned goals 正常保留在 GoalTree 中,前端逐一收到事件,可以展示废弃的分支。
+
+**用户 Rewind**:不展示废弃的分支。GoalTree 被清理为只包含存活 goals,用户可通过"历史版本"页面查看快照。
+
+### 理由
+
+1. GoalTree 和 Messages 的生命周期不同——压缩可以移除消息但不能移除目标
+2. 快照 + 重建逻辑简单可靠,不需要 event sourcing
+3. 干净的 goal.json 让运行时和前端展示始终一致
+
+### 变更范围
+
+- `agent/core/runner.py:_rewind()` — 快照旧树到事件,重建干净树
+- `agent/trace/store.py` — rewind 事件增加 `goal_tree_snapshot`
+
+**实现**:`agent/core/runner.py`
+
+---
+
+## 22. Context 压缩:GoalTree 双视图 + 两级压缩
+
+**日期**: 2026-02-13
+
+### 问题
+
+长时间运行的 Agent 会累积大量 messages,超出 LLM 上下文窗口。需要在保留关键信息的前提下压缩历史。
+
+### 决策
+
+**选择:Level 1 确定性过滤 + Level 2 LLM 总结,压缩不修改存储**
+
+#### 22a. GoalTree 双视图
+
+`to_prompt()` 支持两种模式:
+- `include_summary=False`(默认):精简视图,用于日常周期性注入
+- `include_summary=True`:含所有 completed goals 的 summary,用于压缩时提供上下文
+
+压缩视图追加到第一条 user message 末尾(构建 `llm_messages` 时的内存操作,不修改存储)。
+
+#### 22b. Level 1:GoalTree 过滤(确定性,零成本)
+
+每轮 agent loop 构建 `llm_messages` 时:
+- 始终保留:system prompt、第一条 user message、focus goal 的消息
+- 跳过 completed/abandoned goals 的消息(信息已在 GoalTree summary 中)
+- 通过 Message Tree 的 parent_sequence 实现(压缩 summary 的 parent 跳过被压缩的消息)
+
+大多数情况下 Level 1 足够。
+
+#### 22c. Level 2:LLM 总结(仅在 Level 1 后仍超限时触发)
+
+触发条件:Level 1 之后 token 数仍超过阈值(默认 max_tokens × 0.8)。
+
+做法:在当前消息列表末尾追加压缩 prompt → 主模型回复 → summary 作为新消息存入 messages/,其 parent_sequence 跳过被压缩的范围。
+
+不使用 utility_llm,就用主模型。压缩和反思都是"在消息列表末尾追加 prompt,主模型回复"。
+
+#### 22d. 压缩前经验提取
+
+触发 Level 2 压缩之前,先在消息列表末尾追加反思 prompt → 主模型回复 → 结果追加到 `./.cache/experiences.md`。反思消息为侧枝(parent_sequence 分叉,不在主路径上)。
+
+#### 22e. 压缩不修改存储
+
+- `messages/` 始终保留原始消息
+- 压缩结果(summary)作为新消息存入 messages/
+- 通过 parent_sequence 树结构实现"跳过",不需要 compression events 或 skip list
+- Rewind 到压缩区域内时,原始消息自动恢复到主路径(summary 脱离新主路径)
+
+#### 22f. 多次压缩的恢复
+
+每次压缩的 summary 消息通过 parent_sequence 跳过被压缩的范围。Rewind 时,如果 rewind 点在某次压缩之后,该压缩的 summary 仍在主路径上,压缩保持生效;如果 rewind 点在压缩之前,summary 脱离新主路径,原始消息自动恢复。无需特殊恢复逻辑。
+
+### 变更范围
+
+- `agent/trace/goal_models.py` — `to_prompt(include_summary)` 双视图
+- `agent/trace/compaction.py` — 压缩触发逻辑、Level 1/Level 2 实现
+- `agent/core/runner.py` — agent loop 中集成压缩
+
+**实现**:`agent/trace/compaction.py`, `agent/trace/goal_models.py`, `agent/core/runner.py`
+
+---
+
+## Decision 23: 控制 API 适配消息树
+
+**背景**:Decision 20 引入 parent_sequence 消息树后,控制 API(run_api.py)和查询 API(api.py)的接口语义需要同步更新。原有的 `insert_after`、`include_abandoned` 等概念不再匹配消息树模型。
+
+### 23a. `insert_after` → `after_sequence`(统一续跑/回溯/分支)
+
+**问题**:原 `insert_after` 创建了"续跑"和"回溯"的二分概念,但在消息树模型中,二者本质相同——都是指定新消息的 `parent_sequence`。
+
+**决策**:`TraceRunRequest.insert_after` 重命名为 `after_sequence`。`RunConfig.insert_after` 同步重命名为 `after_sequence`。Runner 根据 `after_sequence` 与当前 `head_sequence` 的关系自动判断行为:
+
+| 情况 | 判断条件 | 行为 |
+|------|---------|------|
+| 续跑 | `after_sequence` 为 None 或 == `head_sequence` | 从末尾追加 |
+| 回溯 | `after_sequence` 在当前主路径上且 < `head_sequence` | 截断后追加,GoalTree 快照+重建 |
+| 分支切换 | `after_sequence` 不在当前主路径上 | 切换到该分支追加(预留,暂不实现) |
+
+前端只需传 `after_sequence`(从哪条消息后面接着跑)和 `messages`(可为空),不需要理解内部模式。
+
+### 23b. `_rewind` 完成目标检测修正
+
+**问题**:`_rewind()` 中使用 `[m for m in all_messages if m.sequence <= cutoff]` 过滤消息来检测 completed goals,但 `all_messages` 包含所有分支的消息,可能把其他分支的消息误判为回溯点之前的消息。
+
+**决策**:改用 `get_main_path_messages(trace_id, cutoff_sequence)` 从 cutoff 沿 parent_sequence 链回溯,只获取实际主路径上的消息来判断哪些 goals 有消息。
+
+### 23c. Reflect 隔离
+
+**问题**:当前 reflect 以续跑方式调用 `run_result()`,会进入完整 agent loop(含工具调用、GoalTree 操作),可能产生副作用。且 head_sequence 恢复不在 try/finally 中,异常时会丢失。
+
+**决策**:
+- reflect 使用 `RunConfig(max_iterations=1, tools=[])` 限制为单轮无工具 LLM 调用
+- head_sequence 恢复放入 try/finally
+- reflect 消息仍为侧枝(parent_sequence 分叉,不在主路径上)
+
+### 23d. Messages 查询 API 适配消息树
+
+**问题**:`GET /api/traces/{id}/messages` 的 `include_abandoned` 参数基于旧的 abandoned 标记概念,不再适用于消息树。
+
+**决策**:替换为两个参数:
+- `mode`: `main_path`(默认)| `all` — 返回当前主路径消息或全部消息
+- `head`: 可选 sequence 值 — 指定从哪个 head 构建主路径(默认用 trace.head_sequence)
+
+`mode=main_path` 调用 `get_main_path_messages()`;`mode=all` 调用 `get_trace_messages()`。
+
+### 23e. Rewind 事件增加 head_sequence
+
+Rewind 事件 payload 中增加 `head_sequence` 字段,便于前端感知分支切换后的新 head 位置。
+
+### 变更范围
+
+- `agent/trace/run_api.py` — `TraceRunRequest.after_sequence`、reflect 隔离
+- `agent/core/runner.py` — `RunConfig.after_sequence`、`_prepare_existing_trace`、`_rewind` 修正
+- `agent/trace/api.py` — messages 查询参数 `mode`/`head`
+
+**实现**:`agent/trace/run_api.py`, `agent/core/runner.py`, `agent/trace/api.py`
+
+---

+ 203 - 0
agent/docs/knowledge.md

@@ -0,0 +1,203 @@
+- 产生知识时区分:人、场景(例如飞书群,可以对应到tags)、业务(还有子业务)
+
+**知识结构**(单条知识)
+    id: 知识唯一标识
+    task: 任务描述,什么场景、在做什么
+
+    type: 知识类型(单选)
+        user_profile:用户画像(偏好、习惯、背景、约束)
+        strategy:具体执行经验,从执行过程中反思获得
+        tool:工具名称,简介,使用方法,优缺点对比,代码示例
+        usecase:用户背景,采用方案,实现步骤,问题,效果
+        definition:概念定义,技术原理,应用场景
+        plan:流程步骤,决策点,某个问题的总结方法论
+
+    tags: 业务标签(可有多个)
+        category: 子类别(如 preference, background, habit, constraint)
+        domain: 领域(如 coding_style, architecture)
+        其他自定义标签
+        scenario
+
+    scopes: 可见范围(可有多个,格式:{entity_type}:{entity_id})
+        user:{user_id}:用户级(个人可见)
+        agent:{agent_id}:Agent 级(特定 Agent 可见)
+        project:{project_id}:项目级(项目组可见)
+        team:{team_id}:团队级(部门可见)
+        org:{org_id}:组织级(全公司可见)
+        public:公开(所有人可见)
+
+    owner: 所有者(格式:{entity_type}:{entity_id},唯一)
+        谁创建的,谁有权修改/删除
+
+    content:
+        基于类型的具体内容,相对完整的一条知识
+
+    source:
+        name: 工具/资源名称(若有)
+        category:paper/exp/skill/book...
+        urls:知识来源的网站
+        agent_id:调用知识查询的agent名称
+        submitted_by:创建本条目的agent负责人名称
+        timestamp: 知识生成时的时间戳
+        trace_id: 来源 Trace ID(若有)
+
+    eval:基于使用反馈
+        helpful: 好用的次数
+        harmful:不好用的次数
+        confidence: 置信度(0-1)
+        helpful_history: [(query+trace_id+outcome), ]用于记录反馈时的调用总结
+        harmful_history: []
+
+知识检索机制
+    检索流程
+        1. 构建可见范围
+            根据执行上下文(user_id, agent_id, project_id, team_id, org_id)
+            构建用户的所有可见 scopes:
+                - user:{user_id}
+                - agent:{agent_id}
+                - project:{project_id}
+                - team:{team_id}
+                - org:{org_id}
+                - public
+
+        2. 向量检索 + 过滤
+            查询条件:
+                - 语义匹配(向量检索)
+                - scopes 过滤(知识的 scopes 与用户的 visible_scopes 有交集)
+                - type 过滤(可选,按知识类型过滤)
+
+        3. 按优先级排序
+            优先级:user > project > agent > team > org > public
+            取知识的 scopes 中优先级最高的进行排序
+
+    触发时机
+        - Agent 启动时:自动检索相关知识
+        - Goal focus 时:检索与当前目标相关的知识
+        - 主动调用:通过 get_knowledge 工具主动查询
+
+    实现位置(规划)
+        - agent/memory/knowledge_store.py: 知识存储接口
+        - agent/memory/knowledge_retriever.py: 检索逻辑
+        - agent/tools/builtin/knowledge.py: get_knowledge 工具
+知识提取机制
+    触发时机
+        - 主动表达:用户明确表达偏好、纠正、提供背景信息
+        - 任务完成:任务完成后的反思总结
+        - 压缩消息:在压缩消息节点提取经验
+        - 用户反馈:用户对结果的 helpful/harmful 评价
+
+    提取流程
+        1. 识别知识类型
+            根据内容判断是 user_profile / strategy / tool / definition / plan / usecase
+
+        2. 结构化提取
+            - 提取核心内容
+            - 生成标签(category, domain 等)
+            - 确定 scopes(基于执行上下文)
+            - 设置 owner 和 visibility
+
+        3. 持久化存储
+            - 存入知识库(向量数据库 + 元数据)
+            - 记录来源(trace_id, agent_id, timestamp)
+            - 初始化评价(helpful=1, confidence=0.5)
+
+    实现位置(规划)
+        - agent/trace/compaction.py: 压缩时提取经验
+        - agent/tools/builtin/knowledge.py: save_knowledge 工具
+        - agent/memory/knowledge_extractor.py: 知识提取逻辑
+用户画像特殊处理
+    用户画像是一种特殊的知识类型(type=user_profile),具有以下特点:
+
+    获取方式
+        - 持续性:在整个对话过程中持续积累
+        - 多触发点:主动表达 > 任务完成 > 周期性总结
+        - 渐进式:逐步完善,置信度逐渐提高
+
+    存储特点
+        - type: user_profile
+        - scopes: 通常包含 user:{user_id},可能包含 project:{project_id}
+        - owner: user:{user_id}
+        - tags.category: preference | background | habit | constraint
+
+    检索优先级
+        - 在 Agent 启动时自动加载
+        - 优先级高于其他类型的知识
+        - 按 confidence 和 helpful 评分排序
+
+    示例
+        {
+            "id": "profile_001",
+            "type": "user_profile",
+            "task": "用户编码风格偏好",
+            "tags": {
+                "category": "preference",
+                "domain": "coding_style"
+            },
+            "scopes": ["user:123", "project:456"],
+            "owner": "user:123",
+            "visibility": "shared",
+            "content": "用户偏好使用 TypeScript 而非 JavaScript,注重类型安全",
+            "source": {
+                "agent_id": "general_assistant",
+                "trace_id": "trace-xxx",
+                "timestamp": "2026-03-03"
+            },
+            "eval": {
+                "helpful": 5,
+                "harmful": 0,
+                "confidence": 0.9
+            }
+        }
+存储结构(规划)
+    数据库表结构
+        CREATE TABLE knowledge (
+            id TEXT PRIMARY KEY,
+            type TEXT NOT NULL,              -- user_profile | strategy | tool | ...
+            task TEXT,
+            tags JSON,
+            scopes JSON,                     -- ["user:123", "project:456", ...]
+            owner TEXT NOT NULL,             -- "user:123" | "agent:xxx"
+            visibility TEXT,                 -- private | shared | org | public
+            content TEXT,
+            source JSON,
+            eval JSON,
+            embedding VECTOR(1536),          -- 向量检索
+            created_at TIMESTAMP,
+            updated_at TIMESTAMP
+        );
+
+        -- 索引
+        CREATE INDEX idx_type ON knowledge(type);
+        CREATE INDEX idx_owner ON knowledge(owner);
+        CREATE INDEX idx_visibility ON knowledge(visibility);
+        CREATE INDEX idx_scopes ON knowledge USING GIN(scopes);
+
+    文件系统结构
+        /workspace/knowledge/
+        ├── global/                   # 全局知识
+        │   ├── tools.json
+        │   ├── definitions.json
+        │   └── plans.json
+        │
+        ├── agents/                   # Agent 经验
+        │   ├── general_assistant/
+        │   ├── crawler_ops/
+        │   └── ...
+        │
+        └── users/                    # 用户画像
+            ├── user_123/
+            │   ├── profile.json      # 基础信息
+            │   ├── preferences.json  # 偏好
+            │   ├── habits.json       # 习惯
+            │   └── constraints.json  # 约束
+            └── ...
+
+TODO
+    1. 实现知识存储接口(agent/memory/knowledge_store.py)
+    2. 实现知识检索逻辑(agent/memory/knowledge_retriever.py)
+    3. 实现 get_knowledge 工具(agent/tools/builtin/knowledge.py)
+    4. 实现 save_knowledge 工具(agent/tools/builtin/knowledge.py)
+    5. 在 Agent 启动时集成知识检索
+    6. 在 Goal focus 时集成知识检索
+    7. 在压缩消息时集成知识提取
+    8. 实现用户画像的特殊处理逻辑

+ 126 - 0
agent/docs/multimodal.md

@@ -0,0 +1,126 @@
+# 多模态支持
+
+多模态消息(文本 + 图片)支持,遵循 OpenAI API 规范。
+
+---
+
+## 架构层次
+
+```
+Prompt 层 (SimplePrompt) → OpenAI 格式消息 → Provider 层适配 → 模型 API
+```
+
+**关键原则**:
+- 遵循 OpenAI API 消息格式规范
+- 模型适配封装在 Provider 层
+- 应用层通过 Prompt 层统一处理
+
+---
+
+## 核心实现
+
+### 1. Prompt 层多模态支持
+
+**实现位置**:`agent/llm/prompts/wrapper.py:SimplePrompt`
+
+**功能**:构建 OpenAI 格式的多模态消息
+
+```python
+# 使用示例
+prompt = SimplePrompt("task.prompt")
+messages = prompt.build_messages(
+    text="内容",
+    images="path/to/image.png"  # 或 images=["img1.png", "img2.png"]
+)
+```
+
+**关键方法**:
+- `build_messages(**context)` - 构建消息列表,支持 `images` 参数
+- `_build_image_content(image)` - 将图片路径转为 OpenAI 格式(data URL)
+
+**消息格式**(OpenAI 规范):
+```python
+[
+  {"role": "system", "content": "系统提示"},
+  {
+    "role": "user",
+    "content": [
+      {"type": "text", "text": "..."},
+      {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
+    ]
+  }
+]
+```
+
+### 2. Gemini Provider 适配
+
+**实现位置**:`agent/llm/gemini.py:_convert_messages_to_gemini`
+
+**功能**:将 OpenAI 多模态格式转换为 Gemini 格式
+
+**转换规则**:
+- 检测 `content` 是否为数组(多模态标志)
+- `{"type": "text"}` → Gemini `{"text": "..."}`
+- `{"type": "image_url"}` → Gemini `{"inline_data": {"mime_type": "...", "data": "..."}}`
+
+**关键逻辑**:
+```python
+# 处理多模态消息
+if isinstance(content, list):
+    parts = []
+    for item in content:
+        if item.get("type") == "text":
+            parts.append({"text": item.get("text")})
+        elif item.get("type") == "image_url":
+            # 解析 data URL 并转换
+            mime_type, base64_data = parse_data_url(url)
+            parts.append({"inline_data": {"mime_type": mime_type, "data": base64_data}})
+```
+
+---
+
+## 使用方式
+
+### .prompt 文件
+
+标准 `.prompt` 文件格式:
+```yaml
+---
+model: gemini-2.5-flash
+temperature: 0.3
+---
+
+$system$
+系统提示...
+
+$user$
+用户提示:%text%
+```
+
+### 应用层调用
+
+**参考示例**:`examples/feature_extract/run.py`
+
+```python
+# 1. 加载 prompt
+prompt = SimplePrompt("task.prompt")
+
+# 2. 构建消息(自动处理图片)
+messages = prompt.build_messages(text="...", images="img.png")
+
+# 3. 调用 Agent
+runner = AgentRunner(llm_call=create_gemini_llm_call())
+result = await runner.call(messages=messages, model="gemini-2.5-flash")
+```
+
+---
+
+## 扩展支持
+
+**当前支持**:
+- 图片格式:PNG, JPEG, GIF, WebP
+- 输入方式:文件路径或 base64 data URL
+
+**未来扩展**:
+- 音频、视频等其他模态
+- 资源缓存和异步加载

+ 331 - 0
agent/docs/scope-design.md

@@ -0,0 +1,331 @@
+# Scope 设计文档
+
+## 概述
+
+Scope 系统用于控制知识的可见性和访问权限。采用**灵活的标签系统**,而非固定的层级结构。
+
+## 核心原则
+
+1. **知识类型与可见性分离**
+   - `type` 字段表示知识类型(What)
+   - `scopes` 字段表示可见范围(Who)
+
+2. **多 Scope 支持**
+   - 一条知识可以有多个 scope 标签
+   - 支持灵活的共享关系
+
+3. **明确的所有权**
+   - `owner` 字段表示唯一所有者
+   - 只有所有者有权修改/删除
+
+## Scope 格式
+
+```
+格式:{entity_type}:{entity_id}
+
+示例:
+- user:123
+- agent:general_assistant
+- project:456
+- team:frontend
+- org:company
+- public
+```
+
+## Scope 类型
+
+| Scope 类型 | 格式 | 说明 | 示例 |
+|-----------|------|------|------|
+| 用户级 | `user:{user_id}` | 用户个人可见 | `user:123` |
+| Agent 级 | `agent:{agent_id}` | 特定 Agent 可见 | `agent:crawler_ops` |
+| 项目级 | `project:{project_id}` | 项目组可见 | `project:456` |
+| 团队级 | `team:{team_id}` | 部门可见 | `team:frontend` |
+| 组织级 | `org:{org_id}` | 全公司可见 | `org:company` |
+| 公开 | `public` | 所有人可见 | `public` |
+
+## 数据结构
+
+```json
+{
+  "id": "knowledge_001",
+
+  "type": "user_profile",
+
+  "scopes": [
+    "user:123",
+    "project:456"
+  ],
+
+  "owner": "user:123",
+
+  "visibility": "shared",
+
+  "content": "...",
+  "tags": {...},
+  "source": {...},
+  "eval": {...}
+}
+```
+
+## 字段说明
+
+### type(知识类型)
+
+- `user_profile`:用户画像
+- `strategy`:执行经验
+- `tool`:工具知识
+- `usecase`:用例
+- `definition`:概念定义
+- `plan`:方法论
+
+### scopes(可见范围)
+
+- 数组类型,可包含多个 scope
+- 知识对所有 scopes 中的实体可见
+- 检索时匹配:知识的 scopes 与用户的 visible_scopes 有交集
+
+### owner(所有者)
+
+- 字符串类型,格式同 scope
+- 唯一所有者,有权修改/删除
+- 通常是创建者
+
+### visibility(可见性级别)
+
+快速过滤标签,用于 UI 展示和简单查询:
+
+- `private`:私有(仅所有者)
+- `shared`:共享(多个实体)
+- `org`:组织级
+- `public`:公开
+
+## 使用场景
+
+### 场景 1:用户的私有偏好
+
+```json
+{
+  "type": "user_profile",
+  "scopes": ["user:123"],
+  "owner": "user:123",
+  "visibility": "private",
+  "content": "用户偏好使用 TypeScript"
+}
+```
+
+### 场景 2:项目组共享的经验
+
+```json
+{
+  "type": "strategy",
+  "scopes": ["project:456"],
+  "owner": "agent:crawler_ops",
+  "visibility": "shared",
+  "content": "爬虫反爬策略:使用代理池 + 随机 UA"
+}
+```
+
+### 场景 3:跨项目的用户偏好
+
+```json
+{
+  "type": "user_profile",
+  "scopes": ["user:123", "project:456", "project:789"],
+  "owner": "user:123",
+  "visibility": "shared",
+  "content": "用户在多个项目中都偏好使用 React"
+}
+```
+
+### 场景 4:Agent 间共享的工具知识
+
+```json
+{
+  "type": "tool",
+  "scopes": [
+    "agent:crawler_ops",
+    "agent:content_library",
+    "agent:general_assistant"
+  ],
+  "owner": "agent:crawler_ops",
+  "visibility": "shared",
+  "content": "Selenium 使用技巧:headless 模式配置"
+}
+```
+
+### 场景 5:组织级公开知识
+
+```json
+{
+  "type": "definition",
+  "scopes": ["org:company"],
+  "owner": "org:company",
+  "visibility": "org",
+  "content": "公司技术栈:React + TypeScript + Node.js"
+}
+```
+
+### 场景 6:完全公开的知识
+
+```json
+{
+  "type": "tool",
+  "scopes": ["public"],
+  "owner": "org:company",
+  "visibility": "public",
+  "content": "Git 常用命令速查表"
+}
+```
+
+## 检索逻辑
+
+### 构建可见范围
+
+```python
+def build_visible_scopes(context):
+    """
+    根据执行上下文构建用户的所有可见 scopes
+
+    context = {
+        "user_id": "123",
+        "agent_id": "general_assistant",
+        "project_id": "456",
+        "team_id": "frontend",
+        "org_id": "company"
+    }
+    """
+    scopes = []
+
+    if context.get("user_id"):
+        scopes.append(f"user:{context['user_id']}")
+
+    if context.get("agent_id"):
+        scopes.append(f"agent:{context['agent_id']}")
+
+    if context.get("project_id"):
+        scopes.append(f"project:{context['project_id']}")
+
+    if context.get("team_id"):
+        scopes.append(f"team:{context['team_id']}")
+
+    if context.get("org_id"):
+        scopes.append(f"org:{context['org_id']}")
+
+    scopes.append("public")
+
+    return scopes
+```
+
+### 检索查询
+
+```python
+def search_knowledge(query, context, knowledge_type=None):
+    """
+    检索知识
+
+    query: 查询内容
+    context: 用户上下文
+    knowledge_type: 可选,过滤知识类型
+    """
+    # 1. 构建可见范围
+    visible_scopes = build_visible_scopes(context)
+
+    # 2. 构建查询条件
+    filters = {
+        "scopes": {"$in": visible_scopes}  # 交集匹配
+    }
+
+    if knowledge_type:
+        filters["type"] = knowledge_type
+
+    # 3. 向量检索 + 过滤
+    results = vector_db.search(
+        query=query,
+        filter=filters,
+        top_k=10
+    )
+
+    # 4. 按优先级排序
+    return rank_by_scope_priority(results, context)
+```
+
+### 优先级排序
+
+```python
+def rank_by_scope_priority(results, context):
+    """
+    按 scope 优先级排序
+
+    优先级:user > project > agent > team > org > public
+    """
+    priority_map = {
+        f"user:{context.get('user_id')}": 6,
+        f"project:{context.get('project_id')}": 5,
+        f"agent:{context.get('agent_id')}": 4,
+        f"team:{context.get('team_id')}": 3,
+        f"org:{context.get('org_id')}": 2,
+        "public": 1
+    }
+
+    def get_priority(knowledge):
+        # 取知识的 scopes 中优先级最高的
+        max_priority = 0
+        for scope in knowledge["scopes"]:
+            max_priority = max(max_priority, priority_map.get(scope, 0))
+        return max_priority
+
+    return sorted(results, key=get_priority, reverse=True)
+```
+
+## 权限控制
+
+### 读权限
+
+用户可以读取知识,当且仅当:
+- 知识的 `scopes` 与用户的 `visible_scopes` 有交集
+
+### 写权限
+
+用户可以修改/删除知识,当且仅当:
+- 用户是知识的 `owner`
+
+### 特殊情况
+
+- 管理员可以修改/删除所有知识
+- 组织级知识(owner=org:xxx)可以由管理员管理
+
+## 实现位置
+
+- `agent/memory/knowledge_store.py`:知识存储接口
+- `agent/memory/knowledge_retriever.py`:检索逻辑
+- `agent/tools/builtin/knowledge.py`:get_knowledge / save_knowledge 工具
+
+## 扩展性
+
+### 新增 Scope 类型
+
+如需新增 scope 类型(如 `department:{dept_id}`),只需:
+
+1. 在 `build_visible_scopes` 中添加逻辑
+2. 在 `rank_by_scope_priority` 中添加优先级
+3. 无需修改数据结构和存储逻辑
+
+### 升级为 ACL
+
+如需更细粒度的权限控制,可扩展为 ACL 系统:
+
+```json
+{
+  "id": "knowledge_001",
+  "type": "strategy",
+  "owner": "user:123",
+  "acl": [
+    {"entity": "user:123", "permission": "read_write"},
+    {"entity": "project:456", "permission": "read"},
+    {"entity": "agent:general_assistant", "permission": "read"}
+  ]
+}
+```
+
+但对于大多数场景,当前的 scope 标签系统已经足够。
+

+ 234 - 0
agent/docs/skills.md

@@ -0,0 +1,234 @@
+# Skills 使用指南
+
+Skills 是 Agent 的领域知识库,存储在 Markdown 文件中。
+
+---
+
+## Skill 分类
+
+| 类型 | 加载位置 | 加载时机 | 文件位置 |
+|------|---------|---------|---------|
+| **Core Skill** | System Prompt | Agent 启动时自动加载 | `agent/skills/core.md` |
+| **内置 Skill** | 对话消息 | 模型调用 `skill` 工具时 | `agent/skills/{name}/` |
+| **自定义 Skill** | 对话消息 | 模型调用 `skill` 工具时 | `./skills/{name}.md` |
+
+### Core Skill
+
+核心系统功能,每个 Agent 都需要了解:
+
+- Step 管理(计划、执行、进度)
+- 其他系统级功能
+
+**位置**:`agent/skills/core.md`
+
+**加载方式**:
+- 框架自动注入到 System Prompt
+- `load_skills_from_dir()` 总是自动加载 `agent/skills/` 中的所有 skills(包括 `core.md`)
+
+### 内置 Skill
+
+框架提供的特定领域能力,按需加载:
+
+- browser_use(浏览器自动化)
+- 其他领域 skills
+
+**位置**:`agent/skills/{name}/`
+
+**加载方式**:模型调用 `skill` 工具
+
+### 自定义 Skill
+
+项目特定的能力,按需加载:
+
+**位置**:`./skills/{name}.md`
+
+**加载方式**:
+1. 通过 `skills_dir` 参数在 Agent 启动时加载到 System Prompt
+2. 通过 `skill` 工具运行时按需加载
+
+---
+
+## 普通 Skill 文件格式
+
+```markdown
+---
+name: browser-use
+description: 浏览器自动化工具使用指南
+category: web-automation
+scope: agent:*
+---
+
+## When to use
+- 需要访问网页、填写表单
+- 需要截图或提取网页内容
+
+## Guidelines
+- 先运行 `browser-use state` 查看可点击元素
+- 使用元素索引进行交互:`browser-use click 5`
+- 每次操作后验证结果
+```
+
+**Frontmatter 字段**:
+- `name`: Skill 名称(必填)
+- `description`: 简短描述(必填)
+- `category`: 分类(可选,默认 general)
+- `scope`: 作用域(可选,默认 agent:*)
+
+**章节**:
+- `## When to use`: 适用场景列表
+- `## Guidelines`: 指导原则列表
+
+## 使用方法
+
+### 1. 创建 skills 目录
+
+```bash
+mkdir skills
+```
+
+### 2. 添加 skill 文件
+
+创建 `skills/browser-use.md` 文件,按照上述格式编写。
+
+### 3. Agent 调用
+
+**方式 1:自动加载到 System Prompt**
+
+```python
+from agent import AgentRunner
+from agent.llm import create_gemini_llm_call
+import os
+
+# 加载自定义 skills 到 System Prompt
+runner = AgentRunner(
+    llm_call=create_gemini_llm_call(os.getenv("GEMINI_API_KEY")),
+    skills_dir="./skills",  # 可选:加载额外的自定义 skills
+)
+
+# 结果:
+# - agent/skills/core.md 自动加载(总是)
+# - agent/skills/ 中的其他 skills 自动加载
+# - ./skills/ 中的 skills 也会自动加载
+```
+
+**方式 2:运行时动态加载**
+
+```python
+from agent import AgentRunner
+from agent.llm import create_gemini_llm_call
+import os
+
+runner = AgentRunner(
+    llm_call=create_gemini_llm_call(os.getenv("GEMINI_API_KEY"))
+)
+
+async for item in runner.run(
+    task="帮我从网站提取数据",
+    model="gemini-2.0-flash-exp"
+):
+    # Agent 会自动调用 skill 工具加载需要的 skills
+    pass
+```
+
+**Agent 工作流**:
+1. Agent 启动时自动加载 `agent/skills/` 中的所有 skills(包括 `core.md`)
+2. 如果提供了 `skills_dir`,也会加载自定义 skills 到 System Prompt
+3. Agent 接收任务
+4. Agent 可以调用 `list_skills()` 查看可用 skills
+5. Agent 可以调用 `skill(skill_name="browser-use")` 动态加载特定 skill
+6. Skill 内容注入到对话历史
+7. Agent 根据 skill 指导完成任务
+
+### 4. 手动测试
+
+```python
+from agent.tools.builtin.skill import list_skills, skill
+
+# 列出所有 skills
+result = await list_skills()
+print(result.output)
+
+# 加载特定 skill
+result = await skill(skill_name="browser-use")
+print(result.output)
+```
+
+## Skill 加载机制
+
+### 自动加载(Agent 启动时)
+
+```python
+# load_skills_from_dir() 自动加载内置 skills
+runner = AgentRunner(
+    llm_call=my_llm,
+    # 不需要指定 skills_dir,内置 skills 会自动加载
+)
+
+# 结果:agent/skills/ 中的所有 skills 都会被加载到 System Prompt
+```
+
+### 可选加载额外的自定义 Skills
+
+```python
+runner = AgentRunner(
+    llm_call=my_llm,
+    skills_dir="./my-custom-skills",  # 可选:加载额外的自定义 skills
+)
+
+# 结果:agent/skills/ + ./my-custom-skills/ 中的所有 skills 都会被加载
+```
+
+### 动态加载(运行时)
+
+Agent 可以通过 `skill` 工具运行时动态加载特定的 skill:
+
+```python
+# Agent 调用
+skill(skill_name="browser-use")
+
+# 搜索路径(优先级):
+# 1. ./skills/browser-use.md         ← 项目自定义
+# 2. ./agent/skills/browser-use/     ← 框架内置
+```
+
+**实现位置**:
+- `agent/memory/skill_loader.py:load_skills_from_dir()` - 自动加载机制
+- `agent/tools/builtin/skill.py` - skill 工具(动态加载)
+
+详见 [`SKILLS_SYSTEM.md`](../SKILLS_SYSTEM.md)
+
+## Skill 工具 API
+
+### `skill` 工具
+
+加载指定的 skill 文档。
+
+**参数**:
+- `skill_name` (str): Skill 名称,如 "browser-use"
+
+**返回**:Skill 的完整内容(Markdown 格式)
+
+### `list_skills` 工具
+
+列出所有可用的 skills,按 category 分组。
+
+**返回**:Skills 列表,包含名称、ID 和简短描述
+
+**实现位置**:`agent/tools/builtin/skill.py`
+
+## 环境变量
+
+可以设置默认 skills 目录:
+
+```bash
+# .env
+SKILLS_DIR=./skills
+```
+
+## 参考
+
+- 完整文档:[`SKILLS_SYSTEM.md`](../SKILLS_SYSTEM.md)
+- 示例:`examples/feature_extract/run.py`
+- Skill 文件:`agent/skills/` 目录
+- 工具实现:`agent/tools/builtin/skill.py`
+- 加载器实现:`agent/memory/skill_loader.py`

+ 1194 - 0
agent/docs/tools.md

@@ -0,0 +1,1194 @@
+# 工具系统文档
+
+> Agent 框架的工具系统:定义、注册、执行工具调用。
+
+---
+
+## 目录
+
+1. [核心概念](#核心概念)
+2. [定义工具](#定义工具)
+3. [ToolResult 和记忆管理](#toolresult-和记忆管理)
+4. [ToolContext 和依赖注入](#toolcontext-和依赖注入)
+5. [高级特性](#高级特性)
+6. [内置基础工具](#内置基础工具)
+7. [集成 Browser-Use](#集成-browser-use)
+8. [最佳实践](#最佳实践)
+
+---
+
+## 核心概念
+
+### 三个核心类型
+
+```python
+from reson_agent import tool, ToolResult, ToolContext
+
+@tool()
+async def my_tool(arg: str, context: Optional[ToolContext] = None) -> ToolResult:
+    return ToolResult(
+        title="Success",
+        output="Result content"
+    )
+```
+
+| 类型 | 作用 | 定义位置 |
+|------|------|---------|
+| **`@tool`** | 装饰器,自动注册工具并生成 Schema | `tools/registry.py` |
+| **`ToolResult`** | 工具执行结果(支持记忆管理) | `tools/models.py` |
+| **`ToolContext`** | 工具执行上下文(依赖注入) | `tools/models.py` |
+
+### 工具的生命周期
+
+```
+1. 定义工具
+   ↓ @tool() 装饰器
+2. 自动注册到 ToolRegistry
+   ↓ 生成 OpenAI Tool Schema(跳过 hidden_params)
+3. LLM 选择工具并生成参数
+   ↓ registry.execute(name, args)
+4. 注入框架参数(hidden_params + inject_params)
+   ↓ 调用工具函数
+5. 返回 ToolResult
+   ↓ 转换为 LLM 消息
+6. 添加到对话历史
+```
+
+### 参数注入机制
+
+工具参数分为三类:
+
+1. **业务参数**:LLM 可见,由 LLM 填写(如 `query`, `limit`)
+2. **隐藏参数**:LLM 不可见,框架自动注入(如 `context`, `uid`)
+3. **注入参数**:LLM 可见但有默认值,框架自动注入默认值(如 `owner`, `tags`)
+
+```python
+@tool(
+    hidden_params=["context", "uid"],  # 不生成 schema,LLM 看不到
+    inject_params={                     # 自动注入默认值
+        "owner": lambda ctx: ctx.config.knowledge.get_owner(),
+        "tags": lambda ctx, args: {**ctx.config.default_tags, **args.get("tags", {})},
+    }
+)
+async def knowledge_save(
+    task: str,                          # 业务参数:LLM 填写
+    content: str,                       # 业务参数:LLM 填写
+    types: List[str],                   # 业务参数:LLM 填写
+    tags: Optional[Dict] = None,        # 注入参数:LLM 可填,框架提供默认值
+    owner: Optional[str] = None,        # 注入参数:LLM 可填,框架提供默认值
+    context: Optional[ToolContext] = None,  # 隐藏参数:LLM 看不到
+    uid: str = "",                      # 隐藏参数:LLM 看不到
+) -> ToolResult:
+    """保存知识到知识库"""
+    ...
+```
+
+**注入时机**:
+- Schema 生成时:跳过 `hidden_params`,不暴露给 LLM
+- 工具执行前:注入 `hidden_params` 和 `inject_params` 的默认值
+
+**实现位置**:
+- Schema 生成:`agent/tools/schema.py:SchemaGenerator.generate()`
+- 参数注入:`agent/tools/registry.py:ToolRegistry.execute()`
+
+---
+
+## 定义工具
+
+### 最简形式
+
+```python
+from reson_agent import tool
+
+@tool()
+async def hello(name: str) -> str:
+    """向用户问好"""
+    return f"Hello, {name}!"
+```
+
+**要点**:
+- 可以是同步或异步函数
+- 返回值自动序列化为 JSON
+- 所有参数默认对 LLM 可见
+
+### 带框架参数
+
+```python
+@tool(hidden_params=["context", "uid"])
+async def search_notes(
+    query: str,
+    limit: int = 10,
+    context: Optional[ToolContext] = None,
+    uid: str = ""
+) -> str:
+    """
+    搜索笔记
+
+    Args:
+        query: 搜索关键词
+        limit: 返回结果数量
+    """
+    # context 和 uid 由框架注入,LLM 看不到这两个参数
+    ...
+```
+
+### 带参数注入
+
+```python
+@tool(
+    hidden_params=["context"],
+    inject_params={
+        "owner": lambda ctx: ctx.config.knowledge.get_owner(),
+        "tags": lambda ctx, args: {**ctx.config.default_tags, **args.get("tags", {})},
+    }
+)
+async def knowledge_save(
+    task: str,
+    content: str,
+    types: List[str],
+    tags: Optional[Dict] = None,  # LLM 可填,框架提供默认值
+    owner: Optional[str] = None,  # LLM 可填,框架提供默认值
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    保存知识
+
+    Args:
+        task: 任务描述
+        content: 知识内容
+        types: 知识类型
+        tags: 业务标签(可选,有默认值)
+        owner: 所有者(可选,有默认值)
+    """
+    # owner 和 tags 如果 LLM 未提供,框架会注入默认值
+    ...
+```
+
+**注入规则**:
+- `inject_params` 的 value 可以是:
+  - `lambda ctx: ...` - 从 context 计算
+  - `lambda ctx, args: ...` - 从 context 和已有参数计算
+  - 字符串 - 直接使用该值
+- 注入时机:工具执行前,使用 `setdefault` 注入(不覆盖 LLM 提供的值)
+
+### 带 UI 元数据
+
+```python
+@tool(
+    display={
+        "zh": {
+            "name": "搜索笔记",
+            "params": {
+                "query": "搜索关键词",
+                "limit": "结果数量"
+            }
+        },
+        "en": {
+            "name": "Search Notes",
+            "params": {
+                "query": "Search query",
+                "limit": "Result limit"
+            }
+        }
+    }
+)
+async def search_notes(query: str, limit: int = 10, uid: str = "") -> str:
+    """搜索用户的笔记"""
+    ...
+```
+
+---
+
+## ToolResult 和记忆管理
+
+### 基础用法
+
+```python
+from reson_agent import ToolResult
+
+@tool()
+async def read_file(path: str) -> ToolResult:
+    content = Path(path).read_text()
+
+    return ToolResult(
+        title=f"Read {path}",
+        output=content
+    )
+```
+
+### 双层记忆管理
+
+**问题**:某些工具返回大量内容(如 Browser-Use 的 `extract`),如果每次都放入对话历史,会快速耗尽 context。
+
+**解决**:`ToolResult` 支持双层记忆:
+
+```python
+@tool()
+async def extract_page_data(url: str) -> ToolResult:
+    # 假设提取了 10K tokens 的内容
+    full_content = extract_all_data(url)
+
+    return ToolResult(
+        title="Extracted page data",
+        output=full_content,  # 完整内容(可能很长)
+        long_term_memory=f"Extracted {len(full_content)} chars from {url}",  # 简短摘要
+        include_output_only_once=True  # output 只给 LLM 看一次
+    )
+```
+
+**效果**:
+- **第一次**:LLM 看到 `output`(完整内容)+ `long_term_memory`(摘要)
+- **后续**:LLM 只看到 `long_term_memory`(摘要)
+
+**对话历史示例**:
+
+```
+[User] 提取 amazon.com 的商品价格
+[Assistant] 调用 extract_page_data(url="amazon.com")
+[Tool]
+# Extracted page data
+
+<完整的 10K tokens 数据...>
+
+Summary: Extracted 10000 chars from amazon.com
+
+[User] 现在保存到文件
+[Assistant] 调用 write_file(content="...")
+[Tool] (此时不再包含 10K tokens,只有摘要)
+Summary: Extracted 10000 chars from amazon.com
+```
+
+### 错误处理
+
+```python
+@tool()
+async def risky_operation() -> ToolResult:
+    try:
+        result = perform_operation()
+        return ToolResult(
+            title="Success",
+            output=result
+        )
+    except Exception as e:
+        return ToolResult(
+            title="Failed",
+            output="",
+            error=str(e)
+        )
+```
+
+### 附件和图片
+
+```python
+@tool()
+async def generate_report() -> ToolResult:
+    report_path = create_pdf_report()
+    screenshot_data = take_screenshot()
+
+    return ToolResult(
+        title="Report generated",
+        output="Report created successfully",
+        attachments=[report_path],  # 文件路径列表
+        images=[{
+            "name": "screenshot.png",
+            "data": screenshot_data  # Base64 或路径
+        }]
+    )
+```
+
+---
+
+## ToolContext 和依赖注入
+
+### 基本概念
+
+工具函数可以声明需要 `ToolContext` 参数,框架自动注入。需要在 `@tool()` 装饰器中声明 `hidden_params=["context"]`,使其对 LLM 不可见。
+
+```python
+from reson_agent import ToolContext
+
+@tool(hidden_params=["context"])
+async def get_current_state(context: Optional[ToolContext] = None) -> ToolResult:
+    return ToolResult(
+        title="Current state",
+        output=f"Trace ID: {context.trace_id}\nStep ID: {context.step_id}"
+    )
+```
+
+### ToolContext 字段
+
+```python
+class ToolContext(Protocol):
+    # 基础字段(所有工具)
+    trace_id: str               # 当前 Trace ID
+    step_id: str                # 当前 Step ID
+    uid: Optional[str]          # 用户 ID
+
+    # 扩展字段(由 runner 注入)
+    store: Optional[TraceStore]     # Trace 存储
+    runner: Optional[AgentRunner]   # Runner 实例
+    goal_tree: Optional[GoalTree]   # 目标树
+    goal_id: Optional[str]          # 当前 Goal ID
+    config: Optional[RunConfig]     # 运行配置
+
+    # 浏览器相关(Browser-Use 集成)
+    browser_session: Optional[Any]      # 浏览器会话
+    page_url: Optional[str]             # 当前页面 URL
+    file_system: Optional[Any]          # 文件系统访问
+    sensitive_data: Optional[Dict]      # 敏感数据
+
+    # 额外上下文
+    context: Optional[Dict[str, Any]]   # 额外上下文数据
+```
+
+### 使用示例
+
+```python
+@tool(hidden_params=["context"])
+async def analyze_current_page(context: Optional[ToolContext] = None) -> ToolResult:
+    """分析当前浏览器页面"""
+
+    if not context or not context.browser_session:
+        return ToolResult(
+            title="Error",
+            error="Browser session not available"
+        )
+
+    # 使用浏览器会话
+    page_content = await context.browser_session.get_content()
+
+    return ToolResult(
+        title=f"Analyzed {context.page_url}",
+        output=page_content,
+        long_term_memory=f"Analyzed page at {context.page_url}"
+    )
+```
+
+### 创建 ToolContext
+
+Runner 在执行工具时自动创建并注入 context:
+
+```python
+# 在 AgentRunner._agent_loop 中
+context = {
+    "store": self.trace_store,
+    "trace_id": trace_id,
+    "goal_id": current_goal_id,
+    "runner": self,
+    "goal_tree": goal_tree,
+    "config": config,
+}
+
+result = await self.tools.execute(
+    tool_name,
+    tool_args,
+    uid=config.uid or "",
+    context=context
+)
+```
+
+---
+
+## 高级特性
+
+### 1. 需要用户确认
+
+```python
+@tool(requires_confirmation=True)
+async def delete_all_notes(uid: str = "") -> ToolResult:
+    """删除所有笔记(危险操作)"""
+    # 执行前会等待用户确认
+    ...
+```
+
+**适用场景**:
+- 删除操作
+- 发送消息
+- 修改重要设置
+- 任何不可逆操作
+
+### 2. 可编辑参数
+
+```python
+@tool(editable_params=["query", "filters"])
+async def advanced_search(
+    query: str,
+    filters: Optional[Dict] = None,
+    uid: str = ""
+) -> ToolResult:
+    """高级搜索"""
+    # LLM 生成参数后,用户可以编辑 query 和 filters
+    ...
+```
+
+**适用场景**:
+- 搜索查询
+- 内容创建
+- 需要用户微调的参数
+
+### 3. 域名过滤(URL Patterns)
+
+**场景**:某些工具只在特定网站可用,减少无关工具的 context 占用。
+
+```python
+@tool(url_patterns=["*.google.com", "www.google.*"])
+async def google_advanced_search(
+    query: str,
+    date_range: Optional[str] = None,
+    uid: str = ""
+) -> ToolResult:
+    """Google 高级搜索技巧(仅在 Google 页面可用)"""
+    ...
+
+@tool(url_patterns=["*.github.com"])
+async def github_pr_create(
+    title: str,
+    body: str,
+    uid: str = ""
+) -> ToolResult:
+    """创建 GitHub PR(仅在 GitHub 页面可用)"""
+    ...
+
+@tool()  # 无 url_patterns,所有页面都可用
+async def take_screenshot() -> ToolResult:
+    """截图(所有页面都可用)"""
+    ...
+```
+
+**支持的模式**:
+
+```python
+# 通配符域名
+"*.google.com"        # 匹配 www.google.com, mail.google.com
+"www.google.*"        # 匹配 www.google.com, www.google.co.uk
+
+# 路径匹配
+"https://github.com/**/issues"  # 匹配所有 issues 页面
+
+# 多个模式
+url_patterns=["*.github.com", "*.gitlab.com"]
+```
+
+**使用过滤后的工具**:
+
+```python
+from reson_agent import get_tool_registry
+
+registry = get_tool_registry()
+
+# 根据 URL 获取可用工具
+current_url = "https://www.google.com/search?q=test"
+tool_names = registry.get_tool_names(current_url)
+# 返回:["google_advanced_search", "take_screenshot"](不包含 github_pr_create)
+
+# 获取过滤后的 Schema
+schemas = registry.get_schemas_for_url(current_url)
+# 传递给 LLM,只包含相关工具
+```
+
+**效果**:
+
+| 场景 | 无过滤 | 有过滤 | 节省 |
+|------|--------|--------|------|
+| 在 Google 页面 | 35 工具 (~5K tokens) | 20 工具 (~3K tokens) | 40% |
+| 在 GitHub 页面 | 35 工具 (~5K tokens) | 18 工具 (~2.5K tokens) | 50% |
+
+### 4. 敏感数据处理
+
+**场景**:浏览器自动化需要输入密码、Token,但不想在对话历史中显示明文。
+
+**设置敏感数据**:
+
+```python
+sensitive_data = {
+    # 格式 1:全局密钥(适用于所有域名)
+    "api_key": "sk-xxxxx",
+
+    # 格式 2:域名特定密钥(推荐)
+    "*.github.com": {
+        "github_token": "ghp_xxxxx",
+        "github_password": "my_secret_password"
+    },
+
+    "*.google.com": {
+        "google_email": "user@example.com",
+        "google_password": "another_secret",
+        "google_2fa_bu_2fa_code": "JBSWY3DPEHPK3PXP"  # TOTP secret
+    }
+}
+```
+
+**LLM 输出占位符**:
+
+```python
+# LLM 决定需要输入密码
+{
+    "tool": "browser_input",
+    "arguments": {
+        "index": 5,
+        "text": "<secret>github_password</secret>"  # 占位符
+    }
+}
+```
+
+**自动替换**:
+
+```python
+# 执行工具前,框架自动替换
+registry.execute(
+    "browser_input",
+    arguments={"index": 5, "text": "<secret>github_password</secret>"},
+    context={"page_url": "https://github.com/login"},
+    sensitive_data=sensitive_data
+)
+
+# 实际执行:
+# arguments = {"index": 5, "text": "my_secret_password"}
+```
+
+**TOTP 2FA 支持**:
+
+```python
+# 密钥以 _bu_2fa_code 结尾,自动生成 TOTP 代码
+sensitive_data = {
+    "*.google.com": {
+        "google_2fa_bu_2fa_code": "JBSWY3DPEHPK3PXP"
+    }
+}
+
+# LLM 输出
+{
+    "text": "<secret>google_2fa_bu_2fa_code</secret>"
+}
+
+# 自动替换为当前的 6 位数字验证码
+{
+    "text": "123456"  # 当前时间的 TOTP 代码
+}
+```
+
+**完整示例**:
+
+```python
+from reson_agent import get_tool_registry, ToolContext
+
+# 设置敏感数据
+sensitive_data = {
+    "*.github.com": {
+        "github_token": "ghp_xxxxxxxxxxxxx",
+        "github_2fa_bu_2fa_code": "JBSWY3DPEHPK3PXP"
+    }
+}
+
+# 执行工具(LLM 输出的参数包含占位符)
+result = await registry.execute(
+    "github_api_call",
+    arguments={
+        "endpoint": "/user",
+        "token": "<secret>github_token</secret>",
+        "totp": "<secret>github_2fa_bu_2fa_code</secret>"
+    },
+    context={"page_url": "https://github.com"},
+    sensitive_data=sensitive_data
+)
+
+# 实际调用时参数已被替换:
+# {
+#     "endpoint": "/user",
+#     "token": "ghp_xxxxxxxxxxxxx",
+#     "totp": "123456"
+# }
+```
+
+**安全性**:
+- ✅ 对话历史中只有 `<secret>key</secret>` 占位符
+- ✅ 实际密码仅在执行时注入
+- ✅ 域名匹配防止密钥泄露到错误的网站
+- ✅ TOTP 验证码实时生成,无需手动输入
+
+### 5. 工具使用统计
+
+**自动记录**:
+
+每个工具调用自动记录:
+- 调用次数
+- 成功/失败次数
+- 平均执行时间
+- 最后调用时间
+
+**查询统计**:
+
+```python
+from reson_agent import get_tool_registry
+
+registry = get_tool_registry()
+
+# 获取所有工具统计
+all_stats = registry.get_stats()
+print(all_stats)
+# {
+#     "search_notes": {
+#         "call_count": 145,
+#         "success_count": 142,
+#         "failure_count": 3,
+#         "average_duration": 0.32,
+#         "success_rate": 0.979,
+#         "last_called": 1704123456.78
+#     },
+#     ...
+# }
+
+# 获取单个工具统计
+search_stats = registry.get_stats("search_notes")
+
+# 获取 Top 工具
+top_tools = registry.get_top_tools(limit=5, by="call_count")
+# ['search_notes', 'read_file', 'browser_click', ...]
+
+top_by_success = registry.get_top_tools(limit=5, by="success_rate")
+fastest_tools = registry.get_top_tools(limit=5, by="average_duration")
+```
+
+**优化工具排序**:
+
+```python
+# 根据使用频率优化工具顺序
+def get_optimized_schemas(registry, current_url):
+    # 获取可用工具
+    tool_names = registry.get_tool_names(current_url)
+
+    # 按调用次数排序(高频工具排前面)
+    all_stats = registry.get_stats()
+    sorted_tools = sorted(
+        tool_names,
+        key=lambda name: all_stats.get(name, {}).get("call_count", 0),
+        reverse=True
+    )
+
+    # 返回排序后的 Schema
+    return registry.get_schemas(sorted_tools)
+```
+
+**监控和告警**:
+
+```python
+# 监控工具失败率
+stats = registry.get_stats()
+for tool_name, tool_stats in stats.items():
+    if tool_stats["call_count"] > 10 and tool_stats["success_rate"] < 0.8:
+        logger.warning(
+            f"Tool {tool_name} has low success rate: "
+            f"{tool_stats['success_rate']:.1%} "
+            f"({tool_stats['failure_count']}/{tool_stats['call_count']} failures)"
+        )
+
+# 监控执行时间
+for tool_name, tool_stats in stats.items():
+    if tool_stats["average_duration"] > 5.0:
+        logger.warning(
+            f"Tool {tool_name} is slow: "
+            f"average {tool_stats['average_duration']:.2f}s"
+        )
+```
+
+### 6. 组合使用
+
+**完整示例:浏览器自动化工具**
+
+```python
+@tool(
+    requires_confirmation=False,
+    editable_params=["query"],
+    url_patterns=["*.google.com"],
+    display={
+        "zh": {"name": "Google 搜索", "params": {"query": "搜索关键词"}},
+        "en": {"name": "Google Search", "params": {"query": "Query"}}
+    }
+)
+async def google_search(
+    query: str,
+    ctx: ToolContext,
+    uid: str = ""
+) -> ToolResult:
+    """
+    在 Google 执行搜索
+
+    仅在 Google 页面可用,支持敏感数据注入
+    """
+    # 使用浏览器会话
+    if not ctx.browser_session:
+        return ToolResult(title="Error", error="Browser session not available")
+
+    # 敏感数据已在 registry.execute() 中自动处理
+    # 例如 query 中的 <secret>api_key</secret> 已被替换
+
+    # 执行搜索
+    await ctx.browser_session.navigate(f"https://google.com/search?q={query}")
+
+    # 提取结果
+    results = await ctx.browser_session.extract_results()
+
+    return ToolResult(
+        title=f"Search results for {query}",
+        output=json.dumps(results),
+        long_term_memory=f"Searched Google for '{query}', found {len(results)} results",
+        include_output_only_once=True
+    )
+```
+
+**使用**:
+
+```python
+# 设置环境
+registry = get_tool_registry()
+sensitive_data = {"*.google.com": {"search_api_key": "sk-xxxxx"}}
+
+# Agent 在 Google 页面时
+current_url = "https://www.google.com"
+
+# 获取工具(自动过滤)
+tool_names = registry.get_tool_names(current_url)
+# 包含 google_search(匹配 *.google.com)
+
+# LLM 决定使用工具(可能包含敏感占位符)
+tool_call = {
+    "name": "google_search",
+    "arguments": {
+        "query": "site:github.com <secret>search_api_key</secret>"
+    }
+}
+
+# 执行(自动替换敏感数据)
+result = await registry.execute(
+    tool_call["name"],
+    tool_call["arguments"],
+    context={"page_url": current_url, "browser_session": browser},
+    sensitive_data=sensitive_data
+)
+
+# 查看统计
+stats = registry.get_stats("google_search")
+print(f"Success rate: {stats['success_rate']:.1%}")
+```
+
+---
+
+## 内置基础工具
+
+> 参考 opencode 实现的文件操作和命令执行工具
+
+框架提供一组内置的基础工具,用于文件读取、编辑、搜索和命令执行等常见任务。这些工具参考了 [opencode](https://github.com/anomalyco/opencode) 的成熟设计,在 Python 中重新实现。
+
+**实现位置**:
+- 工具实现:`agent/tools/builtin/`
+- 适配器层:`agent/tools/adapters/`
+- OpenCode 参考:`vendor/opencode/` (git submodule)
+
+**详细文档**:参考 [`docs/tools-adapters.md`](./tools-adapters.md)
+
+### 可用工具
+
+| 工具 | 功能 | 参考 |
+|------|------|------|
+| `read_file` | 读取文件内容(支持图片、PDF) | opencode read.ts |
+| `edit_file` | 智能文件编辑(多种匹配策略) | opencode edit.ts |
+| `write_file` | 写入文件(创建或覆盖) | opencode write.ts |
+| `bash_command` | 执行 shell 命令 | opencode bash.ts |
+| `glob_files` | 文件模式匹配 | opencode glob.ts |
+| `grep_content` | 内容搜索(正则表达式) | opencode grep.ts |
+| `agent` | 创建 Agent 执行任务(单任务 delegate / 多任务并行 explore) | 自研 |
+| `evaluate` | 评估目标执行结果是否满足要求 | 自研 |
+
+### Agent 工具
+
+创建子 Agent 执行任务。通过 `task` 参数的类型自动区分模式:
+
+| task 类型 | 模式 | 并行执行 | 工具权限 |
+|-----------|------|---------|---------|
+| `str`(单任务) | delegate | ❌ | 完整(除 agent/evaluate 外) |
+| `List[str]`(多任务) | explore | ✅ | 只读(read_file, grep_content, glob_files, goal) |
+
+```python
+@tool(description="创建 Agent 执行任务")
+async def agent(
+    task: Union[str, List[str]],
+    messages: Optional[Union[Messages, List[Messages]]] = None,
+    continue_from: Optional[str] = None,
+    context: Optional[dict] = None,
+) -> Dict[str, Any]:
+```
+
+**messages 参数**:
+- `None`:无预置消息
+- `Messages`(1D 列表):所有 agent 共享
+- `List[Messages]`(2D 列表):per-agent 独立消息
+
+运行时判断:`messages[0]` 是 dict → 1D 共享;是 list → 2D per-agent。
+
+**单任务(delegate)**:
+- 适合委托专门任务(如代码分析、文档生成)
+- 完整工具权限,可执行复杂操作
+- 支持 `continue_from` 参数续跑已有 Sub-Trace
+
+**多任务(explore)**:
+- 适合对比多个方案(如技术选型、架构设计)
+- 使用 `asyncio.gather()` 并行执行,显著提升效率
+- 每个任务创建独立的 Sub-Trace,互不干扰
+- 只读权限(文件系统层面),可使用 goal 工具管理计划
+- 不支持 `continue_from`
+
+### Evaluate 工具
+
+评估指定 Goal 的执行结果,提供质量评估和改进建议。
+
+```python
+@tool(description="评估目标执行结果是否满足要求")
+async def evaluate(
+    messages: Optional[Messages] = None,
+    target_goal_id: Optional[str] = None,
+    continue_from: Optional[str] = None,
+    context: Optional[dict] = None,
+) -> Dict[str, Any]:
+```
+
+- 无 `criteria` 参数——代码自动从 GoalTree 注入目标描述
+- 模型把执行结果和上下文放在 `messages` 中
+- `target_goal_id` 默认为当前 `goal_id`
+- 只读工具权限
+- 返回评估结论和改进建议
+
+**Sub-Trace 结构**:
+- 每个 `agent`/`evaluate` 调用创建独立的 Sub-Trace
+- Sub-Trace ID 格式:`{parent_id}@{mode}-{序号}-{timestamp}-001`
+- 通过 `parent_trace_id` 和 `parent_goal_id` 建立父子关系
+- Sub-Trace 信息存储在独立的 trace 目录中
+
+**Goal 集成**:
+- `agent`/`evaluate` 调用会将 Goal 标记为 `type: "agent_call"`
+- `agent_call_mode` 记录使用的模式
+- `sub_trace_ids` 记录所有创建的 Sub-Trace
+- Goal 完成后,`summary` 包含格式化的汇总结果
+
+**实现位置**:`agent/tools/builtin/subagent.py`
+
+### 快速使用
+
+```python
+from agent.tools.builtin import read_file, edit_file, bash_command
+
+# 读取文件
+result = await read_file(file_path="config.py", limit=100)
+print(result.output)
+
+# 编辑文件(智能匹配)
+result = await edit_file(
+    file_path="config.py",
+    old_string="DEBUG = True",
+    new_string="DEBUG = False"
+)
+
+# 执行命令
+result = await bash_command(
+    command="git status",
+    timeout=30,
+    description="Check git status"
+)
+```
+
+### 核心特性
+
+**Read Tool 特性**:
+- 二进制文件检测
+- 分页读取(offset/limit)
+- 行长度和字节限制
+- 图片/PDF 支持
+
+**Edit Tool 特性**:
+- 多种智能匹配策略:
+  - SimpleReplacer - 精确匹配
+  - LineTrimmedReplacer - 忽略行首尾空白
+  - WhitespaceNormalizedReplacer - 空白归一化
+- 自动生成 unified diff
+- 唯一性检查(防止错误替换)
+
+**Bash Tool 特性**:
+- 异步执行
+- 超时控制(默认 120 秒)
+- 工作目录设置
+- 输出截断(防止过长)
+
+### 更新 OpenCode 参考
+
+内置工具参考 `vendor/opencode/` 中的实现,通过 git submodule 管理:
+
+```bash
+# 更新 opencode 参考
+cd vendor/opencode
+git pull origin main
+cd ../..
+git add vendor/opencode
+git commit -m "chore: update opencode reference"
+
+# 查看最近变更
+cd vendor/opencode
+git log --oneline --since="1 month ago" -- packages/opencode/src/tool/
+```
+
+更新后,检查是否需要同步改进到 Python 实现。
+
+---
+
+## 集成 Browser-Use
+
+### 适配器模式
+
+将 Browser-Use 的 25 个工具适配为你的工具系统:
+
+```python
+from browser_use import BrowserSession, Tools as BrowserUseTools
+from reson_agent import tool, ToolResult, ToolContext
+
+class BrowserToolsAdapter:
+    """Browser-Use 工具适配器"""
+
+    def __init__(self):
+        self.session = BrowserSession(headless=False)
+        self.browser_tools = BrowserUseTools()
+
+    async def __aenter__(self):
+        await self.session.__aenter__()
+        return self
+
+    async def __aexit__(self, *args):
+        await self.session.__aexit__(*args)
+
+    def register_all(self, registry):
+        """批量注册所有 Browser-Use 工具"""
+        for action_name, registered_action in self.browser_tools.registry.actions.items():
+            self._adapt_action(registry, action_name, registered_action)
+
+    def _adapt_action(self, registry, action_name, registered_action):
+        """适配单个 Browser-Use action"""
+
+        @tool()
+        async def adapted_tool(args: dict, ctx: ToolContext) -> ToolResult:
+            # 构建 Browser-Use 需要的 special context
+            special_context = {
+                'browser_session': self.session,
+                'page_url': ctx.page_url,
+                'file_system': ctx.file_system,
+            }
+
+            # 执行 Browser-Use action
+            result = await registered_action.function(
+                params=registered_action.param_model(**args),
+                **special_context
+            )
+
+            # 转换 ActionResult -> ToolResult
+            return ToolResult(
+                title=action_name,
+                output=result.extracted_content or '',
+                long_term_memory=result.long_term_memory,
+                include_output_only_once=result.include_extracted_content_only_once,
+                error=result.error,
+                attachments=result.attachments or [],
+                images=result.images or [],
+                metadata=result.metadata or {}
+            )
+
+        # 注册到你的 registry
+        registry.register(adapted_tool, schema=generate_schema(registered_action))
+```
+
+### 使用示例
+
+```python
+from reson_agent import AgentRunner
+
+async def main():
+    async with BrowserToolsAdapter() as browser:
+        # 创建 Agent
+        agent = AgentRunner(
+            task="在 Amazon 找最便宜的 iPhone 15",
+            tools=[],  # 空列表
+        )
+
+        # 批量注册浏览器工具
+        browser.register_all(agent.tool_registry)
+
+        # 现在 Agent 有 25 个浏览器工具 + 其他工具
+        result = await agent.run()
+```
+
+### Context 占用分析
+
+| 工具类型 | 数量 | Token 占用 | 占比(200K) |
+|---------|------|-----------|-------------|
+| Browser-Use 工具 | 25 | ~4,000 | 2% |
+| 你的自定义工具 | 10 | ~1,000 | 0.5% |
+| **总计** | **35** | **~5,000** | **2.5%** |
+
+**结论**:完全可接受,且 Prompt Caching 会优化后续调用。
+
+---
+
+## 最佳实践
+
+### 1. 工具命名
+
+```python
+# 好:清晰的动词 + 名词
+@tool()
+async def search_notes(...): ...
+
+@tool()
+async def create_document(...): ...
+
+# 不好:模糊或过长
+@tool()
+async def do_something(...): ...
+
+@tool()
+async def search_and_filter_notes_with_advanced_options(...): ...
+```
+
+### 2. 返回结构化数据
+
+```python
+# 好:返回 ToolResult 或结构化字典
+@tool()
+async def get_weather(city: str) -> ToolResult:
+    data = fetch_weather(city)
+    return ToolResult(
+        title=f"Weather in {city}",
+        output=json.dumps(data, indent=2)
+    )
+
+# 不好:返回纯文本
+@tool()
+async def get_weather(city: str) -> str:
+    return "The weather is sunny, 25°C, humidity 60%"  # 难以解析
+```
+
+### 3. 错误处理
+
+```python
+# 好:捕获异常并返回 ToolResult
+@tool()
+async def risky_operation() -> ToolResult:
+    try:
+        result = dangerous_call()
+        return ToolResult(title="Success", output=result)
+    except Exception as e:
+        logger.error(f"Operation failed: {e}")
+        return ToolResult(title="Failed", error=str(e))
+
+# 不好:让异常传播(会中断 Agent 循环)
+@tool()
+async def risky_operation() -> str:
+    return dangerous_call()  # 可能抛出异常
+```
+
+### 4. 记忆管理
+
+```python
+# 好:大量数据用 include_output_only_once
+@tool()
+async def fetch_all_logs() -> ToolResult:
+    logs = get_last_10000_logs()  # 很大
+    return ToolResult(
+        title="Fetched logs",
+        output=logs,
+        long_term_memory=f"Fetched {len(logs)} log entries",
+        include_output_only_once=True  # 只给 LLM 看一次
+    )
+
+# 不好:大量数据每次都传给 LLM
+@tool()
+async def fetch_all_logs() -> str:
+    return get_last_10000_logs()  # 每次都占用 context
+```
+
+### 5. 工具粒度
+
+```python
+# 好:单一职责,细粒度
+@tool()
+async def search_notes(query: str) -> ToolResult: ...
+
+@tool()
+async def get_note_detail(note_id: str) -> ToolResult: ...
+
+@tool()
+async def update_note(note_id: str, content: str) -> ToolResult: ...
+
+# 不好:功能过多,难以使用
+@tool()
+async def manage_notes(
+    action: Literal["search", "get", "update", "delete"],
+    query: Optional[str] = None,
+    note_id: Optional[str] = None,
+    content: Optional[str] = None
+) -> ToolResult:
+    # 太复杂,LLM 容易用错
+    ...
+```
+
+### 6. 文档和示例
+
+```python
+@tool()
+async def search_notes(
+    query: str,
+    filters: Optional[Dict[str, Any]] = None,
+    sort_by: str = "relevance",
+    limit: int = 10,
+    uid: str = ""
+) -> ToolResult:
+    """
+    搜索用户的笔记
+
+    使用语义搜索查找相关笔记,支持过滤和排序。
+
+    Args:
+        query: 搜索关键词(必需)
+        filters: 过滤条件,例如 {"type": "markdown", "tags": ["work"]}
+        sort_by: 排序方式,可选 "relevance" | "date" | "title"
+        limit: 返回结果数量,默认 10,最大 100
+
+    Returns:
+        ToolResult 包含搜索结果列表
+
+    Example:
+        搜索包含 "项目计划" 的工作笔记:
+        {
+            "query": "项目计划",
+            "filters": {"tags": ["work"]},
+            "limit": 5
+        }
+    """
+    ...
+```
+
+---
+
+## 总结
+
+| 特性 | 状态 | 说明 |
+|------|------|------|
+| **基础注册** | ✅ 已实现 | `@tool()` 装饰器 |
+| **Schema 生成** | ✅ 已实现 | 自动从函数签名生成 |
+| **双层记忆** | ✅ 已实现 | `ToolResult` 支持 long_term_memory |
+| **依赖注入** | ✅ 已实现 | `ToolContext` 提供上下文 |
+| **UI 元数据** | ✅ 已实现 | `display`, `requires_confirmation`, `editable_params` |
+| **域名过滤** | ✅ **已实现** | `url_patterns` 参数 + URL 匹配器 |
+| **敏感数据** | ✅ **已实现** | `<secret>` 占位符 + TOTP 支持 |
+| **工具统计** | ✅ **已实现** | 自动记录调用次数、成功率、执行时间 |
+
+**核心设计原则**:
+1. **简单优先**:最简工具只需要一个装饰器
+2. **按需扩展**:高级特性可选
+3. **类型安全**:充分利用 Python 类型注解
+4. **灵活集成**:支持各种工具库(Browser-Use, MCP 等)
+5. **可观测性**:内建统计和监控能力

+ 414 - 0
agent/docs/trace-api.md

@@ -0,0 +1,414 @@
+# Trace 模块 - 执行记录存储
+
+> 执行轨迹记录和存储的后端实现
+
+---
+
+## 架构概览
+
+**职责定位**:`agent/trace` 模块负责所有 Trace/Message 相关功能
+
+```
+agent/trace/
+├── models.py          # Trace/Message 数据模型
+├── goal_models.py     # Goal/GoalTree 数据模型
+├── protocols.py       # TraceStore 存储接口
+├── store.py           # 文件系统存储实现
+├── trace_id.py        # Trace ID 生成工具
+├── api.py             # RESTful 查询 API
+├── run_api.py         # 控制 API(run/stop/reflect)
+├── websocket.py       # WebSocket 实时推送
+├── goal_tool.py       # goal 工具(计划管理)
+└── compaction.py      # Context 压缩
+```
+
+**设计原则**:
+- **高内聚**:所有 Trace 相关代码在一个模块
+- **松耦合**:核心模型不依赖 FastAPI
+- **可扩展**:易于添加 PostgreSQL 等存储实现
+- **统一模型**:主 Agent 和 Sub-Agent 使用相同的 Trace 结构
+
+---
+
+## 核心模型
+
+### Trace - 执行轨迹
+
+一次完整的 LLM 交互(单次调用或 Agent 任务)。每个 Sub-Agent 都是独立的 Trace。
+
+```python
+# 主 Trace
+main_trace = Trace.create(mode="agent", task="探索代码库")
+
+# Sub-Trace(由 delegate 或 explore 工具创建)
+sub_trace = Trace(
+    trace_id="2f8d3a1c...@explore-20260204220012-001",
+    mode="agent",
+    task="探索 JWT 认证方案",
+    parent_trace_id="2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d",
+    parent_goal_id="3",
+    agent_type="explore",
+    status="running"
+)
+
+# 字段说明
+trace.trace_id        # UUID(主 Trace)或 {parent}@{mode}-{timestamp}-{seq}(Sub-Trace)
+trace.mode            # "call" | "agent"
+trace.task            # 任务描述
+trace.parent_trace_id # 父 Trace ID(Sub-Trace 专用)
+trace.parent_goal_id  # 触发的父 Goal ID(Sub-Trace 专用)
+trace.agent_type      # Agent 类型:explore, delegate 等
+trace.status          # "running" | "completed" | "failed" | "stopped"
+trace.total_messages  # Message 总数
+trace.total_tokens    # Token 总数
+trace.total_cost      # 总成本
+trace.current_goal_id # 当前焦点 goal
+trace.head_sequence   # 当前主路径头节点 sequence(用于 build_llm_messages)
+```
+
+**Trace ID 格式**:
+- **主 Trace**:标准 UUID,例如 `2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d`
+- **Sub-Trace**:`{parent_uuid}@{mode}-{timestamp}-{seq}`,例如 `2f8d3a1c...@explore-20260204220012-001`
+
+**实现**:`agent/trace/models.py:Trace`
+
+### Message - 执行消息
+
+对应 LLM API 消息,加上元数据。通过 `goal_id` 关联 GoalTree 中的目标。通过 `parent_sequence` 形成消息树。
+
+```python
+# assistant 消息(模型返回,可能含 text + tool_calls)
+assistant_msg = Message.create(
+    trace_id=trace.trace_id,
+    role="assistant",
+    goal_id="3",                    # Goal ID(Trace 内部自增)
+    content={"text": "...", "tool_calls": [...]},
+    parent_sequence=5,              # 父消息的 sequence
+)
+
+# tool 消息
+tool_msg = Message.create(
+    trace_id=trace.trace_id,
+    role="tool",
+    goal_id="5",
+    tool_call_id="call_abc123",
+    content="工具执行结果",
+    parent_sequence=6,
+)
+```
+
+**parent_sequence**:指向父消息的 sequence,构成消息树。主路径 = 从 `trace.head_sequence` 沿 parent chain 回溯到 root。
+
+**description 字段**(系统自动生成):
+- `assistant` 消息:优先取 content 中的 text,若无 text 则生成 "tool call: XX, XX"
+- `tool` 消息:使用 tool name
+
+**实现**:`agent/trace/models.py:Message`
+
+---
+
+## 存储接口
+
+### TraceStore Protocol
+
+```python
+class TraceStore(Protocol):
+    # Trace 操作
+    async def create_trace(self, trace: Trace) -> str: ...
+    async def get_trace(self, trace_id: str) -> Optional[Trace]: ...
+    async def update_trace(self, trace_id: str, **updates) -> None: ...
+    async def list_traces(self, ...) -> List[Trace]: ...
+
+    # GoalTree 操作(每个 Trace 有独立的 GoalTree)
+    async def get_goal_tree(self, trace_id: str) -> Optional[GoalTree]: ...
+    async def update_goal_tree(self, trace_id: str, tree: GoalTree) -> None: ...
+    async def add_goal(self, trace_id: str, goal: Goal) -> None: ...
+    async def update_goal(self, trace_id: str, goal_id: str, **updates) -> None: ...
+
+    # Message 操作
+    async def add_message(self, message: Message) -> str: ...
+    async def get_message(self, message_id: str) -> Optional[Message]: ...
+    async def get_trace_messages(self, trace_id: str) -> List[Message]: ...
+    async def get_main_path_messages(self, trace_id: str, head_sequence: int) -> List[Message]: ...
+    async def get_messages_by_goal(self, trace_id: str, goal_id: str) -> List[Message]: ...
+    async def update_message(self, message_id: str, **updates) -> None: ...
+
+    # 事件流(WebSocket 断线续传)
+    async def get_events(self, trace_id: str, since_event_id: int) -> List[Dict]: ...
+    async def append_event(self, trace_id: str, event_type: str, payload: Dict) -> int: ...
+```
+
+**实现**:`agent/trace/protocols.py`
+
+### FileSystemTraceStore
+
+```python
+from agent.trace import FileSystemTraceStore
+
+store = FileSystemTraceStore(base_path=".trace")
+```
+
+**目录结构**:
+```
+.trace/
+├── 2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d/           # 主 Trace
+│   ├── meta.json                                   # Trace 元数据
+│   ├── goal.json                                   # GoalTree(扁平 JSON)
+│   ├── messages/                                   # Messages
+│   │   ├── {message_id}.json
+│   │   └── ...
+│   └── events.jsonl                                # 事件流
+│
+├── 2f8d3a1c...@explore-20260204220012-001/        # Sub-Trace A
+│   ├── meta.json                                   # parent_trace_id 指向主 Trace
+│   ├── goal.json                                   # 独立的 GoalTree
+│   ├── messages/
+│   └── events.jsonl
+│
+└── 2f8d3a1c...@explore-20260204220012-002/        # Sub-Trace B
+    └── ...
+```
+
+**关键变化**(相比旧设计):
+- ❌ 不再有 `branches/` 子目录
+- ✅ 每个 Sub-Trace 是顶层独立目录
+- ✅ Sub-Trace 有完整的 Trace 结构(meta + goal + messages + events)
+
+**实现**:`agent/trace/store.py`
+
+---
+
+## REST API 端点
+
+### 查询端点
+
+#### 1. 列出 Traces
+
+```http
+GET /api/traces?mode=agent&status=running&limit=20
+```
+
+返回所有 Traces(包括主 Trace 和 Sub-Traces)。
+
+#### 2. 获取 Trace + GoalTree + Sub-Traces
+
+```http
+GET /api/traces/{trace_id}
+```
+
+返回:
+- Trace 元数据
+- GoalTree(该 Trace 的完整 Goal 树)
+- Sub-Traces 元数据(查询所有 `parent_trace_id == trace_id` 的 Traces)
+
+#### 3. 获取 Messages
+
+```http
+GET /api/traces/{trace_id}/messages?mode=main_path&head=15&goal_id=3
+```
+
+返回指定 Trace 的 Messages。参数:
+- `mode`: `main_path`(默认)| `all` — 返回主路径消息或全部消息
+- `head`: 可选 sequence 值 — 指定主路径的 head(默认用 trace.head_sequence,仅 mode=main_path 有效)
+- `goal_id`: 可选,按 Goal 过滤
+
+**实现**:`agent/trace/api.py`
+
+### 控制端点
+
+需在 `api_server.py` 中配置 Runner。执行在后台异步进行,通过 WebSocket 监听进度。
+
+#### 4. 新建 Trace 并执行
+
+```http
+POST /api/traces
+Content-Type: application/json
+
+{
+  "messages": [
+    {"role": "system", "content": "自定义 system prompt(可选,不传则从 skills 自动构建)"},
+    {"role": "user", "content": "分析项目架构"}
+  ],
+  "model": "gpt-4o",
+  "temperature": 0.3,
+  "max_iterations": 200,
+  "tools": null,
+  "name": "任务名称",
+  "uid": "user_id"
+}
+```
+
+#### 5. 运行(统一续跑 + 回溯)
+
+```http
+POST /api/traces/{trace_id}/run
+Content-Type: application/json
+
+{
+  "messages": [{"role": "user", "content": "..."}],
+  "after_message_id": null
+}
+```
+
+- `after_message_id: null`(或省略)→ 从末尾续跑
+- `after_message_id: "<message_id>"`(主路径上且 < head)→ 回溯到该消息后运行
+- `messages: []` + `after_message_id: "<message_id>"` → 重新生成
+
+Runner 根据解析出的 sequence 与 `head_sequence` 的关系自动判断续跑/回溯行为。
+
+#### 6. 停止运行中的 Trace
+
+```http
+POST /api/traces/{trace_id}/stop
+```
+
+设置取消信号,agent loop 在下一个检查点退出,Trace 状态置为 `stopped`。
+
+#### 7. 列出正在运行的 Trace
+
+```http
+GET /api/traces/running
+```
+
+#### 8. 反思(提取经验)
+
+```http
+POST /api/traces/{trace_id}/reflect
+Content-Type: application/json
+
+{
+  "focus": "可选,反思重点"
+}
+```
+
+在 trace 末尾追加一条包含反思 prompt 的 user message,作为侧枝运行。
+使用 `max_iterations=1, tools=[]` 进行单轮无工具 LLM 调用,生成经验总结,
+结果自动追加到 `./.cache/experiences.md`。head_sequence 通过 try/finally 保证恢复。
+
+### 经验端点
+
+#### 9. 读取经验文件
+
+```http
+GET /api/experiences
+```
+
+返回 `./.cache/experiences.md` 的文件内容。
+
+**实现**:`agent/trace/run_api.py`
+
+---
+
+## WebSocket 事件
+
+### 连接
+
+```
+ws://43.106.118.91:8000/api/traces/{trace_id}/watch?since_event_id=0
+```
+
+### 事件类型
+
+| 事件 | 触发时机 | payload |
+|------|---------|---------|
+| `connected` | WebSocket 连接成功 | trace_id, current_event_id, goal_tree, sub_traces |
+| `goal_added` | 新增 Goal | goal 完整数据(含 stats, parent_id, type) |
+| `goal_updated` | Goal 状态变化(含级联完成) | goal_id, updates, affected_goals(含级联完成的父节点) |
+| `message_added` | 新 Message | message 数据(含 goal_id),affected_goals |
+| `sub_trace_started` | Sub-Trace 开始执行 | trace_id, parent_goal_id, agent_type, task |
+| `sub_trace_completed` | Sub-Trace 完成 | trace_id, status, summary, stats |
+| `rewind` | 回溯执行 | after_sequence, head_sequence, goal_tree_snapshot |
+| `trace_completed` | 执行完成 | 统计信息 |
+
+### Stats 更新逻辑
+
+每次添加 Message 时,后端执行:
+1. 更新对应 Goal 的 `self_stats`
+2. 沿 `parent_id` 链向上更新所有祖先的 `cumulative_stats`
+3. 在 `message_added` 事件的 `affected_goals` 中推送所有受影响的 Goal 及其最新 stats
+
+### 级联完成(Cascade Completion)
+
+当所有子 Goals 都完成时,自动完成父 Goal:
+1. 检测子 Goals 全部 `status == "completed"`
+2. 自动设置父 Goal 的 `status = "completed"`
+3. 在 `goal_updated` 事件的 `affected_goals` 中包含级联完成的父节点
+
+**实现**:`agent/trace/websocket.py`
+
+---
+
+## Sub-Trace 工具
+
+### explore 工具
+
+并行探索多个方向:
+
+```python
+from agent.goal.explore import explore_tool
+
+result = await explore_tool(
+    current_trace_id="main_trace_id",
+    current_goal_id="3",
+    branches=["JWT 方案", "Session 方案"],
+    store=store,
+    run_agent=run_agent_func
+)
+```
+
+- 为每个 branch 创建独立的 Sub-Trace
+- 并行执行所有 Sub-Traces
+- 汇总结果返回
+
+### delegate 工具
+
+将大任务委托给独立 Sub-Agent:
+
+```python
+from agent.goal.delegate import delegate_tool
+
+result = await delegate_tool(
+    current_trace_id="main_trace_id",
+    current_goal_id="3",
+    task="实现用户登录功能",
+    store=store,
+    run_agent=run_agent_func
+)
+```
+
+- 创建单个 Sub-Trace,拥有完整权限
+- 执行任务并返回结果
+
+---
+
+## 使用场景
+
+### Agent 执行时记录
+
+```python
+from agent import AgentRunner
+from agent.trace import FileSystemTraceStore
+
+store = FileSystemTraceStore(base_path=".trace")
+runner = AgentRunner(trace_store=store, llm_call=my_llm_fn)
+
+async for event in runner.run(task="探索代码库"):
+    print(event)  # Trace 或 Message
+```
+
+### 查询 Sub-Traces
+
+```python
+# 获取主 Trace 的所有 Sub-Traces
+all_traces = await store.list_traces(limit=1000)
+sub_traces = [t for t in all_traces if t.parent_trace_id == main_trace_id]
+```
+
+---
+
+## 相关文档
+
+- [frontend/API.md](../frontend/API.md) - 前端对接 API 文档
+- [docs/context-management.md](./context-management.md) - Context 管理完整设计
+- [agent/goal/models.py](../agent/goal/models.py) - GoalTree 模型定义
+- [docs/REFACTOR_SUMMARY.md](./REFACTOR_SUMMARY.md) - 重构总结

+ 32 - 0
agent/llm/__init__.py

@@ -0,0 +1,32 @@
+"""
+LLM Providers
+
+各个 LLM 提供商的适配器
+"""
+
+from .gemini import create_gemini_llm_call
+from .openrouter import create_openrouter_llm_call
+from .yescode import create_yescode_llm_call
+from .usage import TokenUsage, TokenUsageAccumulator, create_usage_from_response
+from .pricing import (
+    ModelPricing,
+    PricingCalculator,
+    get_pricing_calculator,
+    calculate_cost,
+)
+
+__all__ = [
+    # Providers
+    "create_gemini_llm_call",
+    "create_openrouter_llm_call",
+    "create_yescode_llm_call",
+    # Usage
+    "TokenUsage",
+    "TokenUsageAccumulator",
+    "create_usage_from_response",
+    # Pricing
+    "ModelPricing",
+    "PricingCalculator",
+    "get_pricing_calculator",
+    "calculate_cost",
+]

+ 455 - 0
agent/llm/gemini.py

@@ -0,0 +1,455 @@
+"""
+Gemini Provider (HTTP API)
+
+使用 httpx 直接调用 Gemini REST API,避免 google-generativeai SDK 的兼容性问题
+
+参考:Resonote/llm/providers/gemini.py
+"""
+
+import os
+import json
+import sys
+import httpx
+from typing import List, Dict, Any, Optional
+
+from .usage import TokenUsage
+from .pricing import calculate_cost
+
+
+def _dump_llm_request(endpoint: str, payload: Dict[str, Any], model: str):
+    """
+    Dump完整的LLM请求用于调试(需要设置 AGENT_DEBUG=1)
+
+    特别处理:
+    - 图片base64数据:只显示前50字符 + 长度信息
+    - Tools schema:完整显示
+    - 输出到stderr,避免污染正常输出
+    """
+    if not os.getenv("AGENT_DEBUG"):
+        return
+
+    def truncate_images(obj):
+        """递归处理对象,truncate图片base64数据"""
+        if isinstance(obj, dict):
+            result = {}
+            for key, value in obj.items():
+                # 处理 inline_data 中的 base64 图片
+                if key == "inline_data" and isinstance(value, dict):
+                    mime_type = value.get("mime_type", "unknown")
+                    data = value.get("data", "")
+                    data_size_kb = len(data) / 1024 if data else 0
+                    result[key] = {
+                        "mime_type": mime_type,
+                        "data": f"<BASE64_IMAGE: {data_size_kb:.1f}KB, preview: {data[:50]}...>"
+                    }
+                else:
+                    result[key] = truncate_images(value)
+            return result
+        elif isinstance(obj, list):
+            return [truncate_images(item) for item in obj]
+        else:
+            return obj
+
+    # 构造完整的调试信息
+    debug_info = {
+        "endpoint": endpoint,
+        "model": model,
+        "payload": truncate_images(payload)
+    }
+
+    # 输出到stderr
+    print("\n" + "="*80, file=sys.stderr)
+    print("[AGENT_DEBUG] LLM Request Dump", file=sys.stderr)
+    print("="*80, file=sys.stderr)
+    print(json.dumps(debug_info, indent=2, ensure_ascii=False), file=sys.stderr)
+    print("="*80 + "\n", file=sys.stderr)
+
+
+def _convert_messages_to_gemini(messages: List[Dict]) -> tuple[List[Dict], Optional[str]]:
+    """
+    将 OpenAI 格式消息转换为 Gemini 格式
+
+    Returns:
+        (gemini_contents, system_instruction)
+    """
+    contents = []
+    system_instruction = None
+    tool_parts_buffer = []
+
+    def flush_tool_buffer():
+        """合并连续的 tool 消息为单个 user 消息"""
+        if tool_parts_buffer:
+            contents.append({
+                "role": "user",
+                "parts": tool_parts_buffer.copy()
+            })
+            tool_parts_buffer.clear()
+
+    for msg in messages:
+        role = msg.get("role")
+
+        # System 消息 -> system_instruction
+        if role == "system":
+            system_instruction = msg.get("content", "")
+            continue
+
+        # Tool 消息 -> functionResponse
+        if role == "tool":
+            tool_name = msg.get("name")
+            content_text = msg.get("content", "")
+
+            if not tool_name:
+                print(f"[WARNING] Tool message missing 'name' field, skipping")
+                continue
+
+            # 尝试解析为 JSON
+            try:
+                parsed = json.loads(content_text) if content_text else {}
+                if isinstance(parsed, list):
+                    response_data = {"result": parsed}
+                else:
+                    response_data = parsed
+            except (json.JSONDecodeError, ValueError):
+                response_data = {"result": content_text}
+
+            # 添加到 buffer
+            tool_parts_buffer.append({
+                "functionResponse": {
+                    "name": tool_name,
+                    "response": response_data
+                }
+            })
+            continue
+
+        # 非 tool 消息:先 flush buffer
+        flush_tool_buffer()
+
+        content = msg.get("content", "")
+        tool_calls = msg.get("tool_calls")
+
+        # Assistant 消息 + tool_calls
+        if role == "assistant" and tool_calls:
+            parts = []
+            if content and (isinstance(content, str) and content.strip()):
+                parts.append({"text": content})
+
+            # 转换 tool_calls 为 functionCall
+            for tc in tool_calls:
+                func = tc.get("function", {})
+                func_name = func.get("name", "")
+                func_args_str = func.get("arguments", "{}")
+                try:
+                    func_args = json.loads(func_args_str) if isinstance(func_args_str, str) else func_args_str
+                except json.JSONDecodeError:
+                    func_args = {}
+
+                parts.append({
+                    "functionCall": {
+                        "name": func_name,
+                        "args": func_args
+                    }
+                })
+
+            if parts:
+                contents.append({
+                    "role": "model",
+                    "parts": parts
+                })
+            continue
+
+        # 处理多模态消息(content 为数组)
+        if isinstance(content, list):
+            parts = []
+            for item in content:
+                item_type = item.get("type")
+
+                # 文本部分
+                if item_type == "text":
+                    text = item.get("text", "")
+                    if text.strip():
+                        parts.append({"text": text})
+
+                # 图片部分(OpenAI format -> Gemini format)
+                elif item_type == "image_url":
+                    image_url = item.get("image_url", {})
+                    url = image_url.get("url", "")
+
+                    # 处理 data URL (data:image/png;base64,...)
+                    if url.startswith("data:"):
+                        # 解析 MIME type 和 base64 数据
+                        # 格式:data:image/png;base64,<base64_data>
+                        try:
+                            header, base64_data = url.split(",", 1)
+                            mime_type = header.split(";")[0].replace("data:", "")
+
+                            parts.append({
+                                "inline_data": {
+                                    "mime_type": mime_type,
+                                    "data": base64_data
+                                }
+                            })
+                        except Exception as e:
+                            print(f"[WARNING] Failed to parse image data URL: {e}")
+
+            if parts:
+                gemini_role = "model" if role == "assistant" else "user"
+                contents.append({
+                    "role": gemini_role,
+                    "parts": parts
+                })
+            continue
+
+        # 普通文本消息(content 为字符串)
+        if isinstance(content, str):
+            # 跳过空消息
+            if not content.strip():
+                continue
+
+            gemini_role = "model" if role == "assistant" else "user"
+            contents.append({
+                "role": gemini_role,
+                "parts": [{"text": content}]
+            })
+            continue
+
+    # Flush 剩余的 tool messages
+    flush_tool_buffer()
+
+    # 合并连续的 user 消息(Gemini 要求严格交替)
+    merged_contents = []
+    i = 0
+    while i < len(contents):
+        current = contents[i]
+
+        if current["role"] == "user":
+            merged_parts = current["parts"].copy()
+            j = i + 1
+            while j < len(contents) and contents[j]["role"] == "user":
+                merged_parts.extend(contents[j]["parts"])
+                j += 1
+
+            merged_contents.append({
+                "role": "user",
+                "parts": merged_parts
+            })
+            i = j
+        else:
+            merged_contents.append(current)
+            i += 1
+
+    return merged_contents, system_instruction
+
+
+def _convert_tools_to_gemini(tools: List[Dict]) -> List[Dict]:
+    """
+    将 OpenAI 工具格式转换为 Gemini REST API 格式
+
+    OpenAI: [{"type": "function", "function": {"name": "...", "parameters": {...}}}]
+    Gemini API: [{"functionDeclarations": [{"name": "...", "parameters": {...}}]}]
+    """
+    if not tools:
+        return []
+
+    function_declarations = []
+    for tool in tools:
+        if tool.get("type") == "function":
+            func = tool.get("function", {})
+
+            # 清理不支持的字段
+            parameters = func.get("parameters", {})
+            if "properties" in parameters:
+                cleaned_properties = {}
+                for prop_name, prop_def in parameters["properties"].items():
+                    # 移除 default 字段
+                    cleaned_prop = {k: v for k, v in prop_def.items() if k != "default"}
+                    cleaned_properties[prop_name] = cleaned_prop
+
+                # Gemini API 需要完整的 schema
+                cleaned_parameters = {
+                    "type": "object",
+                    "properties": cleaned_properties
+                }
+                if "required" in parameters:
+                    cleaned_parameters["required"] = parameters["required"]
+
+                parameters = cleaned_parameters
+
+            function_declarations.append({
+                "name": func.get("name"),
+                "description": func.get("description", ""),
+                "parameters": parameters
+            })
+
+    return [{"functionDeclarations": function_declarations}] if function_declarations else []
+
+
+def create_gemini_llm_call(
+    base_url: Optional[str] = None,
+    api_key: Optional[str] = None
+):
+    """
+    创建 Gemini LLM 调用函数(HTTP API)
+
+    Args:
+        base_url: Gemini API base URL(默认使用 Google 官方)
+        api_key: API key(默认从环境变量读取)
+
+    Returns:
+        async 函数
+    """
+    base_url = base_url or "https://generativelanguage.googleapis.com/v1beta"
+    api_key = api_key or os.getenv("GEMINI_API_KEY")
+
+    if not api_key:
+        raise ValueError("GEMINI_API_KEY not found")
+
+    # 创建 HTTP 客户端
+    client = httpx.AsyncClient(
+        headers={"x-goog-api-key": api_key},
+        timeout=httpx.Timeout(120.0, connect=10.0)
+    )
+
+    async def gemini_llm_call(
+        messages: List[Dict[str, Any]],
+        model: str = "gemini-2.5-pro",
+        tools: Optional[List[Dict]] = None,
+        **kwargs
+    ) -> Dict[str, Any]:
+        """
+        调用 Gemini REST API
+
+        Args:
+            messages: OpenAI 格式消息
+            model: 模型名称
+            tools: OpenAI 格式工具列表
+            **kwargs: 其他参数
+
+        Returns:
+            {
+                "content": str,
+                "tool_calls": List[Dict] | None,
+                "prompt_tokens": int,
+                "completion_tokens": int,
+                "finish_reason": str,
+                "cost": float
+            }
+        """
+        # 转换消息
+        contents, system_instruction = _convert_messages_to_gemini(messages)
+
+        print(f"\n[Gemini HTTP] Converted {len(contents)} messages: {[c['role'] for c in contents]}")
+
+        # 构建请求
+        endpoint = f"{base_url}/models/{model}:generateContent"
+        payload = {"contents": contents}
+
+        # 添加 system instruction
+        if system_instruction:
+            payload["systemInstruction"] = {"parts": [{"text": system_instruction}]}
+
+        # 添加工具
+        if tools:
+            gemini_tools = _convert_tools_to_gemini(tools)
+            if gemini_tools:
+                payload["tools"] = gemini_tools
+
+        # Debug: dump完整请求(需要设置 AGENT_DEBUG=1)
+        _dump_llm_request(endpoint, payload, model)
+
+        # 调用 API
+        try:
+            response = await client.post(endpoint, json=payload)
+            response.raise_for_status()
+            gemini_resp = response.json()
+
+        except httpx.HTTPStatusError as e:
+            error_body = e.response.text
+            print(f"[Gemini HTTP] Error {e.response.status_code}: {error_body}")
+            raise
+        except Exception as e:
+            print(f"[Gemini HTTP] Request failed: {e}")
+            raise
+
+        # Debug: 输出原始响应(如果启用)
+        if os.getenv("AGENT_DEBUG"):
+            print("\n[AGENT_DEBUG] Gemini Response:", file=sys.stderr)
+            print(json.dumps(gemini_resp, ensure_ascii=False, indent=2)[:2000], file=sys.stderr)
+            print("\n", file=sys.stderr)
+
+        # 解析响应
+        content = ""
+        tool_calls = None
+        finish_reason = "stop"  # 默认值
+
+        candidates = gemini_resp.get("candidates", [])
+        if candidates:
+            candidate = candidates[0]
+
+            # 提取 finish_reason(Gemini -> OpenAI 格式映射)
+            gemini_finish_reason = candidate.get("finishReason", "STOP")
+            if gemini_finish_reason == "STOP":
+                finish_reason = "stop"
+            elif gemini_finish_reason == "MAX_TOKENS":
+                finish_reason = "length"
+            elif gemini_finish_reason in ("SAFETY", "RECITATION"):
+                finish_reason = "content_filter"
+            elif gemini_finish_reason == "MALFORMED_FUNCTION_CALL":
+                finish_reason = "stop"  # 映射为 stop,但在 content 中包含错误信息
+            else:
+                finish_reason = gemini_finish_reason.lower()  # 保持原值,转小写
+
+            # 检查是否有错误
+            if gemini_finish_reason == "MALFORMED_FUNCTION_CALL":
+                # Gemini 返回了格式错误的函数调用
+                # 提取 finishMessage 中的内容作为 content
+                finish_message = candidate.get("finishMessage", "")
+                print(f"[Gemini HTTP] Warning: MALFORMED_FUNCTION_CALL\n{finish_message}")
+                content = f"[模型尝试调用工具但格式错误]\n\n{finish_message}"
+            else:
+                # 正常解析
+                parts = candidate.get("content", {}).get("parts", [])
+
+                # 提取文本
+                for part in parts:
+                    if "text" in part:
+                        content += part.get("text", "")
+
+                # 提取 functionCall
+                for i, part in enumerate(parts):
+                    if "functionCall" in part:
+                        if tool_calls is None:
+                            tool_calls = []
+
+                        fc = part["functionCall"]
+                        name = fc.get("name", "")
+                        args = fc.get("args", {})
+
+                        tool_calls.append({
+                            "id": f"call_{i}",
+                            "type": "function",
+                            "function": {
+                                "name": name,
+                                "arguments": json.dumps(args, ensure_ascii=False)
+                            }
+                        })
+
+        # 提取 usage(完整版)
+        usage_meta = gemini_resp.get("usageMetadata", {})
+        usage = TokenUsage.from_gemini(usage_meta)
+
+        # 计算费用
+        cost = calculate_cost(model, usage)
+
+        return {
+            "content": content,
+            "tool_calls": tool_calls,
+            "prompt_tokens": usage.input_tokens,
+            "completion_tokens": usage.output_tokens,
+            "reasoning_tokens": usage.reasoning_tokens,
+            "cached_content_tokens": usage.cached_content_tokens,
+            "finish_reason": finish_reason,
+            "cost": cost,
+            "usage": usage,  # 完整的 TokenUsage 对象
+        }
+
+    return gemini_llm_call

+ 761 - 0
agent/llm/openrouter.py

@@ -0,0 +1,761 @@
+"""
+OpenRouter Provider
+
+使用 OpenRouter API 调用各种模型(包括 Claude Sonnet 4.5)
+
+路由策略:
+- Claude 模型:走 OpenRouter 的 Anthropic 原生端点(/api/v1/messages),
+  使用自包含的格式转换逻辑,确保多模态工具结果(截图等)正确传递。
+- 其他模型:走 OpenAI 兼容端点(/api/v1/chat/completions)。
+
+OpenRouter 转发多种模型,需要根据实际模型处理不同的 usage 格式:
+- OpenAI 模型: prompt_tokens, completion_tokens, completion_tokens_details.reasoning_tokens
+- Claude 模型: input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens
+- DeepSeek 模型: prompt_tokens, completion_tokens, reasoning_tokens
+"""
+
+import os
+import json
+import asyncio
+import logging
+import httpx
+from pathlib import Path
+from typing import List, Dict, Any, Optional
+
+from .usage import TokenUsage, create_usage_from_response
+from .pricing import calculate_cost
+
+logger = logging.getLogger(__name__)
+
+# 可重试的异常类型
+_RETRYABLE_EXCEPTIONS = (
+    httpx.RemoteProtocolError,  # Server disconnected without sending a response
+    httpx.ConnectError,
+    httpx.ReadTimeout,
+    httpx.WriteTimeout,
+    httpx.ConnectTimeout,
+    httpx.PoolTimeout,
+    ConnectionError,
+)
+
+
+# ── OpenRouter Anthropic endpoint: model name mapping ──────────────────────
+# Local copy of yescode's model tables so this module is self-contained.
+_OR_MODEL_EXACT = {
+    "claude-sonnet-4-6": "claude-sonnet-4-6",
+    "claude-sonnet-4.6": "claude-sonnet-4-6",
+    "claude-sonnet-4-5-20250929": "claude-sonnet-4-5-20250929",
+    "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
+    "claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
+    "claude-opus-4-6": "claude-opus-4-6",
+    "claude-opus-4-5-20251101": "claude-opus-4-5-20251101",
+    "claude-opus-4-5": "claude-opus-4-5-20251101",
+    "claude-opus-4-1-20250805": "claude-opus-4-1-20250805",
+    "claude-opus-4-1": "claude-opus-4-1-20250805",
+    "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
+    "claude-haiku-4-5": "claude-haiku-4-5-20251001",
+}
+
+_OR_MODEL_FUZZY = [
+    ("sonnet-4-6", "claude-sonnet-4-6"),
+    ("sonnet-4.6", "claude-sonnet-4-6"),
+    ("sonnet-4-5", "claude-sonnet-4-5-20250929"),
+    ("sonnet-4.5", "claude-sonnet-4-5-20250929"),
+    ("opus-4-6", "claude-opus-4-6"),
+    ("opus-4.6", "claude-opus-4-6"),
+    ("opus-4-5", "claude-opus-4-5-20251101"),
+    ("opus-4.5", "claude-opus-4-5-20251101"),
+    ("opus-4-1", "claude-opus-4-1-20250805"),
+    ("opus-4.1", "claude-opus-4-1-20250805"),
+    ("haiku-4-5", "claude-haiku-4-5-20251001"),
+    ("haiku-4.5", "claude-haiku-4-5-20251001"),
+    ("sonnet", "claude-sonnet-4-6"),
+    ("opus", "claude-opus-4-6"),
+    ("haiku", "claude-haiku-4-5-20251001"),
+]
+
+
+def _resolve_openrouter_model(model: str) -> str:
+    """Normalize a model name for OpenRouter's Anthropic endpoint.
+
+    Strips ``anthropic/`` prefix, resolves aliases / dot-notation,
+    and re-prepends ``anthropic/`` for OpenRouter routing.
+    """
+    # 1. Strip provider prefix
+    bare = model.split("/", 1)[1] if "/" in model else model
+
+    # 2. Exact match
+    if bare in _OR_MODEL_EXACT:
+        return f"anthropic/{_OR_MODEL_EXACT[bare]}"
+
+    # 3. Fuzzy keyword match (case-insensitive)
+    bare_lower = bare.lower()
+    for keyword, target in _OR_MODEL_FUZZY:
+        if keyword in bare_lower:
+            logger.info("[OpenRouter] Model fuzzy match: %s → anthropic/%s", model, target)
+            return f"anthropic/{target}"
+
+    # 4. Fallback – return as-is (let API report the error)
+    logger.warning("[OpenRouter] Could not resolve model name: %s, passing as-is", model)
+    return model
+
+
+# ── OpenRouter Anthropic endpoint: format conversion helpers ───────────────
+
+def _get_image_dimensions(data: bytes) -> Optional[tuple]:
+    """从图片二进制数据的文件头解析宽高,支持 PNG/JPEG。不依赖 PIL。"""
+    try:
+        # PNG: 前 8 字节签名,IHDR chunk 在 16-24 字节存宽高 (big-endian uint32)
+        if data[:8] == b'\x89PNG\r\n\x1a\n' and len(data) >= 24:
+            import struct
+            w, h = struct.unpack('>II', data[16:24])
+            return (w, h)
+        # JPEG: 扫描 SOF0/SOF2 marker (0xFFC0/0xFFC2)
+        if data[:2] == b'\xff\xd8':
+            import struct
+            i = 2
+            while i < len(data) - 9:
+                if data[i] != 0xFF:
+                    break
+                marker = data[i + 1]
+                if marker in (0xC0, 0xC2):
+                    h, w = struct.unpack('>HH', data[i + 5:i + 9])
+                    return (w, h)
+                length = struct.unpack('>H', data[i + 2:i + 4])[0]
+                i += 2 + length
+    except Exception:
+        pass
+    return None
+
+
+def _to_anthropic_content(content: Any) -> Any:
+    """Convert OpenAI-style *content* (string or block list) to Anthropic format.
+
+    Handles ``image_url`` blocks → Anthropic ``image`` blocks (base64 or url).
+    Passes through ``text`` blocks and ``cache_control`` unchanged.
+    """
+    if not isinstance(content, list):
+        return content
+
+    result = []
+    for block in content:
+        if not isinstance(block, dict):
+            result.append(block)
+            continue
+
+        if block.get("type") == "image_url":
+            image_url_obj = block.get("image_url", {})
+            url = image_url_obj.get("url", "") if isinstance(image_url_obj, dict) else str(image_url_obj)
+            if url.startswith("data:"):
+                header, _, data = url.partition(",")
+                media_type = header.split(":")[1].split(";")[0] if ":" in header else "image/png"
+                import base64 as b64mod
+                raw = b64mod.b64decode(data)
+                dims = _get_image_dimensions(raw)
+                img_block = {
+                    "type": "image",
+                    "source": {
+                        "type": "base64",
+                        "media_type": media_type,
+                        "data": data,
+                    },
+                }
+                if dims:
+                    img_block["_image_meta"] = {"width": dims[0], "height": dims[1]}
+                result.append(img_block)
+            else:
+                # 检测本地文件路径,自动转 base64
+                local_path = Path(url)
+                if local_path.exists() and local_path.is_file():
+                    import base64 as b64mod
+                    import mimetypes
+                    mime_type, _ = mimetypes.guess_type(str(local_path))
+                    mime_type = mime_type or "image/png"
+                    raw = local_path.read_bytes()
+                    dims = _get_image_dimensions(raw)
+                    b64_data = b64mod.b64encode(raw).decode("ascii")
+                    logger.info(f"[OpenRouter] 本地图片自动转 base64: {url} ({len(raw)} bytes)")
+                    img_block = {
+                        "type": "image",
+                        "source": {
+                            "type": "base64",
+                            "media_type": mime_type,
+                            "data": b64_data,
+                        },
+                    }
+                    if dims:
+                        img_block["_image_meta"] = {"width": dims[0], "height": dims[1]}
+                    result.append(img_block)
+                else:
+                    result.append({
+                        "type": "image",
+                        "source": {"type": "url", "url": url},
+                    })
+        else:
+            result.append(block)
+    return result
+
+
+def _to_anthropic_messages(messages: List[Dict[str, Any]]) -> tuple:
+    """Convert an OpenAI-format message list to Anthropic Messages API format.
+
+    Returns ``(system_prompt, anthropic_messages)`` where *system_prompt* is
+    ``None`` or a string extracted from ``role=system`` messages, and
+    *anthropic_messages* is the converted list.
+    """
+    system_prompt = None
+    anthropic_messages: List[Dict[str, Any]] = []
+
+    for msg in messages:
+        role = msg.get("role", "")
+        content = msg.get("content", "")
+
+        if role == "system":
+            system_prompt = content
+
+        elif role == "user":
+            anthropic_messages.append({
+                "role": "user",
+                "content": _to_anthropic_content(content),
+            })
+
+        elif role == "assistant":
+            tool_calls = msg.get("tool_calls")
+            if tool_calls:
+                content_blocks: List[Dict[str, Any]] = []
+                if content:
+                    converted = _to_anthropic_content(content)
+                    if isinstance(converted, list):
+                        content_blocks.extend(converted)
+                    elif isinstance(converted, str) and converted.strip():
+                        content_blocks.append({"type": "text", "text": converted})
+                for tc in tool_calls:
+                    func = tc.get("function", {})
+                    args_str = func.get("arguments", "{}")
+                    try:
+                        args = json.loads(args_str) if isinstance(args_str, str) else args_str
+                    except json.JSONDecodeError:
+                        args = {}
+                    content_blocks.append({
+                        "type": "tool_use",
+                        "id": tc.get("id", ""),
+                        "name": func.get("name", ""),
+                        "input": args,
+                    })
+                anthropic_messages.append({"role": "assistant", "content": content_blocks})
+            else:
+                anthropic_messages.append({"role": "assistant", "content": content})
+
+        elif role == "tool":
+            # Split tool result into text-only tool_result + sibling image blocks.
+            # Images nested inside tool_result.content are not reliably passed
+            # through by all proxies (e.g. OpenRouter).  Placing them as sibling
+            # content blocks in the same user message is more compatible.
+            converted = _to_anthropic_content(content)
+            text_parts: List[Dict[str, Any]] = []
+            image_parts: List[Dict[str, Any]] = []
+            if isinstance(converted, list):
+                for block in converted:
+                    if isinstance(block, dict) and block.get("type") == "image":
+                        image_parts.append(block)
+                    else:
+                        text_parts.append(block)
+            elif isinstance(converted, str):
+                text_parts = [{"type": "text", "text": converted}] if converted else []
+
+            # tool_result keeps only text content
+            tool_result_block: Dict[str, Any] = {
+                "type": "tool_result",
+                "tool_use_id": msg.get("tool_call_id", ""),
+            }
+            if len(text_parts) == 1 and text_parts[0].get("type") == "text":
+                tool_result_block["content"] = text_parts[0]["text"]
+            elif text_parts:
+                tool_result_block["content"] = text_parts
+            # (omit content key entirely when empty – Anthropic accepts this)
+
+            # Build the blocks to append: tool_result first, then any images
+            new_blocks = [tool_result_block] + image_parts
+
+            # Merge consecutive tool results into one user message
+            if (anthropic_messages
+                    and anthropic_messages[-1].get("role") == "user"
+                    and isinstance(anthropic_messages[-1].get("content"), list)
+                    and anthropic_messages[-1]["content"]
+                    and anthropic_messages[-1]["content"][0].get("type") == "tool_result"):
+                anthropic_messages[-1]["content"].extend(new_blocks)
+            else:
+                anthropic_messages.append({
+                    "role": "user",
+                    "content": new_blocks,
+                })
+
+    return system_prompt, anthropic_messages
+
+
+def _to_anthropic_tools(tools: List[Dict]) -> List[Dict]:
+    """Convert OpenAI tool definitions to Anthropic format."""
+    anthropic_tools = []
+    for tool in tools:
+        if tool.get("type") == "function":
+            func = tool["function"]
+            anthropic_tools.append({
+                "name": func.get("name", ""),
+                "description": func.get("description", ""),
+                "input_schema": func.get("parameters", {"type": "object", "properties": {}}),
+            })
+    return anthropic_tools
+
+
+def _parse_anthropic_response(result: Dict[str, Any]) -> Dict[str, Any]:
+    """Parse an Anthropic Messages API response into the unified format.
+
+    Returns a dict with keys: content, tool_calls, finish_reason, usage.
+    """
+    content_blocks = result.get("content", [])
+
+    text_parts = []
+    tool_calls = []
+    for block in content_blocks:
+        if block.get("type") == "text":
+            text_parts.append(block.get("text", ""))
+        elif block.get("type") == "tool_use":
+            tool_calls.append({
+                "id": block.get("id", ""),
+                "type": "function",
+                "function": {
+                    "name": block.get("name", ""),
+                    "arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
+                },
+            })
+
+    content = "\n".join(text_parts)
+
+    stop_reason = result.get("stop_reason", "end_turn")
+    finish_reason_map = {
+        "end_turn": "stop",
+        "tool_use": "tool_calls",
+        "max_tokens": "length",
+        "stop_sequence": "stop",
+    }
+    finish_reason = finish_reason_map.get(stop_reason, stop_reason)
+
+    raw_usage = result.get("usage", {})
+    usage = TokenUsage(
+        input_tokens=raw_usage.get("input_tokens", 0),
+        output_tokens=raw_usage.get("output_tokens", 0),
+        cache_creation_tokens=raw_usage.get("cache_creation_input_tokens", 0),
+        cache_read_tokens=raw_usage.get("cache_read_input_tokens", 0),
+    )
+
+    return {
+        "content": content,
+        "tool_calls": tool_calls if tool_calls else None,
+        "finish_reason": finish_reason,
+        "usage": usage,
+    }
+
+
+# ── Provider detection / usage parsing ─────────────────────────────────────
+
+def _detect_provider_from_model(model: str) -> str:
+    """根据模型名称检测提供商"""
+    model_lower = model.lower()
+    if model_lower.startswith("anthropic/") or "claude" in model_lower:
+        return "anthropic"
+    elif model_lower.startswith("openai/") or model_lower.startswith("gpt") or model_lower.startswith("o1") or model_lower.startswith("o3"):
+        return "openai"
+    elif model_lower.startswith("deepseek/") or "deepseek" in model_lower:
+        return "deepseek"
+    elif model_lower.startswith("google/") or "gemini" in model_lower:
+        return "gemini"
+    else:
+        return "openai"  # 默认使用 OpenAI 格式
+
+
+def _parse_openrouter_usage(usage: Dict[str, Any], model: str) -> TokenUsage:
+    """
+    解析 OpenRouter 返回的 usage
+
+    OpenRouter 会根据底层模型返回不同格式的 usage
+    """
+    provider = _detect_provider_from_model(model)
+
+    # OpenRouter 通常返回 OpenAI 格式,但可能包含额外字段
+    if provider == "anthropic":
+        # Claude 模型可能有缓存字段
+        # OpenRouter 使用 prompt_tokens_details 嵌套结构
+        prompt_details = usage.get("prompt_tokens_details", {})
+
+        # 调试:打印原始 usage
+        if logger.isEnabledFor(logging.DEBUG):
+            logger.debug(f"[OpenRouter] Raw usage: {usage}")
+            logger.debug(f"[OpenRouter] prompt_tokens_details: {prompt_details}")
+
+        return TokenUsage(
+            input_tokens=usage.get("prompt_tokens") or usage.get("input_tokens", 0),
+            output_tokens=usage.get("completion_tokens") or usage.get("output_tokens", 0),
+            # OpenRouter 格式:prompt_tokens_details.cached_tokens / cache_write_tokens
+            cache_read_tokens=prompt_details.get("cached_tokens", 0),
+            cache_creation_tokens=prompt_details.get("cache_write_tokens", 0),
+        )
+    elif provider == "deepseek":
+        # DeepSeek 可能有 reasoning_tokens
+        return TokenUsage(
+            input_tokens=usage.get("prompt_tokens", 0),
+            output_tokens=usage.get("completion_tokens", 0),
+            reasoning_tokens=usage.get("reasoning_tokens", 0),
+        )
+    else:
+        # OpenAI 格式(包括 o1/o3 的 reasoning_tokens)
+        reasoning = 0
+        if details := usage.get("completion_tokens_details"):
+            reasoning = details.get("reasoning_tokens", 0)
+
+        return TokenUsage(
+            input_tokens=usage.get("prompt_tokens", 0),
+            output_tokens=usage.get("completion_tokens", 0),
+            reasoning_tokens=reasoning,
+        )
+
+
+def _normalize_tool_call_ids(messages: List[Dict[str, Any]], target_prefix: str) -> List[Dict[str, Any]]:
+    """
+    将消息历史中的 tool_call_id 统一重写为目标 Provider 的格式。
+    跨 Provider 续跑时,历史中的 tool_call_id 可能不兼容目标 API
+    (如 Anthropic 的 toolu_xxx 发给 OpenAI,或 OpenAI 的 call_xxx 发给 Anthropic)。
+    仅在检测到异格式 ID 时才重写,同格式直接跳过。
+    """
+    # 第一遍:收集需要重写的 ID
+    id_map: Dict[str, str] = {}
+    counter = 0
+    for msg in messages:
+        if msg.get("role") == "assistant" and msg.get("tool_calls"):
+            for tc in msg["tool_calls"]:
+                old_id = tc.get("id", "")
+                if old_id and not old_id.startswith(target_prefix + "_"):
+                    if old_id not in id_map:
+                        id_map[old_id] = f"{target_prefix}_{counter:06x}"
+                        counter += 1
+
+    if not id_map:
+        return messages  # 无需重写
+
+    logger.info("重写 %d 个 tool_call_id (target_prefix=%s)", len(id_map), target_prefix)
+
+    # 第二遍:重写(浅拷贝避免修改原始数据)
+    result = []
+    for msg in messages:
+        if msg.get("role") == "assistant" and msg.get("tool_calls"):
+            new_tcs = []
+            for tc in msg["tool_calls"]:
+                old_id = tc.get("id", "")
+                if old_id in id_map:
+                    new_tcs.append({**tc, "id": id_map[old_id]})
+                else:
+                    new_tcs.append(tc)
+            result.append({**msg, "tool_calls": new_tcs})
+        elif msg.get("role") == "tool" and msg.get("tool_call_id") in id_map:
+            result.append({**msg, "tool_call_id": id_map[msg["tool_call_id"]]})
+        else:
+            result.append(msg)
+
+    return result
+
+
+async def _openrouter_anthropic_call(
+    messages: List[Dict[str, Any]],
+    model: str,
+    tools: Optional[List[Dict]],
+    api_key: str,
+    **kwargs,
+) -> Dict[str, Any]:
+    """
+    通过 OpenRouter 的 Anthropic 原生端点调用 Claude 模型。
+
+    使用 Anthropic Messages API 格式(/api/v1/messages),
+    自包含的格式转换逻辑,确保多模态内容(截图等)正确传递。
+    """
+    endpoint = "https://openrouter.ai/api/v1/messages"
+
+    # Resolve model name for OpenRouter (e.g. "claude-sonnet-4.5" → "anthropic/claude-sonnet-4-5-20250929")
+    resolved_model = _resolve_openrouter_model(model)
+    logger.debug("[OpenRouter/Anthropic] model: %s → %s", model, resolved_model)
+
+    # 跨 Provider 续跑时,重写不兼容的 tool_call_id 为 toolu_ 前缀
+    messages = _normalize_tool_call_ids(messages, "toolu")
+
+    # OpenAI 格式 → Anthropic 格式
+    system_prompt, anthropic_messages = _to_anthropic_messages(messages)
+
+    # Diagnostic: count image blocks in the payload
+    _img_count = 0
+    for _m in anthropic_messages:
+        if isinstance(_m.get("content"), list):
+            for _b in _m["content"]:
+                if isinstance(_b, dict) and _b.get("type") == "image":
+                    _img_count += 1
+    if _img_count:
+        logger.info("[OpenRouter/Anthropic] payload contains %d image block(s)", _img_count)
+        print(f"[OpenRouter/Anthropic] payload contains {_img_count} image block(s)")
+
+    payload: Dict[str, Any] = {
+        "model": resolved_model,
+        "messages": anthropic_messages,
+        "max_tokens": kwargs.get("max_tokens", 16384),
+    }
+    if system_prompt is not None:
+        payload["system"] = system_prompt
+    if tools:
+        payload["tools"] = _to_anthropic_tools(tools)
+    if "temperature" in kwargs:
+        payload["temperature"] = kwargs["temperature"]
+
+    # Debug: 检查 cache_control 是否存在
+    if logger.isEnabledFor(logging.DEBUG):
+        cache_control_count = 0
+        if isinstance(system_prompt, list):
+            for block in system_prompt:
+                if isinstance(block, dict) and "cache_control" in block:
+                    cache_control_count += 1
+        for msg in anthropic_messages:
+            content = msg.get("content", "")
+            if isinstance(content, list):
+                for block in content:
+                    if isinstance(block, dict) and "cache_control" in block:
+                        cache_control_count += 1
+        if cache_control_count > 0:
+            logger.debug(f"[OpenRouter/Anthropic] 发现 {cache_control_count} 个 cache_control 标记")
+
+    headers = {
+        "Authorization": f"Bearer {api_key}",
+        "anthropic-version": "2023-06-01",
+        "content-type": "application/json",
+        "HTTP-Referer": "https://github.com/your-repo",
+        "X-Title": "Agent Framework",
+    }
+
+    max_retries = 3
+    last_exception = None
+    for attempt in range(max_retries):
+        async with httpx.AsyncClient(timeout=300.0) as client:
+            try:
+                response = await client.post(endpoint, json=payload, headers=headers)
+                response.raise_for_status()
+                result = response.json()
+                break
+
+            except httpx.HTTPStatusError as e:
+                status = e.response.status_code
+                error_body = e.response.text
+                if status in (429, 500, 502, 503, 504) and attempt < max_retries - 1:
+                    wait = 2 ** attempt * 2
+                    logger.warning(
+                        "[OpenRouter/Anthropic] HTTP %d (attempt %d/%d), retrying in %ds: %s",
+                        status, attempt + 1, max_retries, wait, error_body[:200],
+                    )
+                    await asyncio.sleep(wait)
+                    last_exception = e
+                    continue
+                # Log AND print error body so it is visible in console output
+                logger.error("[OpenRouter/Anthropic] HTTP %d error body: %s", status, error_body)
+                print(f"[OpenRouter/Anthropic] API Error {status}: {error_body[:500]}")
+                raise
+
+            except _RETRYABLE_EXCEPTIONS as e:
+                last_exception = e
+                if attempt < max_retries - 1:
+                    wait = 2 ** attempt * 2
+                    logger.warning(
+                        "[OpenRouter/Anthropic] %s (attempt %d/%d), retrying in %ds",
+                        type(e).__name__, attempt + 1, max_retries, wait,
+                    )
+                    await asyncio.sleep(wait)
+                    continue
+                raise
+    else:
+        raise last_exception  # type: ignore[misc]
+
+    # 解析 Anthropic 响应 → 统一格式
+    parsed = _parse_anthropic_response(result)
+    usage = parsed["usage"]
+    cost = calculate_cost(model, usage)
+
+    return {
+        "content": parsed["content"],
+        "tool_calls": parsed["tool_calls"],
+        "prompt_tokens": usage.input_tokens,
+        "completion_tokens": usage.output_tokens,
+        "reasoning_tokens": usage.reasoning_tokens,
+        "cache_creation_tokens": usage.cache_creation_tokens,
+        "cache_read_tokens": usage.cache_read_tokens,
+        "finish_reason": parsed["finish_reason"],
+        "cost": cost,
+        "usage": usage,
+    }
+
+
+async def openrouter_llm_call(
+    messages: List[Dict[str, Any]],
+    model: str = "anthropic/claude-sonnet-4.5",
+    tools: Optional[List[Dict]] = None,
+    **kwargs
+) -> Dict[str, Any]:
+    """
+    OpenRouter LLM 调用函数
+
+    Args:
+        messages: OpenAI 格式消息列表
+        model: 模型名称(如 "anthropic/claude-sonnet-4.5")
+        tools: OpenAI 格式工具定义
+        **kwargs: 其他参数(temperature, max_tokens 等)
+
+    Returns:
+        {
+            "content": str,
+            "tool_calls": List[Dict] | None,
+            "prompt_tokens": int,
+            "completion_tokens": int,
+            "finish_reason": str,
+            "cost": float
+        }
+    """
+    api_key = os.getenv("OPEN_ROUTER_API_KEY")
+    if not api_key:
+        raise ValueError("OPEN_ROUTER_API_KEY environment variable not set")
+
+    # Claude 模型走 Anthropic 原生端点,其余走 OpenAI 兼容端点
+    provider = _detect_provider_from_model(model)
+    if provider == "anthropic":
+        logger.debug("[OpenRouter] Routing Claude model to Anthropic native endpoint")
+        return await _openrouter_anthropic_call(messages, model, tools, api_key, **kwargs)
+
+    base_url = "https://openrouter.ai/api/v1"
+    endpoint = f"{base_url}/chat/completions"
+
+    # 跨 Provider 续跑时,重写不兼容的 tool_call_id
+    messages = _normalize_tool_call_ids(messages, "call")
+
+    # 构建请求
+    payload = {
+        "model": model,
+        "messages": messages,
+    }
+
+    # 添加可选参数
+    if tools:
+        payload["tools"] = tools
+
+    if "temperature" in kwargs:
+        payload["temperature"] = kwargs["temperature"]
+    if "max_tokens" in kwargs:
+        payload["max_tokens"] = kwargs["max_tokens"]
+
+    # OpenRouter 特定参数
+    headers = {
+        "Authorization": f"Bearer {api_key}",
+        "HTTP-Referer": "https://github.com/your-repo",  # 可选,用于统计
+        "X-Title": "Agent Framework",  # 可选,显示在 OpenRouter dashboard
+    }
+
+    # 调用 API(带重试)
+    max_retries = 3
+    last_exception = None
+    for attempt in range(max_retries):
+        async with httpx.AsyncClient(timeout=300.0) as client:
+            try:
+                response = await client.post(endpoint, json=payload, headers=headers)
+                response.raise_for_status()
+                result = response.json()
+                break  # 成功,跳出重试循环
+
+            except httpx.HTTPStatusError as e:
+                error_body = e.response.text
+                status = e.response.status_code
+                # 429 (rate limit) 和 5xx 可重试
+                if status in (429, 500, 502, 503, 504) and attempt < max_retries - 1:
+                    wait = 2 ** attempt * 2  # 2s, 4s, 8s
+                    logger.warning(
+                        "[OpenRouter] HTTP %d (attempt %d/%d), retrying in %ds: %s",
+                        status, attempt + 1, max_retries, wait, error_body[:200],
+                    )
+                    await asyncio.sleep(wait)
+                    last_exception = e
+                    continue
+                logger.error("[OpenRouter] Error %d: %s", status, error_body)
+                raise
+
+            except _RETRYABLE_EXCEPTIONS as e:
+                last_exception = e
+                if attempt < max_retries - 1:
+                    wait = 2 ** attempt * 2
+                    logger.warning(
+                        "[OpenRouter] %s (attempt %d/%d), retrying in %ds",
+                        type(e).__name__, attempt + 1, max_retries, wait,
+                    )
+                    await asyncio.sleep(wait)
+                    continue
+                logger.error("[OpenRouter] Request failed after %d attempts: %s", max_retries, e)
+                raise
+
+            except Exception as e:
+                logger.error("[OpenRouter] Request failed: %s", e)
+                raise
+    else:
+        # 所有重试都用完
+        raise last_exception  # type: ignore[misc]
+
+    # 解析响应(OpenAI 格式)
+    choice = result["choices"][0] if result.get("choices") else {}
+    message = choice.get("message", {})
+
+    content = message.get("content", "")
+    tool_calls = message.get("tool_calls")
+    finish_reason = choice.get("finish_reason")  # stop, length, tool_calls, content_filter 等
+
+    # 提取 usage(完整版,根据模型类型解析)
+    raw_usage = result.get("usage", {})
+    usage = _parse_openrouter_usage(raw_usage, model)
+
+    # 计算费用
+    cost = calculate_cost(model, usage)
+
+    return {
+        "content": content,
+        "tool_calls": tool_calls,
+        "prompt_tokens": usage.input_tokens,
+        "completion_tokens": usage.output_tokens,
+        "reasoning_tokens": usage.reasoning_tokens,
+        "cache_creation_tokens": usage.cache_creation_tokens,
+        "cache_read_tokens": usage.cache_read_tokens,
+        "finish_reason": finish_reason,
+        "cost": cost,
+        "usage": usage,  # 完整的 TokenUsage 对象
+    }
+
+
+def create_openrouter_llm_call(
+    model: str = "anthropic/claude-sonnet-4.5"
+):
+    """
+    创建 OpenRouter LLM 调用函数
+
+    Args:
+        model: 模型名称
+            - "anthropic/claude-sonnet-4.5"
+            - "anthropic/claude-opus-4.5"
+            - "openai/gpt-4o"
+            等等
+
+    Returns:
+        异步 LLM 调用函数
+    """
+    async def llm_call(
+        messages: List[Dict[str, Any]],
+        model: str = model,
+        tools: Optional[List[Dict]] = None,
+        **kwargs
+    ) -> Dict[str, Any]:
+        return await openrouter_llm_call(messages, model, tools, **kwargs)
+
+    return llm_call

+ 353 - 0
agent/llm/pricing.py

@@ -0,0 +1,353 @@
+"""
+LLM 定价计算器
+
+使用策略模式,支持:
+1. YAML 配置文件定义模型价格
+2. 不同 token 类型的差异化定价(input/output/reasoning/cache)
+3. 自动匹配模型(支持通配符)
+4. 费用计算
+
+定价单位:美元 / 1M tokens
+"""
+
+import os
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict, Any, Optional, List
+import yaml
+
+from .usage import TokenUsage
+
+
+@dataclass
+class ModelPricing:
+    """
+    单个模型的定价配置
+
+    所有价格单位:美元 / 1M tokens
+    """
+    model: str                          # 模型名称(支持通配符 *)
+    input_price: float = 0.0            # 输入 token 价格
+    output_price: float = 0.0           # 输出 token 价格
+
+    # 可选的差异化定价
+    reasoning_price: Optional[float] = None      # 推理 token 价格(默认 = output_price)
+    cache_creation_price: Optional[float] = None # 缓存创建价格(默认 = input_price * 1.25)
+    cache_read_price: Optional[float] = None     # 缓存读取价格(默认 = input_price * 0.1)
+
+    # 元数据
+    provider: Optional[str] = None      # 提供商
+    description: Optional[str] = None   # 描述
+
+    def get_reasoning_price(self) -> float:
+        """获取推理 token 价格"""
+        return self.reasoning_price if self.reasoning_price is not None else self.output_price
+
+    def get_cache_creation_price(self) -> float:
+        """获取缓存创建价格"""
+        return self.cache_creation_price if self.cache_creation_price is not None else self.input_price * 1.25
+
+    def get_cache_read_price(self) -> float:
+        """获取缓存读取价格"""
+        return self.cache_read_price if self.cache_read_price is not None else self.input_price * 0.1
+
+    def calculate_cost(self, usage: TokenUsage) -> float:
+        """
+        计算费用
+
+        Args:
+            usage: Token 使用量
+
+        Returns:
+            费用(美元)
+        """
+        cost = 0.0
+
+        # 基础输入费用
+        # 如果有缓存,需要分开计算
+        if usage.cache_read_tokens or usage.cache_creation_tokens:
+            # 普通输入 = 总输入 - 缓存读取(缓存读取部分单独计价)
+            regular_input = usage.input_tokens - usage.cache_read_tokens
+            cost += (regular_input / 1_000_000) * self.input_price
+            cost += (usage.cache_read_tokens / 1_000_000) * self.get_cache_read_price()
+            cost += (usage.cache_creation_tokens / 1_000_000) * self.get_cache_creation_price()
+        else:
+            cost += (usage.input_tokens / 1_000_000) * self.input_price
+
+        # 输出费用
+        # 如果有 reasoning tokens,需要分开计算
+        if usage.reasoning_tokens:
+            # 普通输出 = 总输出 - reasoning(reasoning 部分单独计价)
+            regular_output = usage.output_tokens - usage.reasoning_tokens
+            cost += (regular_output / 1_000_000) * self.output_price
+            cost += (usage.reasoning_tokens / 1_000_000) * self.get_reasoning_price()
+        else:
+            cost += (usage.output_tokens / 1_000_000) * self.output_price
+
+        return cost
+
+    def matches(self, model_name: str) -> bool:
+        """
+        检查模型名称是否匹配
+
+        支持通配符:
+        - "gpt-4*" 匹配 "gpt-4", "gpt-4-turbo", "gpt-4o" 等
+        - "claude-3-*" 匹配 "claude-3-opus", "claude-3-sonnet" 等
+        """
+        pattern = self.model.replace("*", ".*")
+        return bool(re.match(f"^{pattern}$", model_name, re.IGNORECASE))
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "ModelPricing":
+        """从字典创建"""
+        return cls(
+            model=data["model"],
+            input_price=data.get("input_price", 0.0),
+            output_price=data.get("output_price", 0.0),
+            reasoning_price=data.get("reasoning_price"),
+            cache_creation_price=data.get("cache_creation_price"),
+            cache_read_price=data.get("cache_read_price"),
+            provider=data.get("provider"),
+            description=data.get("description"),
+        )
+
+
+class PricingCalculator:
+    """
+    定价计算器
+
+    从 YAML 配置加载定价表,计算 LLM 调用费用
+    """
+
+    def __init__(self, config_path: Optional[str] = None):
+        """
+        初始化定价计算器
+
+        Args:
+            config_path: 定价配置文件路径,默认查找:
+                1. 环境变量 AGENT_PRICING_CONFIG
+                2. ./pricing.yaml
+                3. ./config/pricing.yaml
+                4. 使用内置默认配置
+        """
+        self._pricing_map: Dict[str, ModelPricing] = {}
+        self._patterns: List[ModelPricing] = []  # 带通配符的定价
+
+        # 加载配置
+        config_path = self._resolve_config_path(config_path)
+        if config_path and Path(config_path).exists():
+            self._load_from_file(config_path)
+        else:
+            self._load_defaults()
+
+    def _resolve_config_path(self, config_path: Optional[str]) -> Optional[str]:
+        """解析配置文件路径"""
+        if config_path:
+            return config_path
+
+        # 检查环境变量
+        if env_path := os.getenv("AGENT_PRICING_CONFIG"):
+            return env_path
+
+        # 获取 agent 包的根目录(agent/llm/pricing.py -> agent/)
+        agent_dir = Path(__file__).parent.parent
+        project_root = agent_dir.parent  # 项目根目录
+
+        # 检查默认位置(按优先级)
+        search_paths = [
+            # 1. 当前工作目录
+            Path("pricing.yaml"),
+            Path("config/pricing.yaml"),
+            # 2. 项目根目录
+            project_root / "pricing.yaml",
+            project_root / "config" / "pricing.yaml",
+            # 3. agent 包目录
+            agent_dir / "pricing.yaml",
+            agent_dir / "config" / "pricing.yaml",
+        ]
+
+        for path in search_paths:
+            if path.exists():
+                print(f"[Pricing] Loaded config from: {path}")
+                return str(path)
+
+        return None
+
+    def _load_from_file(self, config_path: str) -> None:
+        """从 YAML 文件加载配置"""
+        with open(config_path, "r", encoding="utf-8") as f:
+            config = yaml.safe_load(f)
+
+        for item in config.get("models", []):
+            pricing = ModelPricing.from_dict(item)
+            if "*" in pricing.model:
+                self._patterns.append(pricing)
+            else:
+                self._pricing_map[pricing.model.lower()] = pricing
+
+    def _load_defaults(self) -> None:
+        """加载内置默认定价"""
+        defaults = self._get_default_pricing()
+        for item in defaults:
+            pricing = ModelPricing.from_dict(item)
+            if "*" in pricing.model:
+                self._patterns.append(pricing)
+            else:
+                self._pricing_map[pricing.model.lower()] = pricing
+
+    def _get_default_pricing(self) -> List[Dict[str, Any]]:
+        """
+        内置默认定价表
+
+        价格来源:各提供商官网(2024-12 更新)
+        单位:美元 / 1M tokens
+        """
+        return [
+            # ===== OpenAI =====
+            {"model": "gpt-4o", "input_price": 2.50, "output_price": 10.00, "provider": "openai"},
+            {"model": "gpt-4o-mini", "input_price": 0.15, "output_price": 0.60, "provider": "openai"},
+            {"model": "gpt-4-turbo", "input_price": 10.00, "output_price": 30.00, "provider": "openai"},
+            {"model": "gpt-4", "input_price": 30.00, "output_price": 60.00, "provider": "openai"},
+            {"model": "gpt-3.5-turbo", "input_price": 0.50, "output_price": 1.50, "provider": "openai"},
+            # o1/o3 系列(reasoning tokens 单独计价)
+            {"model": "o1", "input_price": 15.00, "output_price": 60.00, "reasoning_price": 60.00, "provider": "openai"},
+            {"model": "o1-mini", "input_price": 3.00, "output_price": 12.00, "reasoning_price": 12.00, "provider": "openai"},
+            {"model": "o1-preview", "input_price": 15.00, "output_price": 60.00, "reasoning_price": 60.00, "provider": "openai"},
+            {"model": "o3-mini", "input_price": 1.10, "output_price": 4.40, "reasoning_price": 4.40, "provider": "openai"},
+
+            # ===== Anthropic Claude =====
+            {"model": "claude-3-5-sonnet-20241022", "input_price": 3.00, "output_price": 15.00, "provider": "anthropic"},
+            {"model": "claude-3-5-haiku-20241022", "input_price": 0.80, "output_price": 4.00, "provider": "anthropic"},
+            {"model": "claude-3-opus-20240229", "input_price": 15.00, "output_price": 75.00, "provider": "anthropic"},
+            {"model": "claude-3-sonnet-20240229", "input_price": 3.00, "output_price": 15.00, "provider": "anthropic"},
+            {"model": "claude-3-haiku-20240307", "input_price": 0.25, "output_price": 1.25, "provider": "anthropic"},
+            # Claude 通配符
+            {"model": "claude-3-5-sonnet*", "input_price": 3.00, "output_price": 15.00, "provider": "anthropic"},
+            {"model": "claude-3-opus*", "input_price": 15.00, "output_price": 75.00, "provider": "anthropic"},
+            {"model": "claude-sonnet-4*", "input_price": 3.00, "output_price": 15.00, "provider": "anthropic"},
+            {"model": "claude-opus-4*", "input_price": 15.00, "output_price": 75.00, "provider": "anthropic"},
+
+            # ===== Google Gemini =====
+            {"model": "gemini-2.0-flash", "input_price": 0.10, "output_price": 0.40, "provider": "google"},
+            {"model": "gemini-2.0-flash-thinking", "input_price": 0.10, "output_price": 0.40, "reasoning_price": 0.40, "provider": "google"},
+            {"model": "gemini-1.5-pro", "input_price": 1.25, "output_price": 5.00, "provider": "google"},
+            {"model": "gemini-1.5-flash", "input_price": 0.075, "output_price": 0.30, "provider": "google"},
+            {"model": "gemini-2.5-pro", "input_price": 1.25, "output_price": 10.00, "reasoning_price": 10.00, "provider": "google"},
+            # Gemini 通配符
+            {"model": "gemini-2.0*", "input_price": 0.10, "output_price": 0.40, "provider": "google"},
+            {"model": "gemini-1.5*", "input_price": 1.25, "output_price": 5.00, "provider": "google"},
+            {"model": "gemini-2.5*", "input_price": 1.25, "output_price": 10.00, "provider": "google"},
+
+            # ===== DeepSeek =====
+            {"model": "deepseek-chat", "input_price": 0.14, "output_price": 0.28, "provider": "deepseek"},
+            {"model": "deepseek-reasoner", "input_price": 0.55, "output_price": 2.19, "reasoning_price": 2.19, "provider": "deepseek"},
+            {"model": "deepseek-r1*", "input_price": 0.55, "output_price": 2.19, "reasoning_price": 2.19, "provider": "deepseek"},
+
+            # ===== OpenRouter 转发(使用原模型价格)=====
+            {"model": "anthropic/claude-3-5-sonnet", "input_price": 3.00, "output_price": 15.00, "provider": "openrouter"},
+            {"model": "anthropic/claude-3-opus", "input_price": 15.00, "output_price": 75.00, "provider": "openrouter"},
+            {"model": "anthropic/claude-sonnet-4*", "input_price": 3.00, "output_price": 15.00, "provider": "openrouter"},
+            {"model": "anthropic/claude-opus-4*", "input_price": 15.00, "output_price": 75.00, "provider": "openrouter"},
+            {"model": "openai/gpt-4o", "input_price": 2.50, "output_price": 10.00, "provider": "openrouter"},
+            {"model": "openai/o1*", "input_price": 15.00, "output_price": 60.00, "reasoning_price": 60.00, "provider": "openrouter"},
+            {"model": "google/gemini*", "input_price": 1.25, "output_price": 5.00, "provider": "openrouter"},
+            {"model": "deepseek/deepseek-r1*", "input_price": 0.55, "output_price": 2.19, "reasoning_price": 2.19, "provider": "openrouter"},
+
+            # ===== Yescode 代理 =====
+            {"model": "claude-sonnet-4.5", "input_price": 3.00, "output_price": 15.00, "cache_creation_price": 3.75, "cache_read_price": 0.30, "provider": "yescode"},
+        ]
+
+    def get_pricing(self, model: str) -> Optional[ModelPricing]:
+        """
+        获取模型定价
+
+        Args:
+            model: 模型名称
+
+        Returns:
+            ModelPricing 或 None(未找到)
+        """
+        model_lower = model.lower()
+
+        # 精确匹配
+        if model_lower in self._pricing_map:
+            return self._pricing_map[model_lower]
+
+        # 通配符匹配
+        for pattern in self._patterns:
+            if pattern.matches(model):
+                return pattern
+
+        return None
+
+    def calculate_cost(
+        self,
+        model: str,
+        usage: TokenUsage,
+        fallback_input_price: float = 1.0,
+        fallback_output_price: float = 2.0
+    ) -> float:
+        """
+        计算费用
+
+        Args:
+            model: 模型名称
+            usage: Token 使用量
+            fallback_input_price: 未找到定价时的默认输入价格
+            fallback_output_price: 未找到定价时的默认输出价格
+
+        Returns:
+            费用(美元)
+        """
+        pricing = self.get_pricing(model)
+
+        if pricing:
+            return pricing.calculate_cost(usage)
+
+        # 使用 fallback 价格
+        fallback = ModelPricing(
+            model=model,
+            input_price=fallback_input_price,
+            output_price=fallback_output_price
+        )
+        return fallback.calculate_cost(usage)
+
+    def add_pricing(self, pricing: ModelPricing) -> None:
+        """动态添加定价"""
+        if "*" in pricing.model:
+            self._patterns.append(pricing)
+        else:
+            self._pricing_map[pricing.model.lower()] = pricing
+
+    def list_models(self) -> List[str]:
+        """列出所有已配置的模型"""
+        models = list(self._pricing_map.keys())
+        models.extend(p.model for p in self._patterns)
+        return sorted(models)
+
+
+# 全局单例
+_calculator: Optional[PricingCalculator] = None
+
+
+def get_pricing_calculator() -> PricingCalculator:
+    """获取全局定价计算器"""
+    global _calculator
+    if _calculator is None:
+        _calculator = PricingCalculator()
+    return _calculator
+
+
+def calculate_cost(model: str, usage: TokenUsage) -> float:
+    """
+    便捷函数:计算费用
+
+    Args:
+        model: 模型名称
+        usage: Token 使用量
+
+    Returns:
+        费用(美元)
+    """
+    return get_pricing_calculator().calculate_cost(model, usage)

+ 6 - 0
agent/llm/prompts/__init__.py

@@ -0,0 +1,6 @@
+"""Prompt loading and processing utilities"""
+
+from .loader import load_prompt, get_message
+from .wrapper import SimplePrompt, create_prompt
+
+__all__ = ["load_prompt", "get_message", "SimplePrompt", "create_prompt"]

+ 190 - 0
agent/llm/prompts/loader.py

@@ -0,0 +1,190 @@
+"""
+Prompt Loader
+
+支持 .prompt 文件格式:
+- YAML frontmatter 配置(model, temperature等)
+- $section$ 分节语法
+- %variable% 参数替换
+
+格式:
+---
+model: gemini-2.5-flash
+temperature: 0.3
+---
+
+$system$
+系统提示...
+
+$user$
+用户提示...
+%variable%
+"""
+
+import re
+import yaml
+from pathlib import Path
+from typing import Dict, Any, Tuple, Union
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def load_prompt(path: Union[Path, str]) -> Tuple[Dict[str, Any], Dict[str, str]]:
+    """
+    加载 .prompt 文件
+
+    Args:
+        path: .prompt 文件路径
+
+    Returns:
+        (config, messages)
+        - config: 配置字典 {'model': 'gemini-2.5-flash', 'temperature': 0.3, ...}
+        - messages: 消息字典 {'system': '...', 'user': '...'}
+
+    Raises:
+        FileNotFoundError: 文件不存在
+        ValueError: 文件格式错误
+
+    Example:
+        >>> config, messages = load_prompt(Path("task.prompt"))
+        >>> config['model']
+        'gemini-2.5-flash'
+        >>> messages['system']
+        '你是一位计算机视觉专家...'
+    """
+    path = Path(path) if isinstance(path, str) else path
+
+    if not path.exists():
+        raise FileNotFoundError(f".prompt 文件不存在: {path}")
+
+    try:
+        content = path.read_text(encoding='utf-8')
+    except Exception as e:
+        raise ValueError(f"读取 .prompt 文件失败: {e}")
+
+    # 解析文件
+    config, messages = _parse_prompt(content)
+
+    logger.debug(f"加载 .prompt 文件: {path}, 配置项: {len(config)}, 消息段: {len(messages)}")
+
+    return config, messages
+
+
+def _parse_prompt(content: str) -> Tuple[Dict[str, Any], Dict[str, str]]:
+    """
+    解析 .prompt 文件内容
+
+    格式:
+    ---
+    model: gemini-2.5-flash
+    temperature: 0.3
+    ---
+
+    $system$
+    系统提示...
+
+    $user$
+    用户提示...
+    """
+    # 1. 分离 YAML frontmatter 和正文
+    parts = content.split('---', 2)
+
+    if len(parts) < 3:
+        raise ValueError(".prompt 文件格式错误: 缺少 YAML frontmatter(需要 --- 包裹)")
+
+    # 2. 解析 YAML 配置
+    try:
+        config = yaml.safe_load(parts[1]) or {}
+    except yaml.YAMLError as e:
+        raise ValueError(f".prompt 文件 YAML 解析失败: {e}")
+
+    # 3. 解析正文(按 $section$ 分割)
+    body = parts[2]
+    messages = _parse_sections(body)
+
+    return config, messages
+
+
+def _parse_sections(body: str) -> Dict[str, str]:
+    """
+    解析 .prompt 正文分节
+
+    支持语法:
+    - $section$ (如 $system$, $user$)
+
+    Args:
+        body: .prompt 正文内容
+
+    Returns:
+        消息字典 {'system': '...', 'user': '...'}
+
+    Example:
+        >>> body = "$system$\\n你好\\n$user$\\n世界"
+        >>> _parse_sections(body)
+        {'system': '你好', 'user': '世界'}
+    """
+    messages = {}
+
+    # 使用正则表达式分割(匹配 $key$)
+    pattern = r'\$([^$]+)\$'
+    parts = re.split(pattern, body)
+
+    # parts 格式:['前置空白', 'key1', '内容1', 'key2', '内容2', ...]
+    # 跳过 parts[0](前置空白)
+    i = 1
+    while i < len(parts):
+        if i + 1 >= len(parts):
+            break
+
+        key = parts[i].strip()
+        value = parts[i + 1].strip()
+
+        if key and value:
+            messages[key] = value
+
+        i += 2
+
+    if not messages:
+        logger.warning(".prompt 文件没有找到任何分节($section$)")
+
+    return messages
+
+
+def get_message(messages: Dict[str, str], key: str, **params) -> str:
+    """
+    获取消息(带参数替换)
+
+    参数替换:
+    - 使用 %variable% 语法
+    - 直接字符串替换
+
+    Args:
+        messages: 消息字典(来自 load_prompt)
+        key: 消息键(如 'system', 'user')
+        **params: 参数替换(如 text='内容')
+
+    Returns:
+        替换后的消息字符串
+
+    Example:
+        >>> messages = {'user': '特征:%text%'}
+        >>> get_message(messages, 'user', text='整体构图')
+        '特征:整体构图'
+    """
+    message = messages.get(key, "")
+
+    if not message:
+        logger.warning(f".prompt 消息未找到: key='{key}'")
+        return ""
+
+    # 参数替换(%variable% 直接替换)
+    if params:
+        try:
+            for param_name, param_value in params.items():
+                placeholder = f"%{param_name}%"
+                if placeholder in message:
+                    message = message.replace(placeholder, str(param_value))
+        except Exception as e:
+            logger.error(f".prompt 参数替换错误: key='{key}', error={e}")
+
+    return message

+ 168 - 0
agent/llm/prompts/wrapper.py

@@ -0,0 +1,168 @@
+"""
+Prompt Wrapper - 为 .prompt 文件提供 Prompt 实现
+
+类似 Resonote 的 SimpleHPrompt,但增加了多模态支持
+"""
+
+import base64
+from pathlib import Path
+from typing import List, Dict, Any, Union, Optional
+from agent.llm.prompts.loader import load_prompt, get_message
+
+
+class SimplePrompt:
+    """
+    通用的 Prompt 包装器
+
+    特性:
+    - 加载 .prompt 文件(YAML frontmatter + sections)
+    - 支持参数替换(%variable%)
+    - 支持多模态消息(图片)
+
+    使用示例:
+        # 纯文本
+        prompt = SimplePrompt(Path("task.prompt"))
+        messages = prompt.build_messages(text="内容")
+
+        # 多模态(文本 + 图片)
+        messages = prompt.build_messages(
+            text="分析这张图片",
+            images="path/to/image.png"  # 或 images=["img1.png", "img2.png"]
+        )
+    """
+
+    def __init__(self, prompt_path: Union[Path, str]):
+        """
+        Args:
+            prompt_path: .prompt 文件路径
+        """
+        self.prompt_path = Path(prompt_path) if isinstance(prompt_path, str) else prompt_path
+
+        # 加载 .prompt 文件
+        self.config, self._messages = load_prompt(self.prompt_path)
+
+    def build_messages(self, **context) -> List[Dict[str, Any]]:
+        """
+        构造消息列表(支持多模态)
+
+        Args:
+            **context: 参数
+                - 普通参数:用于替换 %variable%
+                - images: 图片资源(可选)
+                  - 单个图片:str 或 Path
+                  - 多个图片:List[str | Path]
+                  - 格式:文件路径或 base64 字符串
+
+        Returns:
+            消息列表,格式遵循 OpenAI API 规范
+
+        Example:
+            >>> messages = prompt.build_messages(
+            ...     text="特征描述",
+            ...     images="input/image.png"
+            ... )
+            [
+                {"role": "system", "content": "..."},
+                {
+                    "role": "user",
+                    "content": [
+                        {"type": "text", "text": "..."},
+                        {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
+                    ]
+                }
+            ]
+        """
+        # 提取图片资源(从 context 中移除,避免传入 get_message)
+        images = context.pop('images', None)
+
+        # 构建文本内容(支持参数替换)
+        system_content = get_message(self._messages, 'system', **context)
+        user_content = get_message(self._messages, 'user', **context)
+
+        messages = []
+
+        # 添加 system 消息
+        if system_content:
+            messages.append({"role": "system", "content": system_content})
+
+        # 添加 user 消息(可能是多模态)
+        if images:
+            # 多模态消息
+            user_message = {"role": "user", "content": []}
+
+            # 添加文本部分
+            if user_content:
+                user_message["content"].append({
+                    "type": "text",
+                    "text": user_content
+                })
+
+            # 添加图片部分
+            if isinstance(images, (list, tuple)):
+                for img in images:
+                    user_message["content"].append(self._build_image_content(img))
+            else:
+                user_message["content"].append(self._build_image_content(images))
+
+            messages.append(user_message)
+        else:
+            # 纯文本消息
+            if user_content:
+                messages.append({"role": "user", "content": user_content})
+
+        return messages
+
+    def _build_image_content(self, image: Union[str, Path]) -> Dict[str, Any]:
+        """
+        构建图片内容部分(OpenAI 格式)
+
+        Args:
+            image: 图片路径或 base64 字符串
+
+        Returns:
+            {"type": "image_url", "image_url": {"url": "data:..."}}
+        """
+        # 如果已经是 base64 data URL,直接使用
+        if isinstance(image, str) and image.startswith("data:"):
+            return {
+                "type": "image_url",
+                "image_url": {"url": image}
+            }
+
+        # 否则,读取文件并转为 base64
+        image_path = Path(image) if isinstance(image, str) else image
+
+        # 推断 MIME type
+        suffix = image_path.suffix.lower()
+        mime_type_map = {
+            '.png': 'image/png',
+            '.jpg': 'image/jpeg',
+            '.jpeg': 'image/jpeg',
+            '.gif': 'image/gif',
+            '.webp': 'image/webp'
+        }
+        mime_type = mime_type_map.get(suffix, 'image/png')
+
+        # 读取并编码
+        with open(image_path, 'rb') as f:
+            image_data = base64.b64encode(f.read()).decode('utf-8')
+
+        data_url = f"data:{mime_type};base64,{image_data}"
+
+        return {
+            "type": "image_url",
+            "image_url": {"url": data_url}
+        }
+
+
+def create_prompt(prompt_path: Union[Path, str]) -> SimplePrompt:
+    """
+    工厂函数:创建 SimplePrompt 实例
+
+    Args:
+        prompt_path: .prompt 文件路径
+
+    Returns:
+        SimplePrompt 实例
+    """
+    return SimplePrompt(prompt_path)

+ 297 - 0
agent/llm/usage.py

@@ -0,0 +1,297 @@
+"""
+Token Usage 数据模型和费用计算
+
+支持各种 LLM 提供商的完整 token 统计:
+- 基础 tokens: input/output
+- 思考 tokens: reasoning/thinking (OpenAI o1/o3, DeepSeek R1, Gemini 2.x)
+- 缓存 tokens: cache_creation/cache_read (Claude)
+- 其他: cached_content (Gemini)
+
+设计模式:
+- TokenUsage: 不可变数据类,表示单次调用的 token 使用
+- TokenUsageAccumulator: 累加器,用于统计多次调用
+- PricingCalculator: 策略模式,根据定价表计算费用
+"""
+
+from dataclasses import dataclass, field
+from typing import Dict, Any, Optional
+import copy
+
+
+@dataclass(frozen=True)
+class TokenUsage:
+    """
+    Token 使用量(不可变)
+
+    统一所有提供商的 token 统计字段,未使用的字段为 0
+    """
+    # 基础 tokens(所有提供商都有)
+    input_tokens: int = 0           # 输入 tokens (prompt_tokens)
+    output_tokens: int = 0          # 输出 tokens (completion_tokens)
+
+    # 思考/推理 tokens(部分模型)
+    # - OpenAI o1/o3: reasoning_tokens (在 completion_tokens_details 中)
+    # - DeepSeek R1: reasoning_tokens
+    # - Gemini 2.x thinking mode: thoughts_tokens
+    reasoning_tokens: int = 0
+
+    # 缓存相关 tokens(Claude)
+    # - cache_creation_input_tokens: 创建缓存消耗的 tokens
+    # - cache_read_input_tokens: 读取缓存的 tokens(通常更便宜)
+    cache_creation_tokens: int = 0
+    cache_read_tokens: int = 0
+
+    # Gemini 特有
+    cached_content_tokens: int = 0  # cachedContentTokenCount
+
+    @property
+    def total_tokens(self) -> int:
+        """总 tokens(input + output,不含 reasoning)"""
+        return self.input_tokens + self.output_tokens
+
+    @property
+    def total_input_tokens(self) -> int:
+        """
+        总输入 tokens
+
+        对于 Claude 带缓存的情况:
+        实际输入 = input_tokens(已包含 cache_read)
+        计费输入 = input_tokens - cache_read_tokens + cache_creation_tokens
+        """
+        return self.input_tokens
+
+    @property
+    def total_output_tokens(self) -> int:
+        """
+        总输出 tokens
+
+        对于有 reasoning 的模型:
+        output_tokens 通常已包含 reasoning_tokens
+        """
+        return self.output_tokens
+
+    @property
+    def billable_input_tokens(self) -> int:
+        """
+        计费输入 tokens(考虑缓存折扣)
+
+        Claude 缓存定价:
+        - cache_read: 0.1x 价格
+        - cache_creation: 1.25x 价格
+        - 普通 input: 1x 价格
+
+        这里返回等效的全价 tokens 数
+        """
+        # 普通输入 = 总输入 - 缓存读取
+        regular_input = self.input_tokens - self.cache_read_tokens
+        # 等效计费 = 普通输入 + 缓存读取*0.1 + 缓存创建*1.25
+        # 简化:返回原始值,让 PricingCalculator 处理
+        return self.input_tokens
+
+    def __add__(self, other: "TokenUsage") -> "TokenUsage":
+        """支持 + 运算符累加"""
+        if not isinstance(other, TokenUsage):
+            return NotImplemented
+        return TokenUsage(
+            input_tokens=self.input_tokens + other.input_tokens,
+            output_tokens=self.output_tokens + other.output_tokens,
+            reasoning_tokens=self.reasoning_tokens + other.reasoning_tokens,
+            cache_creation_tokens=self.cache_creation_tokens + other.cache_creation_tokens,
+            cache_read_tokens=self.cache_read_tokens + other.cache_read_tokens,
+            cached_content_tokens=self.cached_content_tokens + other.cached_content_tokens,
+        )
+
+    def to_dict(self) -> Dict[str, Any]:
+        """转换为字典(只包含非零字段)"""
+        result = {
+            "input_tokens": self.input_tokens,
+            "output_tokens": self.output_tokens,
+            "total_tokens": self.total_tokens,
+        }
+        # 只添加非零的可选字段
+        if self.reasoning_tokens:
+            result["reasoning_tokens"] = self.reasoning_tokens
+        if self.cache_creation_tokens:
+            result["cache_creation_tokens"] = self.cache_creation_tokens
+        if self.cache_read_tokens:
+            result["cache_read_tokens"] = self.cache_read_tokens
+        if self.cached_content_tokens:
+            result["cached_content_tokens"] = self.cached_content_tokens
+        return result
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "TokenUsage":
+        """从字典创建(兼容旧格式)"""
+        return cls(
+            input_tokens=data.get("input_tokens") or data.get("prompt_tokens", 0),
+            output_tokens=data.get("output_tokens") or data.get("completion_tokens", 0),
+            reasoning_tokens=data.get("reasoning_tokens", 0),
+            cache_creation_tokens=data.get("cache_creation_tokens", 0),
+            cache_read_tokens=data.get("cache_read_tokens", 0),
+            cached_content_tokens=data.get("cached_content_tokens", 0),
+        )
+
+    @classmethod
+    def from_openai(cls, usage: Dict[str, Any]) -> "TokenUsage":
+        """
+        从 OpenAI 格式创建
+
+        OpenAI 格式:
+        {
+            "prompt_tokens": 100,
+            "completion_tokens": 50,
+            "total_tokens": 150,
+            "completion_tokens_details": {
+                "reasoning_tokens": 20  # o1/o3 模型
+            }
+        }
+        """
+        reasoning = 0
+        if details := usage.get("completion_tokens_details"):
+            reasoning = details.get("reasoning_tokens", 0)
+
+        return cls(
+            input_tokens=usage.get("prompt_tokens", 0),
+            output_tokens=usage.get("completion_tokens", 0),
+            reasoning_tokens=reasoning,
+        )
+
+    @classmethod
+    def from_anthropic(cls, usage: Dict[str, Any]) -> "TokenUsage":
+        """
+        从 Anthropic/Claude 格式创建
+
+        Claude 格式:
+        {
+            "input_tokens": 100,
+            "output_tokens": 50,
+            "cache_creation_input_tokens": 1000,  # 可选
+            "cache_read_input_tokens": 500        # 可选
+        }
+        """
+        return cls(
+            input_tokens=usage.get("input_tokens", 0),
+            output_tokens=usage.get("output_tokens", 0),
+            cache_creation_tokens=usage.get("cache_creation_input_tokens", 0),
+            cache_read_tokens=usage.get("cache_read_input_tokens", 0),
+        )
+
+    @classmethod
+    def from_gemini(cls, usage_metadata: Dict[str, Any]) -> "TokenUsage":
+        """
+        从 Gemini 格式创建
+
+        Gemini 格式:
+        {
+            "promptTokenCount": 100,
+            "candidatesTokenCount": 50,
+            "totalTokenCount": 150,
+            "cachedContentTokenCount": 0,    # 可选
+            "thoughtsTokenCount": 20          # Gemini 2.x thinking mode
+        }
+        """
+        return cls(
+            input_tokens=usage_metadata.get("promptTokenCount", 0),
+            output_tokens=usage_metadata.get("candidatesTokenCount", 0),
+            reasoning_tokens=usage_metadata.get("thoughtsTokenCount", 0),
+            cached_content_tokens=usage_metadata.get("cachedContentTokenCount", 0),
+        )
+
+    @classmethod
+    def from_deepseek(cls, usage: Dict[str, Any]) -> "TokenUsage":
+        """
+        从 DeepSeek 格式创建
+
+        DeepSeek R1 格式(OpenAI 兼容 + 扩展):
+        {
+            "prompt_tokens": 100,
+            "completion_tokens": 50,
+            "reasoning_tokens": 30,  # DeepSeek R1 特有
+            "total_tokens": 150
+        }
+        """
+        return cls(
+            input_tokens=usage.get("prompt_tokens", 0),
+            output_tokens=usage.get("completion_tokens", 0),
+            reasoning_tokens=usage.get("reasoning_tokens", 0),
+        )
+
+
+class TokenUsageAccumulator:
+    """
+    Token 使用量累加器
+
+    用于在 Trace 级别累计多次 LLM 调用的 token 使用
+    """
+
+    def __init__(self):
+        self._input_tokens: int = 0
+        self._output_tokens: int = 0
+        self._reasoning_tokens: int = 0
+        self._cache_creation_tokens: int = 0
+        self._cache_read_tokens: int = 0
+        self._cached_content_tokens: int = 0
+        self._call_count: int = 0
+
+    def add(self, usage: TokenUsage) -> None:
+        """累加一次调用的 token 使用"""
+        self._input_tokens += usage.input_tokens
+        self._output_tokens += usage.output_tokens
+        self._reasoning_tokens += usage.reasoning_tokens
+        self._cache_creation_tokens += usage.cache_creation_tokens
+        self._cache_read_tokens += usage.cache_read_tokens
+        self._cached_content_tokens += usage.cached_content_tokens
+        self._call_count += 1
+
+    @property
+    def total(self) -> TokenUsage:
+        """获取累计的 TokenUsage"""
+        return TokenUsage(
+            input_tokens=self._input_tokens,
+            output_tokens=self._output_tokens,
+            reasoning_tokens=self._reasoning_tokens,
+            cache_creation_tokens=self._cache_creation_tokens,
+            cache_read_tokens=self._cache_read_tokens,
+            cached_content_tokens=self._cached_content_tokens,
+        )
+
+    @property
+    def call_count(self) -> int:
+        """调用次数"""
+        return self._call_count
+
+    def to_dict(self) -> Dict[str, Any]:
+        """转换为字典"""
+        result = self.total.to_dict()
+        result["call_count"] = self._call_count
+        return result
+
+
+# 向后兼容的别名
+def create_usage_from_response(
+    provider: str,
+    usage_data: Dict[str, Any]
+) -> TokenUsage:
+    """
+    根据提供商创建 TokenUsage
+
+    Args:
+        provider: 提供商名称 ("openai", "anthropic", "gemini", "deepseek", "openrouter")
+        usage_data: API 返回的 usage 数据
+
+    Returns:
+        TokenUsage 实例
+    """
+    provider = provider.lower()
+
+    if provider in ("openai", "openrouter"):
+        return TokenUsage.from_openai(usage_data)
+    elif provider in ("anthropic", "claude"):
+        return TokenUsage.from_anthropic(usage_data)
+    elif provider == "gemini":
+        return TokenUsage.from_gemini(usage_data)
+    elif provider == "deepseek":
+        return TokenUsage.from_deepseek(usage_data)
+    else:
+        # 默认使用 OpenAI 格式
+        return TokenUsage.from_openai(usage_data)

+ 488 - 0
agent/llm/yescode.py

@@ -0,0 +1,488 @@
+"""
+Yescode Provider
+
+使用 Yescode 代理 API 调用 Claude 等模型
+使用 Anthropic Messages API 格式(/v1/messages)
+
+环境变量:
+- YESCODE_BASE_URL: API 基础地址(如 https://co.yes.vg)
+- YESCODE_API_KEY: API 密钥
+
+注意:
+- Yescode 代理要求 User-Agent 包含 "claude-code"
+- 使用 Anthropic 原生 Messages API 格式
+- 响应格式转换为框架统一的 OpenAI 兼容格式
+"""
+
+import os
+import json
+import asyncio
+import logging
+import httpx
+from typing import List, Dict, Any, Optional
+
+from .usage import TokenUsage
+from .pricing import calculate_cost
+
+logger = logging.getLogger(__name__)
+
+# 可重试的异常类型
+_RETRYABLE_EXCEPTIONS = (
+    httpx.RemoteProtocolError,
+    httpx.ConnectError,
+    httpx.ReadTimeout,
+    httpx.WriteTimeout,
+    httpx.ConnectTimeout,
+    httpx.PoolTimeout,
+    ConnectionError,
+)
+
+# 模糊匹配规则:(关键词, 目标模型名),从精确到宽泛排序
+# 精确匹配走 MODEL_EXACT,不命中则按顺序尝试关键词匹配
+MODEL_EXACT = {
+    "claude-sonnet-4-6": "claude-sonnet-4-6",
+    "claude-sonnet-4.6": "claude-sonnet-4-6",
+    "claude-sonnet-4-5-20250929": "claude-sonnet-4-5-20250929",
+    "claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
+    "claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
+    "claude-opus-4-6": "claude-opus-4-6",
+    "claude-opus-4-5-20251101": "claude-opus-4-5-20251101",
+    "claude-opus-4-5": "claude-opus-4-5-20251101",
+    "claude-opus-4-1-20250805": "claude-opus-4-1-20250805",
+    "claude-opus-4-1": "claude-opus-4-1-20250805",
+    "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
+    "claude-haiku-4-5": "claude-haiku-4-5-20251001",
+}
+
+MODEL_FUZZY = [
+    # 版本+家族(精确)
+    ("sonnet-4-6", "claude-sonnet-4-6"),
+    ("sonnet-4.6", "claude-sonnet-4-6"),
+    ("sonnet-4-5", "claude-sonnet-4-5-20250929"),
+    ("sonnet-4.5", "claude-sonnet-4-5-20250929"),
+    ("opus-4-6", "claude-opus-4-6"),
+    ("opus-4.6", "claude-opus-4-6"),
+    ("opus-4-5", "claude-opus-4-5-20251101"),
+    ("opus-4.5", "claude-opus-4-5-20251101"),
+    ("opus-4-1", "claude-opus-4-1-20250805"),
+    ("opus-4.1", "claude-opus-4-1-20250805"),
+    ("haiku-4-5", "claude-haiku-4-5-20251001"),
+    ("haiku-4.5", "claude-haiku-4-5-20251001"),
+    # 仅家族名 → 最新版本
+    ("sonnet", "claude-sonnet-4-6"),
+    ("opus", "claude-opus-4-6"),
+    ("haiku", "claude-haiku-4-5-20251001"),
+]
+
+
+def _resolve_model(model: str) -> str:
+    """将任意格式的模型名映射为 Yescode API 接受的模型名。
+    支持:OpenRouter 前缀(anthropic/xxx)、带点号(4.5)、纯家族名(sonnet)等。
+    """
+    # 1. 剥离 provider 前缀
+    if "/" in model:
+        model = model.split("/", 1)[1]
+
+    # 2. 精确匹配
+    if model in MODEL_EXACT:
+        return MODEL_EXACT[model]
+
+    # 3. 模糊匹配(大小写不敏感)
+    model_lower = model.lower()
+    for keyword, target in MODEL_FUZZY:
+        if keyword in model_lower:
+            logger.info("模型名模糊匹配: %s → %s", model, target)
+            return target
+
+    # 4. 兜底:原样返回,让 API 报错
+    logger.warning("未能匹配模型名: %s, 原样传递", model)
+    return model
+
+
+def _normalize_tool_call_ids(messages: List[Dict[str, Any]], target_prefix: str) -> List[Dict[str, Any]]:
+    """
+    将消息历史中的 tool_call_id 统一重写为目标 Provider 的格式。
+    跨 Provider 续跑时,历史中的 tool_call_id 可能不兼容目标 API
+    (如 Anthropic 的 toolu_xxx 发给 OpenAI,或 OpenAI 的 call_xxx 发给 Anthropic)。
+    仅在检测到异格式 ID 时才重写,同格式直接跳过。
+    """
+    # 第一遍:收集需要重写的 ID
+    id_map: Dict[str, str] = {}
+    counter = 0
+    for msg in messages:
+        if msg.get("role") == "assistant" and msg.get("tool_calls"):
+            for tc in msg["tool_calls"]:
+                old_id = tc.get("id", "")
+                if old_id and not old_id.startswith(target_prefix + "_"):
+                    if old_id not in id_map:
+                        id_map[old_id] = f"{target_prefix}_{counter:06x}"
+                        counter += 1
+
+    if not id_map:
+        return messages  # 无需重写
+
+    logger.info("重写 %d 个 tool_call_id (target_prefix=%s)", len(id_map), target_prefix)
+
+    # 第二遍:重写(浅拷贝避免修改原始数据)
+    result = []
+    for msg in messages:
+        if msg.get("role") == "assistant" and msg.get("tool_calls"):
+            new_tcs = []
+            for tc in msg["tool_calls"]:
+                old_id = tc.get("id", "")
+                if old_id in id_map:
+                    new_tcs.append({**tc, "id": id_map[old_id]})
+                else:
+                    new_tcs.append(tc)
+            result.append({**msg, "tool_calls": new_tcs})
+        elif msg.get("role") == "tool" and msg.get("tool_call_id") in id_map:
+            result.append({**msg, "tool_call_id": id_map[msg["tool_call_id"]]})
+        else:
+            result.append(msg)
+
+    return result
+
+
+def _convert_content_to_anthropic(content: Any) -> Any:
+    """
+    将 OpenAI 格式的 content(字符串或列表)转换为 Anthropic 格式。
+    主要处理 image_url 类型块 → Anthropic image 块。
+    """
+    if not isinstance(content, list):
+        return content
+
+    result = []
+    for block in content:
+        if not isinstance(block, dict):
+            result.append(block)
+            continue
+
+        block_type = block.get("type", "")
+        if block_type == "image_url":
+            image_url_obj = block.get("image_url", {})
+            url = image_url_obj.get("url", "") if isinstance(image_url_obj, dict) else str(image_url_obj)
+            if url.startswith("data:"):
+                # base64 编码图片:data:<media_type>;base64,<data>
+                header, _, data = url.partition(",")
+                media_type = header.split(":")[1].split(";")[0] if ":" in header else "image/png"
+                result.append({
+                    "type": "image",
+                    "source": {
+                        "type": "base64",
+                        "media_type": media_type,
+                        "data": data,
+                    },
+                })
+            else:
+                result.append({
+                    "type": "image",
+                    "source": {
+                        "type": "url",
+                        "url": url,
+                    },
+                })
+        else:
+            result.append(block)
+    return result
+
+
+def _convert_messages_to_anthropic(messages: List[Dict[str, Any]]) -> tuple:
+    """
+    将 OpenAI 格式消息转换为 Anthropic Messages API 格式
+
+    Returns:
+        (system_prompt, anthropic_messages)
+    """
+    system_prompt = None
+    anthropic_messages = []
+
+    for msg in messages:
+        role = msg.get("role", "")
+        content = msg.get("content", "")
+
+        if role == "system":
+            # Anthropic 把 system 消息放在顶层参数中
+            system_prompt = content
+        elif role == "user":
+            anthropic_messages.append({"role": "user", "content": _convert_content_to_anthropic(content)})
+        elif role == "assistant":
+            assistant_msg = {"role": "assistant"}
+            # 处理 tool_calls(assistant 发起工具调用)
+            tool_calls = msg.get("tool_calls")
+            if tool_calls:
+                content_blocks = []
+                if content:
+                    # content 可能已被 _add_cache_control 转成 list(含 cache_control),
+                    # 也可能是普通字符串。两者都需要正确处理,避免产生 {"type":"text","text":[...]}
+                    converted = _convert_content_to_anthropic(content)
+                    if isinstance(converted, list):
+                        content_blocks.extend(converted)
+                    elif isinstance(converted, str) and converted.strip():
+                        content_blocks.append({"type": "text", "text": converted})
+                for tc in tool_calls:
+                    func = tc.get("function", {})
+                    args_str = func.get("arguments", "{}")
+                    try:
+                        args = json.loads(args_str) if isinstance(args_str, str) else args_str
+                    except json.JSONDecodeError:
+                        args = {}
+                    content_blocks.append({
+                        "type": "tool_use",
+                        "id": tc.get("id", ""),
+                        "name": func.get("name", ""),
+                        "input": args,
+                    })
+                assistant_msg["content"] = content_blocks
+            else:
+                assistant_msg["content"] = content
+            anthropic_messages.append(assistant_msg)
+        elif role == "tool":
+            # OpenAI tool 结果 -> Anthropic tool_result
+            # Anthropic 要求同一个 assistant 的所有 tool_results 合并到一个 user message 中
+            tool_result_block = {
+                "type": "tool_result",
+                "tool_use_id": msg.get("tool_call_id", ""),
+                "content": _convert_content_to_anthropic(content),
+            }
+            # 如果上一条已经是 tool_result user message,合并进去
+            if (anthropic_messages
+                    and anthropic_messages[-1].get("role") == "user"
+                    and isinstance(anthropic_messages[-1].get("content"), list)
+                    and anthropic_messages[-1]["content"]
+                    and anthropic_messages[-1]["content"][0].get("type") == "tool_result"):
+                anthropic_messages[-1]["content"].append(tool_result_block)
+            else:
+                anthropic_messages.append({
+                    "role": "user",
+                    "content": [tool_result_block],
+                })
+
+    return system_prompt, anthropic_messages
+
+
+def _convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
+    """将 OpenAI 工具定义转换为 Anthropic 格式"""
+    anthropic_tools = []
+    for tool in tools:
+        if tool.get("type") == "function":
+            func = tool["function"]
+            anthropic_tools.append({
+                "name": func.get("name", ""),
+                "description": func.get("description", ""),
+                "input_schema": func.get("parameters", {"type": "object", "properties": {}}),
+            })
+    return anthropic_tools
+
+
+def _parse_anthropic_response(result: Dict[str, Any]) -> Dict[str, Any]:
+    """
+    将 Anthropic Messages API 响应转换为框架统一格式
+
+    Anthropic 响应格式:
+    {
+        "id": "msg_...",
+        "type": "message",
+        "role": "assistant",
+        "content": [{"type": "text", "text": "..."}, {"type": "tool_use", ...}],
+        "usage": {"input_tokens": ..., "output_tokens": ...},
+        "stop_reason": "end_turn" | "tool_use" | "max_tokens"
+    }
+    """
+    content_blocks = result.get("content", [])
+
+    # 提取文本内容
+    text_parts = []
+    tool_calls = []
+    for block in content_blocks:
+        if block.get("type") == "text":
+            text_parts.append(block.get("text", ""))
+        elif block.get("type") == "tool_use":
+            # 转换为 OpenAI tool_calls 格式
+            tool_calls.append({
+                "id": block.get("id", ""),
+                "type": "function",
+                "function": {
+                    "name": block.get("name", ""),
+                    "arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
+                },
+            })
+
+    content = "\n".join(text_parts)
+
+    # 映射 stop_reason
+    stop_reason = result.get("stop_reason", "end_turn")
+    finish_reason_map = {
+        "end_turn": "stop",
+        "tool_use": "tool_calls",
+        "max_tokens": "length",
+        "stop_sequence": "stop",
+    }
+    finish_reason = finish_reason_map.get(stop_reason, stop_reason)
+
+    # 提取 usage(Anthropic 原生格式)
+    raw_usage = result.get("usage", {})
+    usage = TokenUsage(
+        input_tokens=raw_usage.get("input_tokens", 0),
+        output_tokens=raw_usage.get("output_tokens", 0),
+        cache_creation_tokens=raw_usage.get("cache_creation_input_tokens", 0),
+        cache_read_tokens=raw_usage.get("cache_read_input_tokens", 0),
+    )
+
+    return {
+        "content": content,
+        "tool_calls": tool_calls if tool_calls else None,
+        "finish_reason": finish_reason,
+        "usage": usage,
+    }
+
+
+async def yescode_llm_call(
+    messages: List[Dict[str, Any]],
+    model: str = "claude-sonnet-4.5",
+    tools: Optional[List[Dict]] = None,
+    **kwargs
+) -> Dict[str, Any]:
+    """
+    Yescode LLM 调用函数
+
+    Args:
+        messages: OpenAI 格式消息列表
+        model: 模型名称(如 "claude-sonnet-4.5")
+        tools: OpenAI 格式工具定义
+        **kwargs: 其他参数(temperature, max_tokens 等)
+
+    Returns:
+        统一格式的响应字典
+    """
+    base_url = os.getenv("YESCODE_BASE_URL")
+    api_key = os.getenv("YESCODE_API_KEY")
+
+    if not base_url:
+        raise ValueError("YESCODE_BASE_URL environment variable not set")
+    if not api_key:
+        raise ValueError("YESCODE_API_KEY environment variable not set")
+
+    base_url = base_url.rstrip("/")
+    endpoint = f"{base_url}/v1/messages"
+
+    # 解析模型名
+    api_model = _resolve_model(model)
+
+    # 跨 Provider 续跑时,重写不兼容的 tool_call_id
+    messages = _normalize_tool_call_ids(messages, "toolu")
+
+    # 转换消息格式
+    system_prompt, anthropic_messages = _convert_messages_to_anthropic(messages)
+
+    # 构建 Anthropic 格式请求
+    payload = {
+        "model": api_model,
+        "messages": anthropic_messages,
+        "max_tokens": kwargs.get("max_tokens", 16384),
+    }
+
+    if system_prompt:
+        payload["system"] = system_prompt
+
+    if tools:
+        payload["tools"] = _convert_tools_to_anthropic(tools)
+
+    if "temperature" in kwargs:
+        payload["temperature"] = kwargs["temperature"]
+
+    headers = {
+        "x-api-key": api_key,
+        "content-type": "application/json",
+        "anthropic-version": "2023-06-01",
+        "user-agent": "claude-code/1.0.0",
+    }
+
+    # 调用 API(带重试)
+    max_retries = 5
+    last_exception = None
+    for attempt in range(max_retries):
+        async with httpx.AsyncClient(timeout=300.0) as client:
+            try:
+                response = await client.post(endpoint, json=payload, headers=headers)
+                response.raise_for_status()
+                result = response.json()
+                break
+
+            except httpx.HTTPStatusError as e:
+                error_body = e.response.text
+                status = e.response.status_code
+                if status in (429, 500, 502, 503, 504, 524, 529) and attempt < max_retries - 1:
+                    wait = 2 ** attempt * 2
+                    logger.warning(
+                        "[Yescode] HTTP %d (attempt %d/%d), retrying in %ds: %s",
+                        status, attempt + 1, max_retries, wait, error_body[:200],
+                    )
+                    await asyncio.sleep(wait)
+                    last_exception = e
+                    continue
+                logger.error("[Yescode] Error %d: %s", status, error_body)
+                print(f"[Yescode] API Error {status}: {error_body[:500]}")
+                raise
+
+            except _RETRYABLE_EXCEPTIONS as e:
+                last_exception = e
+                if attempt < max_retries - 1:
+                    wait = 2 ** attempt * 2
+                    logger.warning(
+                        "[Yescode] %s (attempt %d/%d), retrying in %ds",
+                        type(e).__name__, attempt + 1, max_retries, wait,
+                    )
+                    await asyncio.sleep(wait)
+                    continue
+                logger.error("[Yescode] Request failed after %d attempts: %s", max_retries, e)
+                raise
+
+            except Exception as e:
+                logger.error("[Yescode] Request failed: %s", e)
+                raise
+    else:
+        raise last_exception  # type: ignore[misc]
+
+    # 解析 Anthropic 响应并转换为统一格式
+    parsed = _parse_anthropic_response(result)
+    usage = parsed["usage"]
+
+    # 计算费用
+    cost = calculate_cost(model, usage)
+
+    return {
+        "content": parsed["content"],
+        "tool_calls": parsed["tool_calls"],
+        "prompt_tokens": usage.input_tokens,
+        "completion_tokens": usage.output_tokens,
+        "reasoning_tokens": usage.reasoning_tokens,
+        "cache_creation_tokens": usage.cache_creation_tokens,
+        "cache_read_tokens": usage.cache_read_tokens,
+        "finish_reason": parsed["finish_reason"],
+        "cost": cost,
+        "usage": usage,
+    }
+
+
+def create_yescode_llm_call(
+    model: str = "claude-sonnet-4.5"
+):
+    """
+    创建 Yescode LLM 调用函数
+
+    Args:
+        model: 模型名称
+            - "claude-sonnet-4.5"
+
+    Returns:
+        异步 LLM 调用函数
+    """
+    async def llm_call(
+        messages: List[Dict[str, Any]],
+        model: str = model,
+        tools: Optional[List[Dict]] = None,
+        **kwargs
+    ) -> Dict[str, Any]:
+        return await yescode_llm_call(messages, model, tools, **kwargs)
+
+    return llm_call

+ 37 - 0
agent/memory/__init__.py

@@ -0,0 +1,37 @@
+"""
+Memory - 记忆系统
+
+核心职责:
+1. Experience 和 Skill 数据模型
+2. MemoryStore 和 StateStore 接口定义
+3. 内存存储实现(MemoryMemoryStore, MemoryStateStore)
+4. Skill 加载器(从 markdown 加载技能)
+"""
+
+# 数据模型
+from agent.memory.models import Experience, Skill
+
+# 存储接口
+from agent.memory.protocols import MemoryStore, StateStore
+
+# 内存存储实现
+from agent.memory.stores import MemoryMemoryStore, MemoryStateStore
+
+# Skill 加载器
+from agent.memory.skill_loader import SkillLoader, load_skills_from_dir
+
+
+__all__ = [
+    # 模型
+    "Experience",
+    "Skill",
+    # 存储接口
+    "MemoryStore",
+    "StateStore",
+    # 存储实现
+    "MemoryMemoryStore",
+    "MemoryStateStore",
+    # Skill 加载
+    "SkillLoader",
+    "load_skills_from_dir",
+]

+ 177 - 0
agent/memory/models.py

@@ -0,0 +1,177 @@
+"""
+Memory 数据模型
+
+Experience: 经验规则(条件 + 规则 + 证据)
+Skill: 技能(从经验归纳的高层知识)
+"""
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Dict, Any, List, Optional, Literal
+import uuid
+
+
+@dataclass
+class Experience:
+    """
+    经验规则
+
+    从执行过程或人工反馈中提取的规则,格式:
+    - condition: 什么情况下适用
+    - rule: 应该怎么做
+    - evidence: 证据(step_ids)
+    """
+    exp_id: str
+    scope: str  # "agent:{type}" 或 "user:{uid}"
+
+    # 核心三元组
+    condition: str
+    rule: str
+    evidence: List[str] = field(default_factory=list)  # step_ids
+
+    # 元数据
+    source: Literal["execution", "feedback", "manual"] = "feedback"
+    confidence: float = 0.5
+    usage_count: int = 0
+    success_rate: float = 0.0
+
+    # 时间
+    created_at: datetime = field(default_factory=datetime.now)
+    updated_at: datetime = field(default_factory=datetime.now)
+
+    @classmethod
+    def create(
+        cls,
+        scope: str,
+        condition: str,
+        rule: str,
+        evidence: List[str] = None,
+        source: Literal["execution", "feedback", "manual"] = "feedback",
+        confidence: float = 0.5,
+    ) -> "Experience":
+        """创建新的 Experience"""
+        now = datetime.now()
+        return cls(
+            exp_id=str(uuid.uuid4()),
+            scope=scope,
+            condition=condition,
+            rule=rule,
+            evidence=evidence or [],
+            source=source,
+            confidence=confidence,
+            created_at=now,
+            updated_at=now,
+        )
+
+    def to_dict(self) -> Dict[str, Any]:
+        """转换为字典"""
+        return {
+            "exp_id": self.exp_id,
+            "scope": self.scope,
+            "condition": self.condition,
+            "rule": self.rule,
+            "evidence": self.evidence,
+            "source": self.source,
+            "confidence": self.confidence,
+            "usage_count": self.usage_count,
+            "success_rate": self.success_rate,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+            "updated_at": self.updated_at.isoformat() if self.updated_at else None,
+        }
+
+    def to_prompt_text(self) -> str:
+        """转换为可注入 Prompt 的文本"""
+        return f"当 {self.condition} 时,{self.rule}"
+
+
+@dataclass
+class Skill:
+    """
+    技能 - 从经验归纳的高层知识
+
+    技能可以形成层次结构(通过 parent_id)
+    """
+    skill_id: str
+    scope: str  # "agent:{type}" 或 "user:{uid}"
+
+    name: str
+    description: str
+    category: str  # 分类,如 "search", "reasoning", "writing"
+
+    # 层次结构
+    parent_id: Optional[str] = None
+
+    # 内容
+    content: Optional[str] = None  # 完整的 skill 内容(Markdown)
+    guidelines: List[str] = field(default_factory=list)
+    derived_from: List[str] = field(default_factory=list)  # experience_ids
+
+    # 版本
+    version: int = 1
+
+    # 时间
+    created_at: datetime = field(default_factory=datetime.now)
+    updated_at: datetime = field(default_factory=datetime.now)
+
+    @classmethod
+    def create(
+        cls,
+        scope: str,
+        name: str,
+        description: str,
+        category: str = "general",
+        content: Optional[str] = None,
+        guidelines: List[str] = None,
+        derived_from: List[str] = None,
+        parent_id: Optional[str] = None,
+    ) -> "Skill":
+        """创建新的 Skill"""
+        now = datetime.now()
+        return cls(
+            skill_id=str(uuid.uuid4()),
+            scope=scope,
+            name=name,
+            description=description,
+            category=category,
+            parent_id=parent_id,
+            content=content,
+            guidelines=guidelines or [],
+            derived_from=derived_from or [],
+            created_at=now,
+            updated_at=now,
+        )
+
+    def to_dict(self) -> Dict[str, Any]:
+        """转换为字典"""
+        return {
+            "skill_id": self.skill_id,
+            "scope": self.scope,
+            "name": self.name,
+            "description": self.description,
+            "category": self.category,
+            "parent_id": self.parent_id,
+            "content": self.content,
+            "guidelines": self.guidelines,
+            "derived_from": self.derived_from,
+            "version": self.version,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+            "updated_at": self.updated_at.isoformat() if self.updated_at else None,
+        }
+
+    def to_prompt_text(self) -> str:
+        """
+        转换为可注入 Prompt 的文本
+
+        优先使用完整的 content(如果有),否则使用 description + guidelines
+        """
+        # 如果有完整的 content,直接使用
+        if self.content:
+            return self.content.strip()
+
+        # 否则使用旧的格式(向后兼容)
+        lines = [f"### {self.name}", self.description]
+        if self.guidelines:
+            lines.append("指导原则:")
+            for g in self.guidelines:
+                lines.append(f"- {g}")
+        return "\n".join(lines)

+ 106 - 0
agent/memory/protocols.py

@@ -0,0 +1,106 @@
+"""
+Storage Protocols - 存储接口定义
+
+使用 Protocol 定义接口,允许不同的存储实现(内存、PostgreSQL、Neo4j 等)
+
+TraceStore 已移动到 agent.execution.protocols
+"""
+
+from typing import Protocol, List, Optional, Dict, Any, runtime_checkable
+
+from agent.memory.models import Experience, Skill
+
+
+@runtime_checkable
+class MemoryStore(Protocol):
+    """Experience + Skill 存储接口"""
+
+    # ===== Experience 操作 =====
+
+    async def add_experience(self, exp: Experience) -> str:
+        """添加 Experience"""
+        ...
+
+    async def get_experience(self, exp_id: str) -> Optional[Experience]:
+        """获取 Experience"""
+        ...
+
+    async def search_experiences(
+        self,
+        scope: str,
+        context: str,
+        limit: int = 10
+    ) -> List[Experience]:
+        """
+        搜索相关 Experience
+
+        Args:
+            scope: 范围(如 "agent:researcher")
+            context: 当前上下文,用于语义匹配
+            limit: 最大返回数量
+        """
+        ...
+
+    async def update_experience_stats(
+        self,
+        exp_id: str,
+        success: bool
+    ) -> None:
+        """更新 Experience 使用统计"""
+        ...
+
+    # ===== Skill 操作 =====
+
+    async def add_skill(self, skill: Skill) -> str:
+        """添加 Skill"""
+        ...
+
+    async def get_skill(self, skill_id: str) -> Optional[Skill]:
+        """获取 Skill"""
+        ...
+
+    async def get_skill_tree(self, scope: str) -> List[Skill]:
+        """获取技能树"""
+        ...
+
+    async def search_skills(
+        self,
+        scope: str,
+        context: str,
+        limit: int = 5
+    ) -> List[Skill]:
+        """搜索相关 Skills"""
+        ...
+
+
+@runtime_checkable
+class StateStore(Protocol):
+    """短期状态存储接口(用于 Task State,通常用 Redis)"""
+
+    async def get(self, key: str) -> Optional[Dict[str, Any]]:
+        """获取状态"""
+        ...
+
+    async def set(
+        self,
+        key: str,
+        value: Dict[str, Any],
+        ttl: Optional[int] = None
+    ) -> None:
+        """
+        设置状态
+
+        Args:
+            key: 键
+            value: 值
+            ttl: 过期时间(秒)
+        """
+        ...
+
+    async def update(self, key: str, **updates) -> None:
+        """部分更新"""
+        ...
+
+    async def delete(self, key: str) -> None:
+        """删除"""
+        ...

+ 402 - 0
agent/memory/skill_loader.py

@@ -0,0 +1,402 @@
+"""
+Skill Loader - 从 Markdown 文件加载 Skills
+
+支持两种格式:
+
+格式1 - YAML Frontmatter(推荐):
+---
+name: skill-name
+description: Skill description
+category: category-name
+scope: agent:*
+parent: parent-id
+---
+
+## When to use
+- Use case 1
+- Use case 2
+
+## Guidelines
+- Guideline 1
+- Guideline 2
+
+格式2 - 行内元数据(向后兼容):
+# Skill Name
+
+> category: web-automation
+> scope: agent:*
+
+## Description
+...
+
+## Guidelines
+...
+"""
+
+import os
+import re
+from pathlib import Path
+from typing import List, Dict, Optional
+import logging
+
+from agent.memory.models import Skill
+
+logger = logging.getLogger(__name__)
+
+
+class SkillLoader:
+    """从 Markdown 文件加载 Skills"""
+
+    def __init__(self, skills_dir: str):
+        """
+        初始化 SkillLoader
+
+        Args:
+            skills_dir: skills 目录路径
+        """
+        self.skills_dir = Path(skills_dir)
+        if not self.skills_dir.exists():
+            logger.warning(f"Skills 目录不存在: {skills_dir}")
+
+    def load_all(self) -> List[Skill]:
+        """
+        加载目录下所有 .md 文件
+
+        Returns:
+            Skill 列表
+        """
+        if not self.skills_dir.exists():
+            return []
+
+        skills = []
+        for md_file in self.skills_dir.glob("*.md"):
+            try:
+                skill = self.load_file(md_file)
+                if skill:
+                    skills.append(skill)
+                    logger.info(f"成功加载 skill: {skill.name} from {md_file.name}")
+            except Exception as e:
+                logger.error(f"加载 skill 失败 {md_file}: {e}")
+
+        return skills
+
+    def load_file(self, file_path: Path) -> Optional[Skill]:
+        """
+        从单个 Markdown 文件加载 Skill
+
+        Args:
+            file_path: Markdown 文件路径
+
+        Returns:
+            Skill 对象,解析失败返回 None
+        """
+        if not file_path.exists():
+            logger.warning(f"文件不存在: {file_path}")
+            return None
+
+        with open(file_path, "r", encoding="utf-8") as f:
+            content = f.read()
+
+        return self.parse_markdown(content, file_path.stem)
+
+    def parse_markdown(self, content: str, filename: str) -> Optional[Skill]:
+        """
+        解析 Markdown 内容为 Skill
+
+        支持两种格式:
+
+        格式1 - YAML Frontmatter(推荐):
+        ---
+        name: skill-name
+        description: Skill description
+        category: category-name
+        scope: agent:*
+        ---
+
+        ## When to use
+        - Use case 1
+
+        ## Guidelines
+        - Guideline 1
+
+        格式2 - 行内元数据(向后兼容):
+        # Skill Name
+
+        > category: web-automation
+        > scope: agent:*
+
+        ## Description
+        描述内容...
+
+        ## Guidelines
+        - 指导原则1
+
+        Args:
+            content: Markdown 内容
+            filename: 文件名(不含扩展名)
+
+        Returns:
+            Skill 对象
+        """
+        # 检测格式:是否有 YAML frontmatter
+        if content.strip().startswith("---"):
+            return self._parse_frontmatter_format(content, filename)
+        else:
+            return self._parse_inline_format(content, filename)
+
+    def _parse_frontmatter_format(self, content: str, filename: str) -> Optional[Skill]:
+        """
+        解析 YAML frontmatter 格式
+
+        ---
+        name: skill-name
+        description: Skill description
+        category: category-name
+        scope: agent:*
+        parent: parent-id
+        ---
+
+        ## When to use
+        ...
+
+        ## Guidelines
+        ...
+        """
+        lines = content.split("\n")
+
+        # 提取 YAML frontmatter
+        if not lines[0].strip() == "---":
+            logger.warning("格式错误:缺少开始的 ---")
+            return None
+
+        frontmatter = {}
+        i = 1
+        while i < len(lines):
+            line = lines[i].strip()
+            if line == "---":
+                break
+            if ":" in line:
+                key, value = line.split(":", 1)
+                frontmatter[key.strip()] = value.strip()
+            i += 1
+
+        # 提取元数据
+        name = frontmatter.get("name") or self._filename_to_title(filename)
+        description = frontmatter.get("description", "")
+        category = frontmatter.get("category", "general")
+        scope = frontmatter.get("scope", "agent:*")
+        parent_id = frontmatter.get("parent")
+
+        # 提取章节内容(从 frontmatter 之后开始)
+        remaining_content = "\n".join(lines[i+1:])
+        remaining_lines = remaining_content.split("\n")
+
+        # 提取 "When to use" 章节(可选)
+        when_to_use = self._extract_list_items(remaining_lines, "When to use")
+        if when_to_use:
+            # 将 "When to use" 添加到描述中
+            description += "\n\n适用场景:\n" + "\n".join(f"- {item}" for item in when_to_use)
+
+        # 提取 Guidelines
+        guidelines = self._extract_list_items(remaining_lines, "Guidelines")
+
+        # 保存完整的内容(去掉 frontmatter)
+        content = remaining_content.strip()
+
+        # 创建 Skill
+        return Skill.create(
+            scope=scope,
+            name=name,
+            description=description.strip(),
+            category=category,
+            content=content,  # 完整的 Markdown 内容
+            guidelines=guidelines,
+            parent_id=parent_id,
+        )
+
+    def _parse_inline_format(self, content: str, filename: str) -> Optional[Skill]:
+        """
+        解析行内元数据格式(向后兼容)
+
+        # Skill Name
+
+        > category: web-automation
+        > scope: agent:*
+
+        ## Description
+        ...
+
+        ## Guidelines
+        ...
+        """
+        lines = content.split("\n")
+
+        # 提取标题作为 name
+        name = self._extract_title(lines) or self._filename_to_title(filename)
+
+        # 提取元数据
+        metadata = self._extract_metadata(lines)
+        category = metadata.get("category", "general")
+        scope = metadata.get("scope", "agent:*")
+        parent_id = metadata.get("parent")
+
+        # 提取描述
+        description = self._extract_section(lines, "Description") or ""
+
+        # 提取指导原则
+        guidelines = self._extract_list_items(lines, "Guidelines")
+
+        # 提取完整内容(去掉元数据行和标题行)
+        content_lines = []
+        skip_metadata = False
+        for line in lines:
+            stripped = line.strip()
+            # 跳过标题
+            if stripped.startswith("# "):
+                continue
+            # 跳过元数据
+            if stripped.startswith(">"):
+                skip_metadata = True
+                continue
+            # 如果之前是元数据,跳过后续的空行
+            if skip_metadata and not stripped:
+                skip_metadata = False
+                continue
+            content_lines.append(line)
+
+        content = "\n".join(content_lines).strip()
+
+        # 创建 Skill
+        return Skill.create(
+            scope=scope,
+            name=name,
+            description=description.strip(),
+            category=category,
+            content=content,  # 完整的 Markdown 内容
+            guidelines=guidelines,
+            parent_id=parent_id,
+        )
+
+    def _extract_title(self, lines: List[str]) -> Optional[str]:
+        """提取 # 标题"""
+        for line in lines:
+            line = line.strip()
+            if line.startswith("# "):
+                return line[2:].strip()
+        return None
+
+    def _filename_to_title(self, filename: str) -> str:
+        """将文件名转换为标题(kebab-case -> Title Case)"""
+        return " ".join(word.capitalize() for word in filename.split("-"))
+
+    def _extract_metadata(self, lines: List[str]) -> Dict[str, str]:
+        """
+        提取元数据块(> key: value)
+
+        Example:
+            > category: web-automation
+            > scope: agent:*
+        """
+        metadata = {}
+        for line in lines:
+            line = line.strip()
+            if line.startswith(">"):
+                # 去掉 > 符号
+                content = line[1:].strip()
+                # 分割 key: value
+                if ":" in content:
+                    key, value = content.split(":", 1)
+                    metadata[key.strip()] = value.strip()
+        return metadata
+
+    def _extract_section(self, lines: List[str], section_name: str) -> Optional[str]:
+        """
+        提取指定章节的内容
+
+        Args:
+            lines: 文件行列表
+            section_name: 章节名称(如 "Description")
+
+        Returns:
+            章节内容(纯文本)
+        """
+        in_section = False
+        section_lines = []
+
+        for line in lines:
+            stripped = line.strip()
+
+            # 遇到目标章节
+            if stripped.startswith("## ") and section_name.lower() in stripped.lower():
+                in_section = True
+                continue
+
+            # 遇到下一个章节,结束
+            if in_section and stripped.startswith("##"):
+                break
+
+            # 收集章节内容
+            if in_section:
+                section_lines.append(line)
+
+        return "\n".join(section_lines).strip() if section_lines else None
+
+    def _extract_list_items(self, lines: List[str], section_name: str) -> List[str]:
+        """
+        提取指定章节的列表项
+
+        Args:
+            lines: 文件行列表
+            section_name: 章节名称(如 "Guidelines")
+
+        Returns:
+            列表项数组
+        """
+        section_content = self._extract_section(lines, section_name)
+        if not section_content:
+            return []
+
+        items = []
+        for line in section_content.split("\n"):
+            line = line.strip()
+            # 匹配列表项(- item 或 * item)
+            if line.startswith("- ") or line.startswith("* "):
+                items.append(line[2:].strip())
+
+        return items
+
+
+# 便捷函数
+def load_skills_from_dir(skills_dir: Optional[str] = None) -> List[Skill]:
+    """
+    从目录加载所有 Skills
+
+    加载优先级:
+    1. 始终加载内置 skills(agent/skills/)
+    2. 如果指定了 skills_dir,额外加载该目录的 skills
+
+    Args:
+        skills_dir: 用户自定义 skills 目录路径(可选)
+
+    Returns:
+        Skill 列表(内置 + 自定义)
+    """
+    all_skills = []
+
+    # 1. 加载内置 skills(agent/memory/skills/)
+    builtin_skills_dir = Path(__file__).parent / "skills"
+    if builtin_skills_dir.exists():
+        loader = SkillLoader(str(builtin_skills_dir))
+        builtin_skills = loader.load_all()
+        all_skills.extend(builtin_skills)
+        logger.info(f"加载了 {len(builtin_skills)} 个内置 skills")
+
+    # 2. 加载用户自定义 skills(如果提供)
+    if skills_dir:
+        loader = SkillLoader(skills_dir)
+        custom_skills = loader.load_all()
+        all_skills.extend(custom_skills)
+        logger.info(f"加载了 {len(custom_skills)} 个自定义 skills")
+
+    return all_skills
+

+ 35 - 0
agent/memory/skills/browser.md

@@ -0,0 +1,35 @@
+---
+name: browser
+description: 浏览器自动化工具使用指南
+---
+
+## 浏览器工具使用指南
+
+所有浏览器工具都以 `browser_` 为前缀。浏览器会话会持久化,无需每次重新启动。
+
+### 基本工作流程
+
+1. **页面导航**: 使用 `browser_navigate_to_url` 或 `browser_search_web` 到达目标页面
+2. **等待加载**: 页面跳转后调用 `browser_wait(seconds=2)` 等待内容加载
+3. **获取元素索引**: 调用 `browser_get_visual_selector_map` 获取可交互元素的索引映射和当前界面的截图
+4. **执行交互**: 使用 `browser_click_element`、`browser_input_text` 等工具操作页面
+5. **提取内容**: 使用 `browser_extract_content`, `browser_read_long_content`, `browser_get_page_html` 获取数据
+
+### 关键原则
+
+- **禁止模拟结果**:不要输出你认为的搜索结果,而是要调用工具获取真实结果
+- **必须先获取索引**: 所有 `index` 参数都需要先通过 `browser_get_selector_map` 获取
+- **高级工具**:优先使用 `browser_extract_content`, `browser_read_long_content` 等工具获取数据,而不是使用 `browser_get_selector_map` 获取索引后手动解析
+- **操作后等待**: 任何可能触发页面变化的操作(点击、输入、滚动)后都要调用 `browser_wait`
+- **登录处理**:
+  - **正常登录**:当遇到需要登录的网页时,使用 `browser_load_cookies` 来登录
+  - **首次登录**:当没有该网站的 cookie 时,点击进入登录界面,然后等待人类来登录,登录后使用 `browser_export_cookies` 将账户信息存储下来
+- **复杂操作用JS**: 当标准工具无法满足时,使用 `browser_evaluate` 执行 JavaScript 代码
+
+### 工具分类
+
+**导航**: browser_navigate_to_url, browser_search_web, browser_go_back, browser_wait
+**交互**: browser_click_element, browser_input_text, browser_send_keys, browser_upload_file
+**视图**: browser_scroll_page, browser_find_text, browser_screenshot
+**提取**: browser_extract_content, browser_read_long_content, browser_get_page_html, browser_get_selector_map, browser_get_visual_selector_map
+**高级**: browser_evaluate, browser_load_cookies, browser_export_cookies, browser_wait_for_user_action, browser_download_direct_url

+ 109 - 0
agent/memory/skills/core.md

@@ -0,0 +1,109 @@
+---
+name: core
+type: core
+description: 核心系统能力,自动加载到 System Prompt
+---
+
+## 计划与执行
+
+使用 `goal` 工具管理执行计划。目标树是你的工作记忆——系统会定期将当前计划注入给你,帮助你追踪进度和关键结论。
+
+### 核心原则
+
+- **先明确目标再行动**:开始执行前,用 `goal` 明确当前要做什么
+- **灵活运用,不受约束**:
+  - 可以先做全局规划再行动:`goal(add="调研方案, 实现方案, 测试验证")`
+  - 可以走一步看一步,每次只规划下一个目标
+  - 行动中可以动态放弃并调整:`goal(abandon="方案不可行")`
+  - 规划本身可以作为一个目标(如 "调研并确定技术方案")
+- **简单任务只需一个目标**:`goal(add="将CSV转换为JSON")` 即可,不需要强制拆分
+
+### 使用方式
+
+创建目标:
+
+```
+goal(add="调研并确定方案, 执行方案, 评估结果")
+```
+
+聚焦并开始执行(使用计划视图中的 ID,如 "1", "2.1"):
+
+```
+goal(focus="1")
+```
+
+完成目标,记录**关键结论**(不是过程描述):
+
+```
+goal(done="最佳方案是openpose,精度高且支持多人检测")
+```
+
+完成并切换到下一个:
+
+```
+goal(done="openpose方案确认可行", focus="2")
+```
+
+添加子目标或同级目标:
+
+```
+goal(add="设计接口, 实现代码", under="2")
+goal(add="编写文档", after="2")
+```
+
+放弃不可行的目标:
+
+```
+goal(abandon="方案A需要Redis,环境没有")
+```
+
+### 使用规范
+
+1. **聚焦到具体目标**:始终将焦点放在你正在执行的最具体的子目标上,而不是父目标。创建子目标后立即 `focus` 到第一个要执行的子目标。完成后用 `done` + `focus` 切换到下一个。
+2. **同时只有一个目标处于执行中**:完成当前目标后再切换
+3. **summary 记录结论**:记录关键发现,而非 "已完成调研" 这样无信息量的描述
+4. **计划可调整**:根据执行情况随时追加、跳过或放弃目标
+5. **使用 ID 定位**:focus、after、under 参数使用目标的 ID(如 "1", "2.1")
+
+### 知识复用
+
+在**启动新任务**、**拆分复杂目标**或**遇到执行障碍**时,应主动调用 `knowledge_search` 获取相关的历史经验或避坑指南。
+**使用示例:**
+`knowledge_search(query="如何处理浏览器点击不生效的问题", types=["strategy", "tool"])`
+
+## 信息调研
+
+你可以通过联网搜索工具`search_posts`获取来自Github、小红书、微信公众号、知乎等渠道的信息。对于需要深度交互的网页内容,使用浏览器工具进行操作。
+
+调研过程可能需要多次搜索,比如基于搜索结果中获得的启发或信息启动新的搜索,直到得到令人满意的答案。你可以使用`goal`工具管理搜索的过程,或者使用文档记录搜索的中间或最终结果。
+
+## 浏览器工具使用指南
+
+所有浏览器工具都以 `browser_` 为前缀。浏览器会话会持久化,无需每次重新启动。
+
+### 基本工作流程
+
+1. **页面导航**: 使用 `browser_navigate_to_url` 或 `browser_search_web` 到达目标页面
+2. **等待加载**: 页面跳转后调用 `browser_wait(seconds=2)` 等待内容加载
+3. **获取元素索引**: 调用 `browser_get_visual_selector_map` 获取可交互元素的索引映射和当前界面的截图
+4. **执行交互**: 使用 `browser_click_element`、`browser_input_text` 等工具操作页面
+5. **提取内容**: 使用 `browser_extract_content`, `browser_read_long_content`, `browser_get_page_html` 获取数据
+
+### 关键原则
+
+- **禁止模拟结果**:不要输出你认为的搜索结果,而是要调用工具获取真实结果
+- **必须先获取索引**: 所有 `index` 参数都需要先通过 `browser_get_selector_map` 获取
+- **高级工具**:优先使用`browser_extract_content`, `browser_read_long_content`等工具获取数据,而不是使用`browser_get_selector_map`获取索引后手动解析
+- **操作后等待**: 任何可能触发页面变化的操作(点击、输入、滚动)后都要调用 `browser_wait`
+- **登录处理**:
+  - **正常登录**:当遇到需要登录的网页时,使用`browser_load_cookies`来登录
+  - **首次登录**:当没有该网站的cookie时,点击进入登录界面,然后等待人类来登录,登录后使用`browser_export_cookies`将账户信息存储下来
+- **复杂操作用JS**: 当标准工具无法满足时,使用 `browser_evaluate` 执行JavaScript代码
+
+### 工具分类
+
+**导航**: browser_navigate_to_url, browser_search_web, browser_go_back, browser_wait
+**交互**: browser_click_element, browser_input_text, browser_send_keys, browser_upload_file
+**视图**: browser_scroll_page, browser_find_text, browser_screenshot
+**提取**: browser_extract_content, browser_read_long_content, browser_get_page_html, browser_get_selector_map, browser_get_visual_selector_map
+**高级**: browser_evaluate, browser_load_cookies, browser_export_cookies, browser_wait_for_user_action, browser_download_direct_url

+ 65 - 0
agent/memory/skills/planning.md

@@ -0,0 +1,65 @@
+---
+name: planning
+description: 计划管理,使用 goal 工具管理执行计划和目标树
+---
+
+## 计划与执行
+
+使用 `goal` 工具管理执行计划。目标树是你的工作记忆——系统会定期将当前计划注入给你,帮助你追踪进度和关键结论。
+
+### 核心原则
+
+- **先明确目标再行动**:开始执行前,用 `goal` 明确当前要做什么
+- **灵活运用,不受约束**:
+  - 可以先做全局规划再行动:`goal(add="调研方案, 实现方案, 测试验证")`
+  - 可以走一步看一步,每次只规划下一个目标
+  - 行动中可以动态放弃并调整:`goal(abandon="方案不可行")`
+  - 规划本身可以作为一个目标(如 "调研并确定技术方案")
+- **简单任务只需一个目标**:`goal(add="将CSV转换为JSON")` 即可,不需要强制拆分
+
+### 使用方式
+
+创建目标:
+
+```
+goal(add="调研并确定方案, 执行方案, 评估结果")
+```
+
+聚焦并开始执行(使用计划视图中的 ID,如 "1", "2.1"):
+
+```
+goal(focus="1")
+```
+
+完成目标,记录**关键结论**(不是过程描述):
+
+```
+goal(done="最佳方案是openpose,精度高且支持多人检测")
+```
+
+完成并切换到下一个:
+
+```
+goal(done="openpose方案确认可行", focus="2")
+```
+
+添加子目标或同级目标:
+
+```
+goal(add="设计接口, 实现代码", under="2")
+goal(add="编写文档", after="2")
+```
+
+放弃不可行的目标:
+
+```
+goal(abandon="方案A需要Redis,环境没有")
+```
+
+### 使用规范
+
+1. **聚焦到具体目标**:始终将焦点放在你正在执行的最具体的子目标上,而不是父目标。创建子目标后立即 `focus` 到第一个要执行的子目标。完成后用 `done` + `focus` 切换到下一个。
+2. **同时只有一个目标处于执行中**:完成当前目标后再切换
+3. **summary 记录结论**:记录关键发现,而非 "已完成调研" 这样无信息量的描述
+4. **计划可调整**:根据执行情况随时追加、跳过或放弃目标
+5. **使用 ID 定位**:focus、after、under 参数使用目标的 ID(如 "1", "2.1")

+ 419 - 0
agent/memory/skills/research.md

@@ -0,0 +1,419 @@
+---
+name: atomic_research
+description: 知识调研 - 根据目标和任务自动执行搜索,返回结构化知识列表
+---
+
+## 信息调研
+
+你可以通过联网搜索工具 `search_posts` 获取来自 Github、小红书、微信公众号、知乎等渠道的信息。对于需要深度交互的网页内容,使用浏览器工具进行操作。
+
+## 调研过程可能需要多次搜索,比如基于搜索结果中获得的启发或信息启动新的搜索,直到得到令人满意的答案。你可以使用 `goal` 工具管理搜索的过程,或者使用文档记录搜索的中间或最终结果。(可以着重参考browser的工具来辅助搜索)
+
+## 工作流程
+
+### 输入
+
+- **目标**:要达成的目标(如"找到 PDF 表格提取的最佳方案")
+- **任务**:具体的任务描述(如"从复杂 PDF 中提取表格数据")
+
+### 执行流程
+
+**Step 1: 拆解搜索维度**
+
+```
+goal(add="搜索工具, 搜索案例, 搜索方法论")
+```
+
+**Step 2: 多维度搜索**
+
+- 搜索工具:`search_posts(query="PDF table extraction tool")`
+- 搜索案例:`search_posts(query="PDF table extraction usercase site:github.com")`
+- 搜索定义:`search_posts(query="PDF table extraction definition")`
+- 搜索方法:`search_posts(query="PDF table extraction best practice")`
+
+**Step 3: 结构化记录**
+每发现一条有价值的信息,立即保存为结构化知识:
+
+```python
+knowledge_save(
+    task="在什么情景下,要完成什么目标,得到能达成一个什么结果",
+    content="这条知识实际的核心内容",
+    types=["tool"],  # tool/usecase/definition/plan/strategy/user_profile
+    urls=["参考的论文/github/博客等"],
+    agent_id="当前 agent ID",
+    score=5
+)
+```
+
+**Step 4: 输出知识列表**
+
+```
+goal(done="已完成调研,共记录 N 条知识")
+```
+
+### 输出
+
+- 保存到 `.cache/knowledge_atoms/` 目录,每条知识一个 JSON 文件
+- 文件名格式:`atom-YYYYMMDD-HHMMSS-XXXX.json`
+- 返回知识列表摘要
+
+---
+
+## 知识结构
+
+每条知识原子包含以下字段:
+
+### ① id(唯一标识)
+
+格式:`atom-YYYYMMDD-NNN`
+
+示例:`atom-20260302-001`
+
+### ② tags(知识类型)
+
+单条知识可以包含多个标签:
+
+| 类型           | 说明                                       | 示例                                |
+| -------------- | ------------------------------------------ | ----------------------------------- |
+| **tool**       | 工具相关信息,包含使用案例和使用方法       | pdfplumber 库的使用方法             |
+| **usercase**   | 针对任务的用户案例,某个用户完成任务的方法 | 某用户如何提取 PDF 表格的完整流程   |
+| **definition** | 内容的具体定义,或任务的问题定义           | 什么是 PDF 表格提取,有哪些技术挑战 |
+| **plan**       | 完成任务的通用计划、方法论或流程           | PDF 表格提取的标准流程和最佳实践    |
+
+### ③ summary(摘要)
+
+**格式**:在 [情景] 下,完成 [目标],得到 [预期结果]。
+
+**示例**:
+
+```
+在 Python 3.11 环境下,需要从结构复杂的 PDF(包含多列、嵌套表格)中提取表格数据,
+并保留单元格坐标信息,最终得到可用于数据分析的结构化数据。
+```
+
+### ④ content(核心内容)
+
+这条知识实际的核心内容,使用 Markdown 格式,根据类型不同包含不同信息:
+
+**tool 类型**:
+
+- 工具名称和简介
+- 核心 API 和使用方法
+- 适用场景
+- 优缺点对比
+- 代码示例
+
+**usercase 类型**:
+
+- 用户背景和需求
+- 采用的方案
+- 实现步骤
+- 遇到的问题和解决方法
+- 最终效果
+
+**definition 类型**:
+
+- 概念定义
+- 技术原理
+- 应用场景
+- 与相关概念的区别
+
+**plan 类型**:
+
+- 完整流程步骤
+- 关键决策点
+- 常见陷阱和避坑指南
+- 评估标准
+
+### ⑤ tips(避坑指南)
+
+⚠️ 具体的避坑指南或核心建议。
+
+**示例**:
+
+```
+⚠️ 如果 PDF 包含隐形表格线,务必开启 explicit_horizontal_lines 参数
+⚠️ 使用 page.crop() 先裁剪区域再提取,可提升 3-5 倍速度
+```
+
+### ⑥ trace(回溯)
+
+**包含**:
+
+- `urls`: 参考的论文、GitHub、博客等(URL 列表)
+- `agent_id`: 执行此调研的 agent ID
+- `timestamp`: 记录时间
+
+**示例**:
+
+```json
+{
+  "urls": ["https://github.com/jsvine/pdfplumber"],
+  "agent_id": "research_agent_001",
+  "timestamp": "2026-03-02T12:45:41Z"
+}
+```
+
+### ⑦ eval(评估反馈)
+
+**包含**:
+
+- `helpful`: 好用的使用次数(初始值为 1)
+- `harmful`: 不好用的使用次数(初始值为 0)
+
+**示例**:
+
+```json
+{
+  "helpful": 1,
+  "harmful": 0
+}
+```
+
+### ⑧ execute_history(执行历史)
+
+**包含**:
+
+- `helpful`: 好用的使用案例描述列表(字符串数组,初始为空)
+- `harmful`: 不好用的使用案例描述列表(字符串数组,初始为空)
+
+**示例**:
+
+```json
+{
+  "helpful": [],
+  "harmful": []
+}
+```
+
+---
+
+## 完整示例
+
+````json
+{
+  "research_report": {
+    "goal": "找到 PDF 表格提取的最佳方案",
+    "task": "从复杂 PDF 中提取表格数据",
+    "summary": "共记录 3 条核心知识原子,涵盖工具选型与实战 SOP",
+    "atoms": [
+      {
+        "id": "atom-20260302-001",
+        "tags": ["tool", "plan", "usercase"],
+        "summary": "在 Python 3.11 环境下,从结构复杂的 PDF(包含多列、嵌套表格)中提取表格数据,并保留单元格坐标信息,最终得到可用于数据分析的结构化数据。",
+        "content": "## 推荐工具\npdfplumber - 专注于 PDF 表格提取\n\n## 核心 API\n使用 extract_tables() 方法\n\n## 代码示例\n```python\nimport pdfplumber\nwith pdfplumber.open('file.pdf') as pdf:\n    tables = pdf.pages[0].extract_tables()\n```",
+        "tips": "⚠️ 如果 PDF 包含隐形表格线,务必开启 explicit_horizontal_lines 参数\n⚠️ 使用 page.crop() 先裁剪区域再提取,可提升 3-5 倍速度",
+        "trace": {
+          "urls": ["https://github.com/jsvine/pdfplumber"],
+          "agent_id": "research_agent_001",
+          "timestamp": "2026-03-02T12:45:41Z"
+        },
+        "eval": {
+          "helpful": 1,
+          "harmful": 0
+        },
+        "execute_history": {
+          "helpful": [],
+          "harmful": []
+        }
+      },
+      {
+        "id": "atom-20260302-002",
+        "tags": ["usercase"],
+        "summary": "针对 500MB 以上的大型扫描版 PDF 进行自动化处理。",
+        "content": "使用纯 Python 库(如 pdfplumber/PyMuPDF)性能极差且准确率低。",
+        "tips": "建议方案:先使用 PaddleOCR 进行版面分析,再提取坐标区域。",
+        "trace": {
+          "urls": ["https://reddit.com/r/python/comments/..."],
+          "agent_id": "research_agent_01",
+          "timestamp": "2026-03-02T13:00:00Z"
+        },
+        "eval": {
+          "helpful": 1,
+          "harmful": 0
+        },
+        "execute_history": {
+          "helpful": [],
+          "harmful": []
+        }
+      }
+    ]
+  }
+}
+````
+
+---
+
+## 适用场景
+
+- 需要从多个来源收集和整理知识
+- 需要持续积累和评估知识的有效性
+- 需要追溯知识来源和使用历史
+
+---
+
+## 使用工具
+
+### 保存知识
+
+````python
+knowledge_save(
+    task="在 Python 3.11 环境下,从复杂 PDF 中提取表格数据,并保留单元格坐标信息。",
+    content="""
+## 推荐工具
+pdfplumber - 专注于 PDF 表格提取
+
+## 核心 API
+使用 extract_tables() 方法
+
+## 代码示例
+```python
+import pdfplumber
+with pdfplumber.open('file.pdf') as pdf:
+    tables = pdf.pages[0].extract_tables()
+```
+
+⚠️ 必须设置 explicit_horizontal_lines=True 以识别隐形表格线
+""",
+    types=["tool", "plan"],
+    urls=["https://github.com/jsvine/pdfplumber"],
+    agent_id="research_agent_001",
+    score=5
+)
+````
+
+### 更新评估反馈
+
+```python
+knowledge_update(
+    knowledge_id="knowledge-20260302-001",
+    add_helpful_case={
+        "description": "在解析 2025 年报 PDF 时,通过配置 explicit_lines 成功提取了 100+ 嵌套表格。",
+        "trace_id": "trace-xxx"
+    }
+)
+```
+
+或添加失败案例:
+
+```python
+knowledge_update(
+    knowledge_id="knowledge-20260302-001",
+    add_harmful_case={
+        "description": "在处理 300MB 的扫描版 PDF 时,该方案因缺乏 OCR 能力导致提取结果为空。",
+        "trace_id": "trace-xxx"
+    }
+)
+```
+
+---
+
+## 调研策略
+
+### 1. 工具调研(tool)
+
+**搜索关键词**:
+
+- `[任务] tool python`
+- `[任务] library comparison`
+- `[任务] vs site:reddit.com`
+
+**记录重点**:
+
+- 工具名称和链接
+- 核心 API
+- 适用场景
+- 优缺点对比
+- 避坑指南
+
+### 2. 案例调研(usercase)
+
+**搜索关键词**:
+
+- `[任务] example site:github.com`
+- `[任务] tutorial`
+- `how to [任务]`
+
+**记录重点**:
+
+- 用户背景
+- 采用方案
+- 实现步骤
+- 遇到的问题
+- 最终效果
+
+### 3. 定义调研(definition)
+
+**搜索关键词**:
+
+- `what is [任务]`
+- `[任务] definition`
+- `[任务] explained`
+
+**记录重点**:
+
+- 概念定义
+- 技术原理
+- 应用场景
+- 相关概念区别
+
+### 4. 方法论调研(plan)
+
+**搜索关键词**:
+
+- `[任务] best practice`
+- `[任务] workflow`
+- `[任务] step by step`
+
+**记录重点**:
+
+- 完整流程
+- 关键决策点
+- 常见陷阱
+- 评估标准
+
+---
+
+## 输出格式
+
+调研完成后,输出知识列表摘要:
+
+```
+📚 调研完成报告
+
+目标:找到 PDF 表格提取的最佳方案
+任务:从复杂 PDF 中提取表格数据
+
+共记录 5 条知识:
+
+1. [tool] pdfplumber - PDF 表格提取工具
+   评分:⭐⭐⭐⭐⭐ (5/5)
+   反馈:helpful: 1, harmful: 0
+
+2. [usercase] 财报 PDF 表格提取案例
+   评分:⭐⭐⭐⭐ (4/5)
+   反馈:helpful: 1, harmful: 0
+
+3. [definition] PDF 表格提取技术原理
+   评分:⭐⭐⭐⭐ (4/5)
+   反馈:helpful: 1, harmful: 0
+
+4. [plan] PDF 表格提取标准流程
+   评分:⭐⭐⭐⭐⭐ (5/5)
+   反馈:helpful: 1, harmful: 0
+
+5. [tool] tabula-py - 替代方案
+   评分:⭐⭐⭐ (3/5)
+   反馈:helpful: 1, harmful: 0
+
+知识文件保存在:.cache/knowledge_atoms/
+```
+
+---
+
+## 记住
+
+- **边搜边记**:发现有价值的信息立即保存
+- **结构化**:严格按照 5 个维度记录
+- **可追溯**:记录所有参考来源
+- **可评估**:初始评分 + 持续反馈

+ 103 - 0
agent/memory/stores.py

@@ -0,0 +1,103 @@
+"""
+Memory Implementation - 内存存储实现
+
+用于测试和简单场景,数据不持久化
+"""
+
+from typing import Dict, List, Optional, Any
+from datetime import datetime
+
+from agent.memory.models import Experience, Skill
+
+
+class MemoryMemoryStore:
+    """内存 Memory 存储(Experience + Skill)"""
+
+    def __init__(self):
+        self._experiences: Dict[str, Experience] = {}
+        self._skills: Dict[str, Skill] = {}
+
+    # ===== Experience =====
+
+    async def add_experience(self, exp: Experience) -> str:
+        self._experiences[exp.exp_id] = exp
+        return exp.exp_id
+
+    async def get_experience(self, exp_id: str) -> Optional[Experience]:
+        return self._experiences.get(exp_id)
+
+    async def search_experiences(
+        self,
+        scope: str,
+        context: str,
+        limit: int = 10
+    ) -> List[Experience]:
+        # 简单实现:按 scope 过滤,按 confidence 排序
+        experiences = [
+            e for e in self._experiences.values()
+            if e.scope == scope
+        ]
+        experiences.sort(key=lambda e: e.confidence, reverse=True)
+        return experiences[:limit]
+
+    async def update_experience_stats(
+        self,
+        exp_id: str,
+        success: bool
+    ) -> None:
+        exp = self._experiences.get(exp_id)
+        if exp:
+            exp.usage_count += 1
+            if success:
+                # 更新成功率
+                total_success = exp.success_rate * (exp.usage_count - 1) + (1 if success else 0)
+                exp.success_rate = total_success / exp.usage_count
+            exp.updated_at = datetime.now()
+
+    # ===== Skill =====
+
+    async def add_skill(self, skill: Skill) -> str:
+        self._skills[skill.skill_id] = skill
+        return skill.skill_id
+
+    async def get_skill(self, skill_id: str) -> Optional[Skill]:
+        return self._skills.get(skill_id)
+
+    async def get_skill_tree(self, scope: str) -> List[Skill]:
+        return [s for s in self._skills.values() if s.scope == scope]
+
+    async def search_skills(
+        self,
+        scope: str,
+        context: str,
+        limit: int = 5
+    ) -> List[Skill]:
+        # 简单实现:按 scope 过滤
+        skills = [s for s in self._skills.values() if s.scope == scope]
+        return skills[:limit]
+
+
+class MemoryStateStore:
+    """内存状态存储"""
+
+    def __init__(self):
+        self._state: Dict[str, Dict[str, Any]] = {}
+
+    async def get(self, key: str) -> Optional[Dict[str, Any]]:
+        return self._state.get(key)
+
+    async def set(
+        self,
+        key: str,
+        value: Dict[str, Any],
+        ttl: Optional[int] = None
+    ) -> None:
+        # 内存实现忽略 ttl
+        self._state[key] = value
+
+    async def update(self, key: str, **updates) -> None:
+        if key in self._state:
+            self._state[key].update(updates)
+
+    async def delete(self, key: str) -> None:
+        self._state.pop(key, None)

+ 21 - 0
agent/tools/__init__.py

@@ -0,0 +1,21 @@
+"""
+Tools 包 - 工具注册和 Schema 生成
+"""
+
+from agent.tools.registry import ToolRegistry, tool, get_tool_registry
+from agent.tools.schema import SchemaGenerator
+from agent.tools.models import ToolResult, ToolContext, ToolContextImpl
+
+# 导入 builtin 工具以触发 @tool 装饰器注册
+# noqa: F401 表示这是故意的副作用导入
+import agent.tools.builtin  # noqa: F401
+
+__all__ = [
+	"ToolRegistry",
+	"tool",
+	"get_tool_registry",
+	"SchemaGenerator",
+	"ToolResult",
+	"ToolContext",
+	"ToolContextImpl",
+]

+ 13 - 0
agent/tools/adapters/__init__.py

@@ -0,0 +1,13 @@
+"""
+工具适配器 - 集成第三方工具到 Agent 框架
+
+提供统一的适配器接口,将外部工具(如 opencode)适配到我们的工具系统。
+"""
+
+from agent.tools.adapters.base import ToolAdapter
+from agent.tools.adapters.opencode_bun_adapter import OpenCodeBunAdapter
+
+__all__ = [
+    "ToolAdapter",
+    "OpenCodeBunAdapter",
+]

+ 62 - 0
agent/tools/adapters/base.py

@@ -0,0 +1,62 @@
+"""
+基础工具适配器 - 第三方工具适配接口
+
+职责:
+1. 定义统一的适配器接口
+2. 处理工具执行上下文的转换
+3. 统一返回值格式
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any, Callable, Dict
+
+from agent.tools.models import ToolResult, ToolContext
+
+
+class ToolAdapter(ABC):
+    """工具适配器基类"""
+
+    @abstractmethod
+    async def adapt_execute(
+        self,
+        tool_func: Callable,
+        args: Dict[str, Any],
+        context: ToolContext
+    ) -> ToolResult:
+        """
+        适配第三方工具的执行
+
+        Args:
+            tool_func: 原始工具函数/对象
+            args: 工具参数
+            context: 我们的上下文对象
+
+        Returns:
+            ToolResult: 统一的返回格式
+        """
+        pass
+
+    @abstractmethod
+    def adapt_schema(self, original_schema: Dict) -> Dict:
+        """
+        适配工具 Schema 到我们的格式
+
+        Args:
+            original_schema: 原始工具的 Schema
+
+        Returns:
+            适配后的 Schema(OpenAI Tool Schema 格式)
+        """
+        pass
+
+    def extract_memory(self, result: Any) -> str:
+        """
+        从结果中提取长期记忆摘要
+
+        Args:
+            result: 工具执行结果
+
+        Returns:
+            记忆摘要字符串
+        """
+        return ""

+ 120 - 0
agent/tools/adapters/opencode-wrapper.ts

@@ -0,0 +1,120 @@
+/**
+ * OpenCode Tool Wrapper - 供 Python 调用的命令行接口
+ *
+ * 用法:
+ * bun run tool-wrapper.ts <tool_name> <args_json>
+ *
+ * 示例:
+ * bun run tool-wrapper.ts read '{"filePath": "config.py"}'
+ *
+ * 支持的工具:
+ * - read: 读取文件
+ * - edit: 编辑文件
+ * - write: 写入文件
+ * - bash: 执行命令
+ * - glob: 文件匹配
+ * - grep: 内容搜索
+ * - webfetch: 抓取网页
+ * - lsp: LSP 诊断
+ */
+
+import { resolve } from 'path'
+
+// 动态导入工具(避免编译时依赖)
+async function loadTool(toolName: string) {
+  // 从 agent/tools/adapters/ 定位到 vendor/opencode
+  const toolPath = resolve(__dirname, '../../../../vendor/opencode/packages/opencode/src/tool')
+
+  switch (toolName) {
+    case 'read':
+      return (await import(`${toolPath}/read.ts`)).ReadTool
+    case 'edit':
+      return (await import(`${toolPath}/edit.ts`)).EditTool
+    case 'write':
+      return (await import(`${toolPath}/write.ts`)).WriteTool
+    case 'bash':
+      return (await import(`${toolPath}/bash.ts`)).BashTool
+    case 'glob':
+      return (await import(`${toolPath}/glob.ts`)).GlobTool
+    case 'grep':
+      return (await import(`${toolPath}/grep.ts`)).GrepTool
+    case 'webfetch':
+      return (await import(`${toolPath}/webfetch.ts`)).WebFetchTool
+    case 'lsp':
+      return (await import(`${toolPath}/lsp.ts`)).LspTool
+    default:
+      throw new Error(`Unknown tool: ${toolName}`)
+  }
+}
+
+async function main() {
+  const toolName = process.argv[2]
+  const argsJson = process.argv[3]
+
+  if (!toolName || !argsJson) {
+    console.error('Usage: bun run tool-wrapper.ts <tool_name> <args_json>')
+    console.error('Example: bun run tool-wrapper.ts read \'{"filePath": "test.py"}\'')
+    process.exit(1)
+  }
+
+  try {
+    // 解析参数
+    const args = JSON.parse(argsJson)
+
+    // 加载工具
+    const Tool = await loadTool(toolName)
+
+    // 构造最小化的 context(Python 调用不需要完整 context)
+    const context = {
+      sessionID: 'python-adapter',
+      messageID: 'python-adapter',
+      agent: 'python',
+      abort: new AbortController().signal,
+      messages: [],
+      metadata: () => {},
+      ask: async () => {}, // 跳过权限检查(由 Python 层处理)
+    }
+
+    // 初始化工具
+    const toolInfo = await Tool.init()
+
+    // 执行工具
+    const result = await toolInfo.execute(args, context)
+
+    // 输出 JSON 结果
+    const output = {
+      title: result.title,
+      output: result.output,
+      metadata: result.metadata || {},
+      attachments: result.attachments || []
+    }
+
+    console.log(JSON.stringify(output))
+
+  } catch (error: any) {
+    // 输出错误信息
+    const errorOutput = {
+      title: 'Error',
+      output: `Tool execution failed: ${error.message}`,
+      metadata: {
+        error: error.message,
+        stack: error.stack
+      }
+    }
+
+    console.error(JSON.stringify(errorOutput))
+    process.exit(1)
+  }
+}
+
+main().catch((error) => {
+  console.error(JSON.stringify({
+    title: 'Fatal Error',
+    output: `Fatal error: ${error.message}`,
+    metadata: {
+      error: error.message,
+      stack: error.stack
+    }
+  }))
+  process.exit(1)
+})

+ 138 - 0
agent/tools/adapters/opencode_bun_adapter.py

@@ -0,0 +1,138 @@
+"""
+OpenCode Bun 适配器 - 通过子进程调用 opencode 工具
+
+这个适配器真正调用 opencode 的 TypeScript 实现,
+而不是 Python 重新实现。
+
+使用场景:
+- 高级工具(LSP、CodeSearch 等)
+- 需要完整功能(9 种编辑策略)
+- 不在意性能开销(50-100ms per call)
+"""
+
+import json
+import asyncio
+import subprocess
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+from agent.tools.adapters.base import ToolAdapter
+from agent.tools.models import ToolResult, ToolContext
+
+
+class OpenCodeBunAdapter(ToolAdapter):
+    """
+    通过 Bun 子进程调用 opencode 工具
+
+    需要安装 Bun: https://bun.sh/
+    """
+
+    def __init__(self):
+        # wrapper 和 adapter 在同一目录
+        self.wrapper_script = Path(__file__).parent / "opencode-wrapper.ts"
+        self.opencode_path = Path(__file__).parent.parent.parent.parent / "vendor/opencode"
+
+        # 检查 Bun 是否可用
+        self._check_bun()
+
+    def _check_bun(self):
+        """检查 Bun 运行时是否可用"""
+        try:
+            result = subprocess.run(
+                ["bun", "--version"],
+                capture_output=True,
+                timeout=5
+            )
+            if result.returncode != 0:
+                raise RuntimeError("Bun is not available")
+        except FileNotFoundError:
+            raise RuntimeError(
+                "Bun runtime not found. Install from https://bun.sh/\n"
+                "Or use Python-based tools instead."
+            )
+
+    async def adapt_execute(
+        self,
+        tool_name: str,  # 'read', 'edit', 'bash' 等
+        args: Dict[str, Any],
+        context: Optional[ToolContext] = None
+    ) -> ToolResult:
+        """
+        调用 opencode 工具
+
+        Args:
+            tool_name: opencode 工具名称
+            args: 工具参数
+            context: 上下文
+        """
+        # 构造命令
+        cmd = [
+            "bun", "run",
+            str(self.wrapper_script),
+            tool_name,
+            json.dumps(args)
+        ]
+
+        # 执行
+        try:
+            process = await asyncio.create_subprocess_exec(
+                *cmd,
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+                cwd=str(self.opencode_path)
+            )
+
+            stdout, stderr = await asyncio.wait_for(
+                process.communicate(),
+                timeout=30  # 30 秒超时
+            )
+
+            if process.returncode != 0:
+                error_msg = stderr.decode('utf-8', errors='replace')
+                return ToolResult(
+                    title="OpenCode Error",
+                    output=f"工具执行失败: {error_msg}",
+                    error=error_msg
+                )
+
+            # 解析结果
+            result_data = json.loads(stdout.decode('utf-8'))
+
+            # 转换为 ToolResult
+            return ToolResult(
+                title=result_data.get("title", ""),
+                output=result_data.get("output", ""),
+                metadata=result_data.get("metadata", {}),
+                long_term_memory=self.extract_memory(result_data)
+            )
+
+        except asyncio.TimeoutError:
+            return ToolResult(
+                title="Timeout",
+                output="OpenCode 工具执行超时",
+                error="Timeout after 30s"
+            )
+        except Exception as e:
+            return ToolResult(
+                title="Execution Error",
+                output=f"调用 OpenCode 失败: {str(e)}",
+                error=str(e)
+            )
+
+    def adapt_schema(self, original_schema: Dict) -> Dict:
+        """OpenCode 使用 OpenAI 格式,直接返回"""
+        return original_schema
+
+    def extract_memory(self, result: Dict) -> str:
+        """从 opencode 结果提取记忆"""
+        metadata = result.get("metadata", {})
+
+        if metadata.get("truncated"):
+            return f"输出被截断 (file: {result.get('title', '')})"
+
+        if "diagnostics" in metadata:
+            count = len(metadata["diagnostics"])
+            if count > 0:
+                return f"检测到 {count} 个诊断问题"
+
+        return ""

+ 15 - 0
agent/tools/advanced/__init__.py

@@ -0,0 +1,15 @@
+"""
+高级工具 - 通过 Bun 适配器调用 OpenCode
+
+这些工具实现复杂,直接调用 opencode 的 TypeScript 实现。
+
+需要 Bun 运行时:https://bun.sh/
+"""
+
+from agent.tools.advanced.webfetch import webfetch
+from agent.tools.advanced.lsp import lsp_diagnostics
+
+__all__ = [
+    "webfetch",
+    "lsp_diagnostics",
+]

+ 52 - 0
agent/tools/advanced/lsp.py

@@ -0,0 +1,52 @@
+"""
+LSP Tool - 通过 Bun 适配器调用 OpenCode
+
+Language Server Protocol 集成,提供代码诊断、补全等功能。
+"""
+
+from typing import Optional
+from agent.tools import tool, ToolResult, ToolContext
+from agent.tools.adapters.opencode_bun_adapter import OpenCodeBunAdapter
+
+
+# 创建适配器实例
+_adapter = None
+
+def _get_adapter():
+    global _adapter
+    if _adapter is None:
+        _adapter = OpenCodeBunAdapter()
+    return _adapter
+
+
+@tool(description="获取文件的 LSP 诊断信息(语法错误、类型错误等)")
+async def lsp_diagnostics(
+    file_path: str,
+    uid: str = "",
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    获取 LSP 诊断信息
+
+    使用 OpenCode 的 LSP 工具(通过 Bun 适配器调用)。
+    返回文件的语法错误、类型错误、代码警告等。
+
+    Args:
+        file_path: 文件路径
+        uid: 用户 ID
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 诊断信息
+    """
+    adapter = _get_adapter()
+
+    args = {
+        "filePath": file_path,
+    }
+
+    return await adapter.adapt_execute(
+        tool_name="lsp",
+        args=args,
+        context=context
+    )

+ 60 - 0
agent/tools/advanced/webfetch.py

@@ -0,0 +1,60 @@
+"""
+WebFetch Tool - 通过 Bun 适配器调用 OpenCode
+
+网页抓取功能,包括 HTML 转 Markdown、内容提取等复杂逻辑。
+"""
+
+from typing import Optional
+from agent.tools import tool, ToolResult, ToolContext
+from agent.tools.adapters.opencode_bun_adapter import OpenCodeBunAdapter
+
+
+# 创建适配器实例
+_adapter = None
+
+def _get_adapter():
+    global _adapter
+    if _adapter is None:
+        _adapter = OpenCodeBunAdapter()
+    return _adapter
+
+
+@tool(description="抓取网页内容并转换为 Markdown 格式")
+async def webfetch(
+    url: str,
+    format: str = "markdown",
+    timeout: Optional[int] = None,
+    uid: str = "",
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    抓取网页内容
+
+    使用 OpenCode 的 webfetch 工具(通过 Bun 适配器调用)。
+    包含 HTML 到 Markdown 转换、内容清理等功能。
+
+    Args:
+        url: 网页 URL
+        format: 输出格式(markdown, text, html),默认 markdown
+        timeout: 超时时间(秒)
+        uid: 用户 ID
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 网页内容
+    """
+    adapter = _get_adapter()
+
+    args = {
+        "url": url,
+        "format": format,
+    }
+
+    if timeout is not None:
+        args["timeout"] = timeout
+
+    return await adapter.adapt_execute(
+        tool_name="webfetch",
+        args=args,
+        context=context
+    )

+ 55 - 0
agent/tools/builtin/__init__.py

@@ -0,0 +1,55 @@
+"""
+内置基础工具 - 参考 opencode 实现
+
+这些工具参考 vendor/opencode/packages/opencode/src/tool/ 的设计,
+在 Python 中重新实现核心功能。
+
+参考版本:opencode main branch (2025-01)
+"""
+
+from agent.tools.builtin.file.read import read_file
+from agent.tools.builtin.file.edit import edit_file
+from agent.tools.builtin.file.write import write_file
+from agent.tools.builtin.glob_tool import glob_files
+from agent.tools.builtin.file.grep import grep_content
+from agent.tools.builtin.bash import bash_command
+from agent.tools.builtin.skill import skill, list_skills
+from agent.tools.builtin.subagent import agent, evaluate
+from agent.tools.builtin.search import search_posts, get_search_suggestions
+from agent.tools.builtin.sandbox import (sandbox_create_environment, sandbox_run_shell,
+                                         sandbox_rebuild_with_ports,sandbox_destroy_environment)
+from agent.tools.builtin.knowledge import(knowledge_search,knowledge_save,knowledge_list,knowledge_update,knowledge_batch_update,knowledge_slim)
+from agent.trace.goal_tool import goal
+# 导入浏览器工具以触发注册
+import agent.tools.builtin.browser  # noqa: F401
+
+import agent.tools.builtin.feishu
+
+__all__ = [
+    # 文件操作
+    "read_file",
+    "edit_file",
+    "write_file",
+    "glob_files",
+    "grep_content",
+    # 系统工具
+    "bash_command",
+    "skill",
+    "knowledge_search",
+    "knowledge_save",
+    "knowledge_list",
+    "knowledge_update",
+    "knowledge_batch_update",
+    "knowledge_slim",
+    "list_skills",
+    "agent",
+    "evaluate",
+    "search_posts",
+    "get_search_suggestions",
+    "sandbox_create_environment",
+    "sandbox_run_shell",
+    "sandbox_rebuild_with_ports",
+    "sandbox_destroy_environment",
+    # Goal 管理
+    "goal",
+]

+ 315 - 0
agent/tools/builtin/bash.py

@@ -0,0 +1,315 @@
+"""
+Bash Tool - 命令执行工具
+
+核心功能:
+- 执行 shell 命令
+- 超时控制
+- 工作目录设置
+- 环境变量传递
+- 虚拟环境隔离(Python 命令,强制执行,LLM 不可控)
+- 目录白名单保护
+"""
+
+import os
+import signal
+import asyncio
+import logging
+from pathlib import Path
+from typing import Optional, Dict
+
+from agent.tools import tool, ToolResult, ToolContext
+
+# 常量
+DEFAULT_TIMEOUT = 120
+MAX_OUTPUT_LENGTH = 50000
+GRACEFUL_KILL_WAIT = 3
+
+# ===== 安全配置(模块级,LLM 不可控)=====
+ENABLE_VENV = True                # 是否强制启用虚拟环境
+VENV_DIR = ".venv"                # 虚拟环境目录名(相对于项目根目录)
+
+ALLOWED_WORKDIR_PATTERNS = [
+    ".",
+    "examples",
+    "examples/**",
+    ".cache",
+    ".cache/**",
+    "tests",
+    "tests/**",
+    "output",
+    "output/**",
+]
+
+PYTHON_KEYWORDS = ["python", "python3", "pip", "pip3", "pytest", "poetry", "uv"]
+
+logger = logging.getLogger(__name__)
+
+
+def _get_project_root() -> Path:
+    """获取项目根目录(bash.py 在 agent/tools/builtin/ 下)"""
+    return Path(__file__).parent.parent.parent.parent
+
+
+def _is_safe_workdir(path: Path) -> bool:
+    """检查工作目录是否在白名单内"""
+    try:
+        project_root = _get_project_root()
+        resolved_path = path.resolve()
+        resolved_root = project_root.resolve()
+
+        if not resolved_path.is_relative_to(resolved_root):
+            return False
+
+        relative_path = resolved_path.relative_to(resolved_root)
+
+        for pattern in ALLOWED_WORKDIR_PATTERNS:
+            if pattern == ".":
+                if relative_path == Path("."):
+                    return True
+            elif pattern.endswith("/**"):
+                base = Path(pattern[:-3])
+                if relative_path == base or relative_path.is_relative_to(base):
+                    return True
+            else:
+                if relative_path == Path(pattern):
+                    return True
+
+        return False
+    except (ValueError, OSError) as e:
+        logger.warning(f"路径检查失败: {e}")
+        return False
+
+
+def _should_use_venv(command: str) -> bool:
+    """判断命令是否应该在虚拟环境中执行"""
+    command_lower = command.lower()
+    for keyword in PYTHON_KEYWORDS:
+        if keyword in command_lower:
+            return True
+    return False
+
+
+async def _ensure_venv(venv_path: Path) -> bool:
+    """确保虚拟环境存在,不存在则创建"""
+    if os.name == 'nt':
+        python_exe = venv_path / "Scripts" / "python.exe"
+    else:
+        python_exe = venv_path / "bin" / "python"
+
+    if venv_path.exists() and python_exe.exists():
+        return True
+
+    # 创建虚拟环境
+    print(f"[bash] 正在创建虚拟环境: {venv_path}")
+    logger.info(f"创建虚拟环境: {venv_path}")
+    try:
+        process = await asyncio.create_subprocess_shell(
+            f"python -m venv {venv_path}",
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60)
+
+        if process.returncode == 0:
+            print(f"[bash] ✅ 虚拟环境创建成功: {venv_path}")
+            logger.info(f"虚拟环境创建成功: {venv_path}")
+            return True
+        else:
+            err_text = stderr.decode('utf-8', errors='replace') if stderr else ""
+            print(f"[bash] ❌ 虚拟环境创建失败: {err_text[:200]}")
+            logger.error(f"虚拟环境创建失败: exit code {process.returncode}, {err_text[:200]}")
+            return False
+
+    except Exception as e:
+        print(f"[bash] ❌ 虚拟环境创建异常: {e}")
+        logger.error(f"虚拟环境创建异常: {e}")
+        return False
+
+
+def _wrap_command_with_venv(command: str, venv_path: Path) -> str:
+    """将命令包装为在虚拟环境中执行"""
+    if os.name == 'nt':
+        activate_script = venv_path / "Scripts" / "activate.bat"
+        return f'call "{activate_script}" && {command}'
+    else:
+        activate_script = venv_path / "bin" / "activate"
+        return f'source "{activate_script}" && {command}'
+
+
+def _kill_process_tree(pid: int) -> None:
+    """先 SIGTERM 整个进程组,等 GRACEFUL_KILL_WAIT 秒后 SIGKILL 兜底。"""
+    import time
+
+    try:
+        pgid = os.getpgid(pid)
+    except ProcessLookupError:
+        return
+
+    try:
+        os.killpg(pgid, signal.SIGTERM)
+    except ProcessLookupError:
+        return
+
+    time.sleep(GRACEFUL_KILL_WAIT)
+
+    try:
+        os.killpg(pgid, signal.SIGKILL)
+    except ProcessLookupError:
+        pass
+
+
+@tool(description="执行 bash 命令", hidden_params=["context"])
+async def bash_command(
+    command: str,
+    timeout: Optional[int] = None,
+    workdir: Optional[str] = None,
+    env: Optional[Dict[str, str]] = None,
+    description: str = "",
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    执行 bash 命令
+
+    Args:
+        command: 要执行的命令
+        timeout: 超时时间(秒),默认 120 秒
+        workdir: 工作目录,默认为当前目录
+        env: 环境变量字典(会合并到系统环境变量)
+        description: 命令描述(5-10 个词)
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 命令输出
+    """
+    if timeout is not None and timeout < 0:
+        return ToolResult(
+            title="参数错误",
+            output=f"无效的 timeout 值: {timeout}。必须是正数。",
+            error="Invalid timeout"
+        )
+
+    timeout_sec = timeout or DEFAULT_TIMEOUT
+
+    # 工作目录
+    cwd = Path(workdir) if workdir else Path.cwd()
+    if not cwd.exists():
+        return ToolResult(
+            title="目录不存在",
+            output=f"工作目录不存在: {workdir}",
+            error="Directory not found"
+        )
+
+    # 目录白名单检查
+    if not _is_safe_workdir(cwd):
+        project_root = _get_project_root()
+        return ToolResult(
+            title="目录不允许",
+            output=(
+                f"工作目录不在白名单内: {cwd}\n"
+                f"项目根目录: {project_root}\n"
+                f"允许的目录: {', '.join(ALLOWED_WORKDIR_PATTERNS)}"
+            ),
+            error="Directory not allowed"
+        )
+
+    # 虚拟环境处理(强制执行,LLM 不可绕过)
+    actual_command = command
+    if ENABLE_VENV and _should_use_venv(command):
+        venv_dir = _get_project_root() / VENV_DIR
+
+        venv_ok = await _ensure_venv(venv_dir)
+        if venv_ok:
+            actual_command = _wrap_command_with_venv(command, venv_dir)
+            print(f"[bash] 🐍 使用虚拟环境: {venv_dir}")
+            logger.info(f"[bash] 使用虚拟环境: {venv_dir}")
+        else:
+            logger.warning(f"[bash] 虚拟环境不可用,回退到系统环境: {venv_dir}")
+
+    # 准备环境变量
+    process_env = os.environ.copy()
+    if env:
+        process_env.update(env)
+
+    # 执行命令
+    try:
+        if os.name == 'nt':
+            process = await asyncio.create_subprocess_shell(
+                actual_command,
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+                cwd=str(cwd),
+                env=process_env,
+            )
+        else:
+            process = await asyncio.create_subprocess_shell(
+                actual_command,
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+                cwd=str(cwd),
+                env=process_env,
+                start_new_session=True,
+            )
+
+        try:
+            stdout, stderr = await asyncio.wait_for(
+                process.communicate(),
+                timeout=timeout_sec
+            )
+        except asyncio.TimeoutError:
+            _kill_process_tree(process.pid)
+            try:
+                await asyncio.wait_for(process.wait(), timeout=GRACEFUL_KILL_WAIT + 2)
+            except asyncio.TimeoutError:
+                pass
+            return ToolResult(
+                title="命令超时",
+                output=f"命令执行超时(>{timeout_sec}s): {command[:100]}",
+                error="Timeout",
+                metadata={"command": command, "timeout": timeout_sec}
+            )
+
+        stdout_text = stdout.decode('utf-8', errors='replace') if stdout else ""
+        stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ""
+
+        truncated = False
+        if len(stdout_text) > MAX_OUTPUT_LENGTH:
+            stdout_text = stdout_text[:MAX_OUTPUT_LENGTH] + f"\n\n(输出被截断,总长度: {len(stdout_text)} 字符)"
+            truncated = True
+
+        output = ""
+        if stdout_text:
+            output += stdout_text
+        if stderr_text:
+            if output:
+                output += "\n\n--- stderr ---\n"
+            output += stderr_text
+        if not output:
+            output = "(命令无输出)"
+
+        exit_code = process.returncode
+        success = exit_code == 0
+
+        title = description or f"命令: {command[:50]}"
+        if not success:
+            title += f" (exit code: {exit_code})"
+
+        return ToolResult(
+            title=title,
+            output=output,
+            metadata={
+                "exit_code": exit_code,
+                "success": success,
+                "truncated": truncated,
+                "command": command,
+                "cwd": str(cwd)
+            },
+            error=None if success else f"Command failed with exit code {exit_code}"
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="执行错误",
+            output=f"命令执行失败: {str(e)}",
+            error=str(e),
+            metadata={"command": command}
+        )

+ 115 - 0
agent/tools/builtin/browser/__init__.py

@@ -0,0 +1,115 @@
+"""
+浏览器工具 - Browser-Use 原生工具适配器
+
+基于 browser-use 实现的浏览器自动化工具集。
+"""
+
+from agent.tools.builtin.browser.baseClass import (
+    # 会话管理
+    init_browser_session,
+    get_browser_session,
+    cleanup_browser_session,
+    kill_browser_session,
+
+    # 导航类工具
+    browser_navigate_to_url,
+    browser_search_web,
+    browser_go_back,
+    browser_wait,
+
+    # 元素交互工具
+    browser_click_element,
+    browser_input_text,
+    browser_send_keys,
+    browser_upload_file,
+
+    # 滚动和视图工具
+    browser_scroll_page,
+    browser_find_text,
+    browser_screenshot,
+
+    # 标签页管理工具
+    browser_switch_tab,
+    browser_close_tab,
+
+    # 下拉框工具
+    browser_get_dropdown_options,
+    browser_select_dropdown_option,
+
+    # 内容提取工具
+    browser_extract_content,
+    browser_read_long_content,
+    browser_get_page_html,
+    browser_download_direct_url,
+    browser_get_selector_map,
+    browser_get_visual_selector_map,
+
+    # JavaScript 执行工具
+    browser_evaluate,
+    browser_ensure_login_with_cookies,
+
+    # 等待用户操作
+    browser_wait_for_user_action,
+
+    # 任务完成
+    browser_done,
+
+    # Cookie 持久化
+    browser_export_cookies,
+    browser_load_cookies,
+)
+
+__all__ = [
+    # 会话管理
+    'init_browser_session',
+    'get_browser_session',
+    'cleanup_browser_session',
+    'kill_browser_session',
+
+    # 导航类工具
+    'browser_navigate_to_url',
+    'browser_search_web',
+    'browser_go_back',
+    'browser_wait',
+
+    # 元素交互工具
+    'browser_click_element',
+    'browser_input_text',
+    'browser_send_keys',
+    'browser_upload_file',
+
+    # 滚动和视图工具
+    'browser_scroll_page',
+    'browser_find_text',
+    'browser_screenshot',
+
+    # 标签页管理工具
+    'browser_switch_tab',
+    'browser_close_tab',
+
+    # 下拉框工具
+    'browser_get_dropdown_options',
+    'browser_select_dropdown_option',
+
+    # 内容提取工具
+    'browser_extract_content',
+    'browser_read_long_content',
+    'browser_download_direct_url',
+    'browser_get_page_html',
+    'browser_get_selector_map',
+    'browser_get_visual_selector_map',
+
+    # JavaScript 执行工具
+    'browser_evaluate',
+    'browser_ensure_login_with_cookies',
+
+    # 等待用户操作
+    'browser_wait_for_user_action',
+
+    # 任务完成
+    'browser_done',
+
+    # Cookie 持久化
+    'browser_export_cookies',
+    'browser_load_cookies',
+]

+ 2200 - 0
agent/tools/builtin/browser/baseClass.py

@@ -0,0 +1,2200 @@
+"""
+Browser-Use 原生工具适配器
+Native Browser-Use Tools Adapter
+
+直接使用 browser-use 的原生类(BrowserSession, Tools)实现所有浏览器操作工具。
+不依赖 Playwright,完全基于 CDP 协议。
+
+核心特性:
+1. 浏览器会话持久化 - 只启动一次浏览器
+2. 状态自动保持 - 登录状态、Cookie、LocalStorage 等
+3. 完整的底层访问 - 可以直接使用 CDP 协议
+4. 性能优异 - 避免频繁创建/销毁浏览器实例
+5. 多种浏览器类型 - 支持 local、cloud、container 三种模式
+
+支持的浏览器类型:
+1. Local (本地浏览器):
+   - 在本地运行 Chrome
+   - 支持可视化调试
+   - 速度最快
+   - 示例: init_browser_session(browser_type="local")
+
+2. Cloud (云浏览器):
+   - 在云端运行
+   - 不占用本地资源
+   - 适合生产环境
+   - 示例: init_browser_session(browser_type="cloud")
+
+3. Container (容器浏览器):
+   - 在独立容器中运行
+   - 隔离性好
+   - 支持预配置账户
+   - 示例: init_browser_session(browser_type="container", container_url="https://example.com")
+
+使用方法:
+1. 在 Agent 初始化时调用 init_browser_session() 并指定 browser_type
+2. 使用各个工具函数执行浏览器操作
+3. 任务结束时调用 cleanup_browser_session()
+
+文件操作说明:
+- 浏览器专用文件目录:.cache/.browser_use_files/ (在当前工作目录下)
+  用于存储浏览器会话产生的临时文件(下载、上传、截图等)
+- 一般文件操作:请使用 agent.tools.builtin 中的文件工具 (read_file, write_file, edit_file)
+  这些工具功能更完善,支持diff预览、智能匹配、分页读取等
+"""
+import logging
+import sys
+import os
+import json
+import httpx
+import asyncio
+import aiohttp
+import re
+import base64
+from urllib.parse import urlparse, parse_qs, unquote
+from typing import Optional, List, Dict, Any, Tuple, Union
+from pathlib import Path
+from langchain_core.runnables import RunnableLambda
+from argparse import Namespace # 使用 Namespace 快速构造带属性的对象
+from langchain_core.messages import AIMessage
+from ....llm.openrouter import openrouter_llm_call
+
+# 将项目根目录添加到 Python 路径
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+# 配置日志
+logger = logging.getLogger(__name__)
+
+# 导入框架的工具装饰器和结果类
+from agent.tools import tool, ToolResult
+from agent.tools.builtin.browser.sync_mysql_help import mysql
+
+# 导入 browser-use 的核心类
+from browser_use import BrowserSession, BrowserProfile
+from browser_use.tools.service import Tools
+try:
+    from browser_use.tools.views import ReadContentAction  # type: ignore
+except Exception:
+    from pydantic import BaseModel
+
+    class ReadContentAction(BaseModel):
+        goal: str
+        source: str = "page"
+        context: str = ""
+from browser_use.agent.views import ActionResult
+from browser_use.filesystem.file_system import FileSystem
+
+
+# ============================================================
+# 无需注册的内部辅助函数
+# ============================================================
+
+
+# ============================================================
+# 全局浏览器会话管理
+# ============================================================
+
+# 全局变量:浏览器会话和工具实例
+_browser_session: Optional[BrowserSession] = None
+_browser_tools: Optional[Tools] = None
+_file_system: Optional[FileSystem] = None
+
+async def create_container(url: str, account_name: str = "liuwenwu") -> Dict[str, Any]:
+    """
+    创建浏览器容器并导航到指定URL
+
+    按照 test.md 的要求:
+    1.1 调用接口创建容器
+    1.2 调用接口创建窗口并导航到URL
+
+    Args:
+        url: 要导航的URL地址
+        account_name: 账户名称
+
+    Returns:
+        包含容器信息的字典:
+        - success: 是否成功
+        - container_id: 容器ID
+        - vnc: VNC访问URL
+        - cdp: CDP协议URL(用于浏览器连接)
+        - connection_id: 窗口连接ID
+        - error: 错误信息(如果失败)
+    """
+    result = {
+        "success": False,
+        "container_id": None,
+        "vnc": None,
+        "cdp": None,
+        "connection_id": None,
+        "error": None
+    }
+
+    try:
+        async with aiohttp.ClientSession() as session:
+            # 步骤1.1: 创建容器
+            print("📦 步骤1.1: 创建容器...")
+            create_url = "http://47.84.182.56:8200/api/v1/container/create"
+            create_payload = {
+                "auto_remove": True,
+                "need_port_binding": True,
+                "max_lifetime_seconds": 900
+            }
+
+            async with session.post(create_url, json=create_payload) as resp:
+                if resp.status != 200:
+                    raise RuntimeError(f"创建容器失败: HTTP {resp.status}")
+
+                create_result = await resp.json()
+                if create_result.get("code") != 0:
+                    raise RuntimeError(f"创建容器失败: {create_result.get('msg')}")
+
+                data = create_result.get("data", {})
+                result["container_id"] = data.get("container_id")
+                result["vnc"] = data.get("vnc")
+                result["cdp"] = data.get("cdp")
+
+                print(f"✅ 容器创建成功")
+                print(f"   Container ID: {result['container_id']}")
+                print(f"   VNC: {result['vnc']}")
+                print(f"   CDP: {result['cdp']}")
+
+            # 等待容器内的浏览器启动
+            print(f"\n⏳ 等待容器内浏览器启动...")
+            await asyncio.sleep(5)
+
+            # 步骤1.2: 创建页面并导航
+            print(f"\n📱 步骤1.2: 创建页面并导航到 {url}...")
+
+            page_create_url = "http://47.84.182.56:8200/api/v1/browser/page/create"
+            page_payload = {
+                "container_id": result["container_id"],
+                "url": url,
+                "account_name": account_name,
+                "need_wait": True,
+                "timeout": 30
+            }
+
+            # 重试机制:最多尝试3次
+            max_retries = 3
+            page_created = False
+            last_error = None
+
+            for attempt in range(max_retries):
+                try:
+                    if attempt > 0:
+                        print(f"   重试 {attempt + 1}/{max_retries}...")
+                        await asyncio.sleep(3)  # 重试前等待
+
+                    async with session.post(page_create_url, json=page_payload, timeout=aiohttp.ClientTimeout(total=60)) as resp:
+                        if resp.status != 200:
+                            response_text = await resp.text()
+                            last_error = f"HTTP {resp.status}: {response_text[:200]}"
+                            continue
+
+                        page_result = await resp.json()
+                        if page_result.get("code") != 0:
+                            last_error = f"{page_result.get('msg')}"
+                            continue
+
+                        page_data = page_result.get("data", {})
+                        result["connection_id"] = page_data.get("connection_id")
+                        result["success"] = True
+                        page_created = True
+
+                        print(f"✅ 页面创建成功")
+                        print(f"   Connection ID: {result['connection_id']}")
+                        break
+
+                except asyncio.TimeoutError:
+                    last_error = "请求超时"
+                    continue
+                except aiohttp.ClientError as e:
+                    last_error = f"网络错误: {str(e)}"
+                    continue
+                except Exception as e:
+                    last_error = f"未知错误: {str(e)}"
+                    continue
+
+            if not page_created:
+                raise RuntimeError(f"创建页面失败(尝试{max_retries}次后): {last_error}")
+
+    except Exception as e:
+        result["error"] = str(e)
+        print(f"❌ 错误: {str(e)}")
+
+    return result
+
+async def init_browser_session(
+    browser_type: str = "local",
+    # TEMPORARY FIX (2026-03-02): 改为 True 以解决 CDP 连接时序问题
+    # browser-use 在非 headless 模式下有时会在 Chrome 完全启动前尝试连接 CDP,
+    # 导致 "JSONDecodeError: Expecting value" 错误
+    # TODO: 之后改回 headless: bool = False,或在 browser-use 修复此问题后移除此注释
+    headless: bool = True,  # 原值: False
+    url: Optional[str] = None,
+    profile_name: str = "default",
+    user_data_dir: Optional[str] = None,
+    browser_profile: Optional[BrowserProfile] = None,
+    **kwargs
+) -> tuple[BrowserSession, Tools]:
+    global _browser_session, _browser_tools, _file_system
+
+    if _browser_session is not None:
+        return _browser_session, _browser_tools
+
+    valid_types = ["local", "cloud", "container"]
+    if browser_type not in valid_types:
+        raise ValueError(f"无效的 browser_type: {browser_type}")
+
+    # --- 核心:定义本地统一存储路径 ---
+    save_dir = Path.cwd() / ".cache/.browser_use_files"
+    save_dir.mkdir(parents=True, exist_ok=True)
+
+    # 基础参数配置
+    session_params = {
+        "headless": headless,
+        # 告诉 Playwright 所有的下载临时流先存入此本地目录
+        "downloads_path": str(save_dir), 
+    }
+
+    if browser_type == "container":
+        print("🐳 使用容器浏览器模式")
+        if not url: url = "about:blank"
+        container_info = await create_container(url=url, account_name=profile_name)
+        if not container_info["success"]:
+            raise RuntimeError(f"容器创建失败: {container_info['error']}")
+        session_params["cdp_url"] = container_info["cdp"]
+        await asyncio.sleep(3)
+
+    elif browser_type == "cloud":
+        print("🌐 使用云浏览器模式")
+        session_params["use_cloud"] = True
+        if profile_name and profile_name != "default":
+            session_params["cloud_profile_id"] = profile_name
+
+    else:  # local
+        print("💻 使用本地浏览器模式")
+        session_params["is_local"] = True
+        if user_data_dir is None and profile_name:
+            user_data_dir = str(Path.home() / ".browser_use" / "profiles" / profile_name)
+            Path(user_data_dir).mkdir(parents=True, exist_ok=True)
+            session_params["user_data_dir"] = user_data_dir
+        
+        # macOS 路径兼容
+        import platform
+        if platform.system() == "Darwin":
+            chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+            if Path(chrome_path).exists():
+                session_params["executable_path"] = chrome_path
+
+    if browser_profile:
+        session_params["browser_profile"] = browser_profile
+
+    session_params.update(kwargs)
+
+    # 创建会话
+    _browser_session = BrowserSession(**session_params)
+    # 添加短暂延迟,确保 Chrome CDP 端点完全就绪
+    await asyncio.sleep(1)
+    await _browser_session.start()
+
+    _browser_tools = Tools()
+    _file_system = FileSystem(base_dir=str(save_dir))
+
+    print(f"✅ 浏览器会话初始化成功 | 默认下载路径: {save_dir}")
+
+    if browser_type in ["local", "cloud"] and url:
+        await _browser_tools.navigate(url=url, browser_session=_browser_session)
+
+    return _browser_session, _browser_tools
+
+
+async def get_browser_session() -> tuple[BrowserSession, Tools]:
+    """
+    获取当前浏览器会话,如果不存在或连接已断开则自动重新创建
+
+    Returns:
+        (BrowserSession, Tools) 元组
+    """
+    global _browser_session, _browser_tools, _file_system
+
+    if _browser_session is not None:
+        # 检查底层 CDP 连接是否仍然存活
+        # 当 runner.stop() 暂停后用户在菜单停留较久,WebSocket 可能超时断开,
+        # 但 _browser_session 对象仍然存在,导致后续操作抛出 ConnectionClosedError
+        alive = False
+        try:
+            cdp_root = getattr(_browser_session, '_cdp_client_root', None)
+            sess_mgr = getattr(_browser_session, 'session_manager', None)
+            if cdp_root is not None and sess_mgr is not None:
+                cdp_session = await _browser_session.get_or_create_cdp_session()
+                await asyncio.wait_for(
+                    cdp_session.cdp_client.send.Runtime.evaluate(
+                        params={'expression': '1+1'},
+                        session_id=cdp_session.session_id
+                    ),
+                    timeout=3.0,
+                )
+                alive = True
+        except Exception:
+            pass
+
+        if not alive:
+            print("⚠️ 浏览器会话连接已断开,正在重新初始化...")
+            try:
+                await cleanup_browser_session()
+            except Exception:
+                _browser_session = None
+                _browser_tools = None
+                _file_system = None
+
+    if _browser_session is None:
+        await init_browser_session()
+
+    return _browser_session, _browser_tools
+
+
+async def cleanup_browser_session():
+    """
+    清理浏览器会话
+    优雅地停止浏览器但保留会话状态
+    """
+    global _browser_session, _browser_tools, _file_system
+
+    if _browser_session is not None:
+        await _browser_session.stop()
+        _browser_session = None
+        _browser_tools = None
+        _file_system = None
+
+
+async def kill_browser_session():
+    """
+    强制终止浏览器会话
+    完全关闭浏览器进程
+    """
+    global _browser_session, _browser_tools, _file_system
+
+    if _browser_session is not None:
+        await _browser_session.kill()
+        _browser_session = None
+        _browser_tools = None
+        _file_system = None
+
+
+# ============================================================
+# 辅助函数:ActionResult 转 ToolResult
+# ============================================================
+
+def action_result_to_tool_result(result: ActionResult, title: str = None) -> ToolResult:
+    """
+    将 browser-use 的 ActionResult 转换为框架的 ToolResult
+
+    Args:
+        result: browser-use 的 ActionResult
+        title: 可选的标题(如果不提供则从 result 推断)
+
+    Returns:
+        ToolResult
+    """
+    if result.error:
+        return ToolResult(
+            title=title or "操作失败",
+            output="",
+            error=result.error,
+            long_term_memory=result.long_term_memory or result.error
+        )
+
+    return ToolResult(
+        title=title or "操作成功",
+        output=result.extracted_content or "",
+        long_term_memory=result.long_term_memory or result.extracted_content or "",
+        metadata=result.metadata or {}
+    )
+
+
+def _cookie_domain_for_type(cookie_type: str, url: str) -> Tuple[str, str]:
+    if cookie_type:
+        key = cookie_type.lower()
+        if key in {"xiaohongshu", "xhs"}:
+            return ".xiaohongshu.com", "https://www.xiaohongshu.com"
+    parsed = urlparse(url or "")
+    domain = parsed.netloc or ""
+    domain = domain.replace("www.", "")
+    if domain:
+        domain = f".{domain}"
+    base_url = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else url
+    return domain, base_url
+
+
+def _parse_cookie_string(cookie_str: str, domain: str, url: str) -> List[Dict[str, Any]]:
+    cookies: List[Dict[str, Any]] = []
+    if not cookie_str:
+        return cookies
+    parts = cookie_str.split(";")
+    for part in parts:
+        if not part:
+            continue
+        if "=" not in part:
+            continue
+        name, value = part.split("=", 1)
+        cookie = {
+            "name": str(name).strip(),
+            "value": str(value).strip(),
+            "domain": domain,
+            "path": "/",
+            "expires": -1,
+            "httpOnly": False,
+            "secure": True,
+            "sameSite": "None"
+        }
+        if url:
+            cookie["url"] = url
+        cookies.append(cookie)
+    return cookies
+
+
+def _normalize_cookies(cookie_value: Any, domain: str, url: str) -> List[Dict[str, Any]]:
+    if cookie_value is None:
+        return []
+    if isinstance(cookie_value, list):
+        return cookie_value
+    if isinstance(cookie_value, dict):
+        if "cookies" in cookie_value:
+            return _normalize_cookies(cookie_value.get("cookies"), domain, url)
+        if "name" in cookie_value and "value" in cookie_value:
+            return [cookie_value]
+        return []
+    if isinstance(cookie_value, (bytes, bytearray)):
+        cookie_value = cookie_value.decode("utf-8", errors="ignore")
+    if isinstance(cookie_value, str):
+        text = cookie_value.strip()
+        if not text:
+            return []
+        try:
+            parsed = json.loads(text)
+        except Exception:
+            parsed = None
+        if parsed is not None:
+            return _normalize_cookies(parsed, domain, url)
+        return _parse_cookie_string(text, domain, url)
+    return []
+
+
+def _extract_cookie_value(row: Optional[Dict[str, Any]]) -> Any:
+    if not row:
+        return None
+    # 优先使用 cookies 字段
+    if "cookies" in row:
+        return row["cookies"]
+    # 兼容其他可能的字段名
+    for key, value in row.items():
+        if "cookie" in key.lower():
+            return value
+    return None
+
+
+def _fetch_cookie_row(cookie_type: str) -> Optional[Dict[str, Any]]:
+    if not cookie_type:
+        return None
+    try:
+        return mysql.fetchone(
+            "select * from agent_channel_cookies where type=%s limit 1",
+            (cookie_type,)
+        )
+    except Exception:
+        return None
+
+
+def _fetch_profile_id(cookie_type: str) -> Optional[str]:
+    """从数据库获取 cloud_profile_id"""
+    if not cookie_type:
+        return None
+    try:
+        row = mysql.fetchone(
+            "select profileId from agent_channel_cookies where type=%s limit 1",
+            (cookie_type,)
+        )
+        if row and "profileId" in row:
+            return row["profileId"]
+        return None
+    except Exception:
+        return None
+
+
+# ============================================================
+# 需要注册的工具
+# ============================================================
+
+# ============================================================
+# 导航类工具 (Navigation Tools)
+# ============================================================
+
+@tool()
+async def browser_navigate_to_url(url: str, new_tab: bool = False) -> ToolResult:
+    """
+    导航到指定的 URL
+    Navigate to a specific URL
+
+    使用 browser-use 的原生导航功能,支持在新标签页打开。
+
+    Args:
+        url: 要访问的 URL 地址
+        new_tab: 是否在新标签页中打开(默认 False)
+
+    Returns:
+        ToolResult: 包含导航结果的工具返回对象
+
+    Example:
+        navigate_to_url("https://www.baidu.com")
+        navigate_to_url("https://www.google.com", new_tab=True)
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        # 使用 browser-use 的 navigate 工具
+        result = await tools.navigate(
+            url=url,
+            new_tab=new_tab,
+            browser_session=browser
+        )
+
+        return action_result_to_tool_result(result, f"导航到 {url}")
+
+    except Exception as e:
+        return ToolResult(
+            title="导航失败",
+            output="",
+            error=f"Failed to navigate to {url}: {str(e)}",
+            long_term_memory=f"导航到 {url} 失败"
+        )
+
+
+@tool()
+async def browser_search_web(query: str, engine: str = "bing") -> ToolResult:
+    """
+    使用搜索引擎搜索
+    Search the web using a search engine
+
+    Args:
+        query: 搜索关键词
+        engine: 搜索引擎 (google, duckduckgo, bing) - 默认: google
+
+    Returns:
+        ToolResult: 搜索结果
+
+    Example:
+        search_web("Python async programming", engine="google")
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        # 使用 browser-use 的 search 工具
+        result = await tools.search(
+            query=query,
+            engine=engine,
+            browser_session=browser
+        )
+
+        return action_result_to_tool_result(result, f"搜索: {query}")
+
+    except Exception as e:
+        return ToolResult(
+            title="搜索失败",
+            output="",
+            error=f"Search failed: {str(e)}",
+            long_term_memory=f"搜索 '{query}' 失败"
+        )
+
+
+@tool()
+async def browser_go_back() -> ToolResult:
+    """
+    返回到上一个页面
+    Go back to the previous page
+
+    模拟浏览器的"后退"按钮功能。
+
+    Returns:
+        ToolResult: 包含返回操作结果的工具返回对象
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        result = await tools.go_back(browser_session=browser)
+
+        return action_result_to_tool_result(result, "返回上一页")
+
+    except Exception as e:
+        return ToolResult(
+            title="返回失败",
+            output="",
+            error=f"Failed to go back: {str(e)}",
+            long_term_memory="返回上一页失败"
+        )
+
+
+@tool()
+async def browser_wait(seconds: int = 3) -> ToolResult:
+    """
+    等待指定的秒数
+    Wait for a specified number of seconds
+
+    用于等待页面加载、动画完成或其他异步操作。
+
+    Args:
+        seconds: 等待时间(秒),最大30秒
+
+    Returns:
+        ToolResult: 包含等待操作结果的工具返回对象
+
+    Example:
+        wait(5)  # 等待5秒
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        result = await tools.wait(seconds=seconds, browser_session=browser)
+
+        return action_result_to_tool_result(result, f"等待 {seconds} 秒")
+
+    except Exception as e:
+        return ToolResult(
+            title="等待失败",
+            output="",
+            error=f"Failed to wait: {str(e)}",
+            long_term_memory="等待失败"
+        )
+
+
+# ============================================================
+# 元素交互工具 (Element Interaction Tools)
+# ============================================================
+
+# 定义一个专门捕获下载链接的 Handler
+class DownloadLinkCaptureHandler(logging.Handler):
+    def __init__(self):
+        super().__init__()
+        self.captured_url = None
+
+    def emit(self, record):
+        # 如果已经捕获到了(通常第一条是最完整的),就不再处理后续日志
+        if self.captured_url:
+            return
+
+        message = record.getMessage()
+        # 寻找包含下载信息的日志
+        if "redirection?filename=" in message or "Failed to download" in message:
+            # 使用更严格的正则,确保不抓取带省略号(...)的截断链接
+            # 排除掉末尾带有三个点的干扰
+            match = re.search(r"https?://[^\s]+(?!\.\.\.)", message)
+            if match:
+                url = match.group(0)
+                # 再次过滤:如果发现提取出的 URL 确实包含三个点,说明依然抓到了截断版,跳过
+                if "..." not in url:
+                    self.captured_url = url
+                    # print(f"🎯 成功锁定完整直链: {url[:50]}...") # 调试用
+
+@tool()
+async def browser_download_direct_url(url: str, save_name: str = "book.epub") -> ToolResult:
+    save_dir = Path.cwd() / ".cache/.browser_use_files"
+    save_dir.mkdir(parents=True, exist_ok=True)
+    
+    # 提取域名作为 Referer,这能骗过 90% 的防盗链校验
+    from urllib.parse import urlparse
+    parsed_url = urlparse(url)
+    base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/"
+    
+    # 如果没传 save_name,自动从 URL 获取
+    if not save_name:
+        import unquote
+        # 尝试从 URL 路径获取文件名并解码(处理中文)
+        save_name = Path(urlparse(url).path).name or f"download_{int(time.time())}"
+        save_name = unquote(save_name) 
+
+    target_path = save_dir / save_name
+
+    headers = {
+        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+        "Accept": "*/*",
+        "Referer": base_url,  # 动态设置 Referer
+        "Range": "bytes=0-",  # 有时对大文件下载有奇效
+    }
+
+    try:
+        print(f"🚀 开始下载: {url[:60]}...")
+        
+        # 使用 follow_redirects=True 处理链接中的 redirection
+        async with httpx.AsyncClient(headers=headers, follow_redirects=True, timeout=60.0) as client:
+            async with client.stream("GET", url) as response:
+                if response.status_code != 200:
+                    print(f"❌ 下载失败,HTTP 状态码: {response.status_code}")
+                    return
+                
+                # 获取实际文件名(如果服务器提供了)
+                # 这里会优先使用你指定的 save_name
+                
+                with open(target_path, "wb") as f:
+                    downloaded_bytes = 0
+                    async for chunk in response.aiter_bytes():
+                        f.write(chunk)
+                        downloaded_bytes += len(chunk)
+                        if downloaded_bytes % (1024 * 1024) == 0: # 每下载 1MB 打印一次
+                            print(f"📥 已下载: {downloaded_bytes // (1024 * 1024)} MB")
+
+        print(f"✅ 下载完成!文件已存至: {target_path}")
+        success_msg = f"✅ 下载完成!文件已存至: {target_path}"
+        return ToolResult(
+            title="直链下载成功",
+            output=success_msg,
+            long_term_memory=success_msg,
+            metadata={"path": str(target_path)}
+        )
+
+    except Exception as e:
+        # 异常捕获返回
+        return ToolResult(
+            title="下载异常",
+            output="",
+            error=f"💥 发生错误: {str(e)}",
+            long_term_memory=f"下载任务由于异常中断: {str(e)}"
+        )
+    
+@tool()
+async def browser_click_element(index: int) -> ToolResult:
+    """
+    点击页面元素,并自动通过拦截内部日志获取下载直链。
+    """
+    # 1. 挂载日志窃听器
+    capture_handler = DownloadLinkCaptureHandler()
+    logger = logging.getLogger("browser_use") # 拦截整个 browser_use 命名空间
+    logger.addHandler(capture_handler)
+    
+    try:
+        browser, tools = await get_browser_session()
+
+        # 2. 执行原生的点击动作
+        result = await tools.click(
+            index=index,
+            browser_session=browser
+        )
+
+        # 3. 检查是否有“意外收获”
+        download_msg = ""
+        if capture_handler.captured_url:
+            captured_url = capture_handler.captured_url
+            download_msg = f"\n\n⚠️ 系统检测到浏览器下载被拦截,已自动捕获准确直链:\n{captured_url}\n\n建议:你可以直接使用 browser_download_direct_url 工具下载此链接。"
+            
+            # 如果你想更激进一点,甚至可以在这里直接自动触发本地下载逻辑
+            # await auto_download_file(captured_url)
+
+        # 4. 转换结果并附加捕获的信息
+        tool_result = action_result_to_tool_result(result, f"点击元素 {index}")
+        
+        if download_msg:
+            # 关键:把日志里的信息塞进 output,这样 LLM 就能看到了!
+            tool_result.output = (tool_result.output or "") + download_msg
+            tool_result.long_term_memory = (tool_result.long_term_memory or "") + f" 捕获下载链接: {captured_url}"
+
+        return tool_result
+
+    except Exception as e:
+        return ToolResult(
+            title="点击失败",
+            output="",
+            error=f"Failed to click element {index}: {str(e)}",
+            long_term_memory=f"点击元素 {index} 失败"
+        )
+    finally:
+        # 5. 务必移除监听器,防止内存泄漏和日志污染
+        logger.removeHandler(capture_handler)
+
+
+@tool()
+async def browser_input_text(index: int, text: str, clear: bool = True) -> ToolResult:
+    """
+    在指定元素中输入文本
+    Input text into an element
+
+    Args:
+        index: 元素索引(从浏览器状态中获取)
+        text: 要输入的文本内容
+        clear: 是否先清除现有文本(默认 True)
+
+    Returns:
+        ToolResult: 包含输入操作结果的工具返回对象
+
+    Example:
+        input_text(index=0, text="Hello World", clear=True)
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        result = await tools.input(
+            index=index,
+            text=text,
+            clear=clear,
+            browser_session=browser
+        )
+
+        return action_result_to_tool_result(result, f"输入文本到元素 {index}")
+
+    except Exception as e:
+        return ToolResult(
+            title="输入失败",
+            output="",
+            error=f"Failed to input text into element {index}: {str(e)}",
+            long_term_memory=f"输入文本失败"
+        )
+
+
+@tool()
+async def browser_send_keys(keys: str) -> ToolResult:
+    """
+    发送键盘按键或快捷键
+    Send keyboard keys or shortcuts
+
+    支持发送单个按键、组合键和快捷键。
+
+    Args:
+        keys: 要发送的按键字符串
+              - 单个按键: "Enter", "Escape", "PageDown", "Tab"
+              - 组合键: "Control+o", "Shift+Tab", "Alt+F4"
+              - 功能键: "F1", "F2", ..., "F12"
+
+    Returns:
+        ToolResult: 包含按键操作结果的工具返回对象
+
+    Example:
+        send_keys("Enter")
+        send_keys("Control+A")
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        result = await tools.send_keys(
+            keys=keys,
+            browser_session=browser
+        )
+
+        return action_result_to_tool_result(result, f"发送按键: {keys}")
+
+    except Exception as e:
+        return ToolResult(
+            title="发送按键失败",
+            output="",
+            error=f"Failed to send keys: {str(e)}",
+            long_term_memory="发送按键失败"
+        )
+
+
+@tool()
+async def browser_upload_file(index: int, path: str) -> ToolResult:
+    """
+    上传文件到文件输入元素
+    Upload a file to a file input element
+
+    Args:
+        index: 文件输入框的元素索引
+        path: 要上传的文件路径(绝对路径)
+
+    Returns:
+        ToolResult: 包含上传操作结果的工具返回对象
+
+    Example:
+        upload_file(index=7, path="/path/to/file.pdf")
+
+    Note:
+        文件必须存在且路径必须是绝对路径
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        result = await tools.upload_file(
+            index=index,
+            path=path,
+            browser_session=browser,
+            available_file_paths=[path],
+            file_system=_file_system
+        )
+
+        return action_result_to_tool_result(result, f"上传文件: {path}")
+
+    except Exception as e:
+        return ToolResult(
+            title="上传失败",
+            output="",
+            error=f"Failed to upload file: {str(e)}",
+            long_term_memory=f"上传文件 {path} 失败"
+        )
+
+# ============================================================
+# 滚动和视图工具 (Scroll & View Tools)
+# ============================================================
+@tool()
+async def browser_scroll_page(down: bool = True, pages: float = 1.0, index: Optional[int] = None) -> ToolResult:
+    try:
+        # 限制单次滚动幅度,避免 agent 一次滚 100 页
+        MAX_PAGES = 10
+        if pages > MAX_PAGES:
+            pages = MAX_PAGES
+
+        browser, tools = await get_browser_session()
+        cdp_session = await browser.get_or_create_cdp_session()
+
+        before_y_result = await cdp_session.cdp_client.send.Runtime.evaluate(
+            params={'expression': 'window.scrollY'},
+            session_id=cdp_session.session_id
+        )
+        before_y = before_y_result.get('result', {}).get('value', 0)
+
+        # 执行滚动
+        result = await tools.scroll(down=down, pages=pages, index=index, browser_session=browser)
+
+        # 等待渲染(懒加载页面需要更长时间)
+        await asyncio.sleep(2)
+
+        after_y_result = await cdp_session.cdp_client.send.Runtime.evaluate(
+            params={'expression': 'window.scrollY'},
+            session_id=cdp_session.session_id
+        )
+        after_y = after_y_result.get('result', {}).get('value', 0)
+
+        # 如果第一次检测没动,再等一轮(应对懒加载触发后的延迟滚动)
+        if before_y == after_y and index is None:
+            await asyncio.sleep(2)
+            retry_result = await cdp_session.cdp_client.send.Runtime.evaluate(
+                params={'expression': 'window.scrollY'},
+                session_id=cdp_session.session_id
+            )
+            after_y = retry_result.get('result', {}).get('value', 0)
+
+        if before_y == after_y and index is None:
+            direction = "下" if down else "上"
+            return ToolResult(
+                title="滚动无效",
+                output=f"页面已到达{direction}边界,无法继续滚动",
+                error="No movement detected"
+            )
+
+        delta = abs(after_y - before_y)
+        direction = "下" if down else "上"
+        return action_result_to_tool_result(result, f"已向{direction}滚动 {delta}px")
+
+    except Exception as e:
+        # --- 核心修复 2: 必须补全 output 参数,否则框架会报错 ---
+        return ToolResult(
+            title="滚动失败", 
+            output="",  # 补全这个缺失的必填参数
+            error=str(e)
+        )
+
+
+
+@tool()
+async def browser_find_text(text: str) -> ToolResult:
+    """
+    查找页面中的文本并滚动到该位置
+    Find text on the page and scroll to it
+
+    在页面中搜索指定的文本,找到后自动滚动到该位置。
+
+    Args:
+        text: 要查找的文本内容
+
+    Returns:
+        ToolResult: 包含查找结果的工具返回对象
+
+    Example:
+        find_text("Privacy Policy")
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        result = await tools.find_text(
+            text=text,
+            browser_session=browser
+        )
+
+        return action_result_to_tool_result(result, f"查找文本: {text}")
+
+    except Exception as e:
+        return ToolResult(
+            title="查找失败",
+            output="",
+            error=f"Failed to find text: {str(e)}",
+            long_term_memory=f"查找文本 '{text}' 失败"
+        )
+
+@tool()
+async def browser_get_visual_selector_map() -> ToolResult:
+    """
+    获取当前页面的视觉快照和交互元素索引映射。
+    Get visual snapshot and selector map of interactive elements.
+
+    该工具会同时执行两个操作:
+    1. 捕捉当前页面的截图,并用 browser-use 内置方法在截图上标注元素索引号。
+    2. 生成页面所有可交互元素的索引字典(含 href、type 等属性信息)。
+
+    Returns:
+        ToolResult: 包含高亮截图(在 images 中)和元素列表的工具返回对象。
+    """
+    try:
+        browser, _ = await get_browser_session()
+
+        # 1. 构造同时包含 DOM 和 截图 的请求
+        from browser_use.browser.events import BrowserStateRequestEvent
+        from browser_use.browser.python_highlights import create_highlighted_screenshot_async
+
+        event = browser.event_bus.dispatch(
+            BrowserStateRequestEvent(
+                include_dom=True,
+                include_screenshot=True,
+                include_recent_events=False
+            )
+        )
+
+        # 2. 等待浏览器返回完整状态
+        browser_state = await event.event_result(raise_if_none=True, raise_if_any=True)
+
+        # 3. 提取 Selector Map
+        selector_map = browser_state.dom_state.selector_map if browser_state.dom_state else {}
+
+        # 4. 提取截图并生成带索引标注的高亮截图(通过 CDP 获取精确 DPI 和滚动偏移)
+        screenshot_b64 = browser_state.screenshot or ""
+        highlighted_b64 = ""
+        if screenshot_b64 and selector_map:
+            try:
+                cdp_session = await browser.get_or_create_cdp_session()
+                highlighted_b64 = await create_highlighted_screenshot_async(
+                    screenshot_b64, selector_map,
+                    cdp_session=cdp_session,
+                    filter_highlight_ids=False
+                )
+            except Exception:
+                highlighted_b64 = screenshot_b64  # fallback to raw screenshot
+        else:
+            highlighted_b64 = screenshot_b64
+
+        # 5. 构建供 Agent 阅读的完整元素列表,包含丰富的属性信息
+        elements_info = []
+        for index, node in selector_map.items():
+            tag = node.tag_name
+            attrs = node.attributes or {}
+            desc = attrs.get('aria-label') or attrs.get('placeholder') or attrs.get('title') or node.get_all_children_text(max_depth=1) or ""
+            # 收集有用的属性片段
+            extra_parts = []
+            if attrs.get('href'):
+                extra_parts.append(f"href={attrs['href'][:60]}")
+            if attrs.get('type'):
+                extra_parts.append(f"type={attrs['type']}")
+            if attrs.get('role'):
+                extra_parts.append(f"role={attrs['role']}")
+            if attrs.get('name'):
+                extra_parts.append(f"name={attrs['name']}")
+            extra = f" ({', '.join(extra_parts)})" if extra_parts else ""
+            elements_info.append(f"Index {index}: <{tag}> \"{desc[:50]}\"{extra}")
+
+        output = f"页面截图已捕获(含元素索引标注)\n找到 {len(selector_map)} 个交互元素\n\n"
+        output += "元素列表:\n" + "\n".join(elements_info)
+
+        # 6. 将高亮截图存入 images 字段,metadata 保留结构化数据
+        images = []
+        if highlighted_b64:
+            images.append({"type": "base64", "media_type": "image/png", "data": highlighted_b64})
+
+        return ToolResult(
+            title="视觉元素观察",
+            output=output,
+            long_term_memory=f"在页面观察到 {len(selector_map)} 个元素并保存了截图",
+            images=images,
+            metadata={
+                "selector_map": {k: str(v) for k, v in list(selector_map.items())[:100]},
+                "url": browser_state.url,
+                "title": browser_state.title
+            }
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="视觉观察失败",
+            output="",
+            error=f"Failed to get visual selector map: {str(e)}",
+            long_term_memory="获取视觉元素映射失败"
+        )
+    
+@tool()
+async def browser_screenshot() -> ToolResult:
+    """
+    请求在下次观察中包含页面截图
+    Request a screenshot to be included in the next observation
+
+    用于视觉检查页面状态,帮助理解页面布局和内容。
+
+    Returns:
+        ToolResult: 包含截图请求结果的工具返回对象
+
+    Example:
+        screenshot()
+
+    Note:
+        截图会在下次页面观察时自动包含在结果中。
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        result = await tools.screenshot(browser_session=browser)
+
+        return action_result_to_tool_result(result, "截图请求")
+
+    except Exception as e:
+        return ToolResult(
+            title="截图失败",
+            output="",
+            error=f"Failed to capture screenshot: {str(e)}",
+            long_term_memory="截图失败"
+        )
+
+
+# ============================================================
+# 标签页管理工具 (Tab Management Tools)
+# ============================================================
+
+@tool()
+async def browser_switch_tab(tab_id: str) -> ToolResult:
+    """
+    切换到指定标签页
+    Switch to a different browser tab
+
+    Args:
+        tab_id: 4字符标签ID(target_id 的最后4位)
+
+    Returns:
+        ToolResult: 切换结果
+
+    Example:
+        switch_tab(tab_id="a3f2")
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        normalized_tab_id = tab_id[-4:] if tab_id else tab_id
+        result = await tools.switch(
+            tab_id=normalized_tab_id,
+            browser_session=browser
+        )
+
+        return action_result_to_tool_result(result, f"切换到标签页 {normalized_tab_id}")
+
+    except Exception as e:
+        return ToolResult(
+            title="切换标签页失败",
+            output="",
+            error=f"Failed to switch tab: {str(e)}",
+            long_term_memory=f"切换到标签页 {tab_id} 失败"
+        )
+
+
+@tool()
+async def browser_close_tab(tab_id: str) -> ToolResult:
+    """
+    关闭指定标签页
+    Close a browser tab
+
+    Args:
+        tab_id: 4字符标签ID
+
+    Returns:
+        ToolResult: 关闭结果
+
+    Example:
+        close_tab(tab_id="a3f2")
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        normalized_tab_id = tab_id[-4:] if tab_id else tab_id
+        result = await tools.close(
+            tab_id=normalized_tab_id,
+            browser_session=browser
+        )
+
+        return action_result_to_tool_result(result, f"关闭标签页 {normalized_tab_id}")
+
+    except Exception as e:
+        return ToolResult(
+            title="关闭标签页失败",
+            output="",
+            error=f"Failed to close tab: {str(e)}",
+            long_term_memory=f"关闭标签页 {tab_id} 失败"
+        )
+
+
+# ============================================================
+# 下拉框工具 (Dropdown Tools)
+# ============================================================
+
+@tool()
+async def browser_get_dropdown_options(index: int) -> ToolResult:
+    """
+    获取下拉框的所有选项
+    Get options from a dropdown element
+
+    Args:
+        index: 下拉框的元素索引
+
+    Returns:
+        ToolResult: 包含所有选项的结果
+
+    Example:
+        get_dropdown_options(index=8)
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        result = await tools.dropdown_options(
+            index=index,
+            browser_session=browser
+        )
+
+        return action_result_to_tool_result(result, f"获取下拉框选项: {index}")
+
+    except Exception as e:
+        return ToolResult(
+            title="获取下拉框选项失败",
+            output="",
+            error=f"Failed to get dropdown options: {str(e)}",
+            long_term_memory=f"获取下拉框 {index} 选项失败"
+        )
+
+
+@tool()
+async def browser_select_dropdown_option(index: int, text: str) -> ToolResult:
+    """
+    选择下拉框选项
+    Select an option from a dropdown
+
+    Args:
+        index: 下拉框的元素索引
+        text: 要选择的选项文本(精确匹配)
+
+    Returns:
+        ToolResult: 选择结果
+
+    Example:
+        select_dropdown_option(index=8, text="Option 2")
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        result = await tools.select_dropdown(
+            index=index,
+            text=text,
+            browser_session=browser
+        )
+
+        return action_result_to_tool_result(result, f"选择下拉框选项: {text}")
+
+    except Exception as e:
+        return ToolResult(
+            title="选择下拉框选项失败",
+            output="",
+            error=f"Failed to select dropdown option: {str(e)}",
+            long_term_memory=f"选择选项 '{text}' 失败"
+        )
+
+
+# ============================================================
+# 内容提取工具 (Content Extraction Tools)
+# ============================================================
+def scrub_search_redirect_url(url: str) -> str:
+    """
+    自动检测并解析 Bing/Google 等搜索引擎的重定向链接,提取真实目标 URL。
+    """
+    if not url or not isinstance(url, str):
+        return url
+    
+    try:
+        parsed = urlparse(url)
+        
+        # 1. 处理 Bing 重定向 (特征:u 参数带 Base64)
+        # 示例:...&u=a1aHR0cHM6Ly96aHVhbmxhbi56aGlodS5jb20vcC8zODYxMjgwOQ&...
+        if "bing.com" in parsed.netloc:
+            u_param = parse_qs(parsed.query).get('u', [None])[0]
+            if u_param:
+                # 移除开头的 'a1', 'a0' 等标识符
+                b64_str = u_param[2:]
+                # 补齐 Base64 填充符
+                padding = '=' * (4 - len(b64_str) % 4)
+                decoded = base64.b64decode(b64_str + padding).decode('utf-8', errors='ignore')
+                if decoded.startswith('http'):
+                    return decoded
+
+        # 2. 处理 Google 重定向 (特征:url 参数)
+        if "google.com" in parsed.netloc:
+            url_param = parse_qs(parsed.query).get('url', [None])[0]
+            if url_param:
+                return unquote(url_param)
+
+        # 3. 兜底:处理常见的跳转参数
+        for param in ['target', 'dest', 'destination', 'link']:
+            found = parse_qs(parsed.query).get(param, [None])[0]
+            if found and found.startswith('http'):
+                return unquote(found)
+                
+    except Exception:
+        pass # 解析失败则返回原链接
+    
+    return url
+
+async def extraction_adapter(input_data):
+    # 提取字符串
+    if isinstance(input_data, list):
+        prompt = input_data[-1].content if hasattr(input_data[-1], 'content') else str(input_data[-1])
+    else:
+        prompt = str(input_data)
+    
+    response = await openrouter_llm_call(
+        messages=[{"role": "user", "content": prompt}]
+    )
+    
+    content = response["content"]
+    
+    # --- 核心改进:URL 自动修复 ---
+    # 使用正则表达式匹配内容中的所有 URL,并尝试进行洗涤
+    urls = re.findall(r'https?://[^\s<>"\']+', content)
+    for original_url in urls:
+        clean_url = scrub_search_redirect_url(original_url)
+        if clean_url != original_url:
+            content = content.replace(original_url, clean_url)
+    
+    from argparse import Namespace
+    return Namespace(completion=content)
+
+@tool()
+async def browser_extract_content(query: str, extract_links: bool = False,
+                         start_from_char: int = 0) -> ToolResult:
+    """
+    使用 LLM 从页面提取结构化数据
+    Extract content from the current page using LLM
+
+    Args:
+        query: 提取查询(告诉 LLM 要提取什么内容)
+        extract_links: 是否提取链接(默认 False,节省 token)
+        start_from_char: 从哪个字符开始提取(用于分页提取大内容)
+
+    Returns:
+        ToolResult: 提取的内容
+
+    Example:
+        extract_content(query="提取页面上所有产品的名称和价格", extract_links=True)
+
+    Note:
+        需要配置 page_extraction_llm,否则会失败
+        支持分页提取,最大100k字符
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        # 注意:extract 需要 page_extraction_llm 参数
+        # 这里我们假设用户会在初始化时配置 LLM
+        # 如果没有配置,会抛出异常
+        result = await tools.extract(
+            query=query,
+            extract_links=extract_links,
+            start_from_char=start_from_char,
+            browser_session=browser,
+            page_extraction_llm=RunnableLambda(extraction_adapter),  # 需要用户配置
+            file_system=_file_system
+        )
+
+        return action_result_to_tool_result(result, f"提取内容: {query}")
+
+    except Exception as e:
+        return ToolResult(
+            title="内容提取失败",
+            output="",
+            error=f"Failed to extract content: {str(e)}",
+            long_term_memory=f"提取内容失败: {query}"
+        )
+
+async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
+    """
+    检测当前页面是否为 PDF,如果是则通过 CDP(浏览器内 fetch)下载到本地。
+    优势:自动携带浏览器的 cookies/session,可访问需要登录的 PDF。
+    返回本地文件路径,非 PDF 页面返回 None。
+    """
+    try:
+        current_url = await browser.get_current_page_url()
+        if not current_url:
+            return None
+
+        parsed = urlparse(current_url)
+        is_pdf = parsed.path.lower().endswith('.pdf')
+
+        # URL 不明显是 PDF 时,通过 CDP 检查 content-type
+        if not is_pdf:
+            try:
+                cdp = await browser.get_or_create_cdp_session()
+                ct_result = await cdp.cdp_client.send.Runtime.evaluate(
+                    params={'expression': 'document.contentType'},
+                    session_id=cdp.session_id
+                )
+                content_type = ct_result.get('result', {}).get('value', '')
+                is_pdf = 'pdf' in content_type.lower()
+            except Exception:
+                pass
+
+        if not is_pdf:
+            return None
+
+        # 通过浏览器内 fetch API 下载 PDF(自动携带 cookies)
+        cdp = await browser.get_or_create_cdp_session()
+        js_code = """
+        (async () => {
+            try {
+                const resp = await fetch(window.location.href);
+                if (!resp.ok) return JSON.stringify({error: 'HTTP ' + resp.status});
+                const blob = await resp.blob();
+                return new Promise((resolve, reject) => {
+                    const reader = new FileReader();
+                    reader.onloadend = () => resolve(JSON.stringify({data: reader.result}));
+                    reader.onerror = () => resolve(JSON.stringify({error: 'FileReader failed'}));
+                    reader.readAsDataURL(blob);
+                });
+            } catch(e) {
+                return JSON.stringify({error: e.message});
+            }
+        })()
+        """
+        result = await cdp.cdp_client.send.Runtime.evaluate(
+            params={
+                'expression': js_code,
+                'awaitPromise': True,
+                'returnByValue': True,
+                'timeout': 60000
+            },
+            session_id=cdp.session_id
+        )
+
+        value = result.get('result', {}).get('value', '')
+        if not value:
+            print("⚠️ CDP fetch PDF: 无返回值")
+            return None
+
+        data = json.loads(value)
+        if 'error' in data:
+            print(f"⚠️ CDP fetch PDF 失败: {data['error']}")
+            return None
+
+        # 从 data URL 中提取 base64 并解码
+        data_url = data['data']  # data:application/pdf;base64,JVBERi0...
+        base64_data = data_url.split(',', 1)[1]
+        pdf_bytes = base64.b64decode(base64_data)
+
+        # 保存到本地
+        save_dir = Path.cwd() / ".cache/.browser_use_files"
+        save_dir.mkdir(parents=True, exist_ok=True)
+
+        filename = Path(parsed.path).name if parsed.path else ""
+        if not filename or not filename.lower().endswith('.pdf'):
+            import time
+            filename = f"downloaded_{int(time.time())}.pdf"
+        save_path = str(save_dir / filename)
+
+        with open(save_path, 'wb') as f:
+            f.write(pdf_bytes)
+
+        print(f"📄 PDF 已通过 CDP 下载到: {save_path} ({len(pdf_bytes)} bytes)")
+        return save_path
+
+    except Exception as e:
+        print(f"⚠️ PDF 检测/下载异常: {e}")
+        return None
+
+
+@tool()
+async def browser_read_long_content(
+    goal: Union[str, dict],
+    source: str = "page",
+    context: str = "",
+    **kwargs
+) -> ToolResult:
+    """
+    智能读取长内容。支持自动检测并读取网页上的 PDF 文件。
+
+    当 source="page" 且当前页面是 PDF 时,会通过 CDP 下载 PDF 并用 pypdf 解析,
+    而非使用 DOM 提取(DOM 无法读取浏览器内置 PDF Viewer 的内容)。
+    通过 CDP 下载可自动携带浏览器的 cookies/session,支持需要登录的 PDF。
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        # 1. 提取目标文本 (针对 GoalTree 字典结构)
+        final_goal_text = ""
+        if isinstance(goal, dict):
+            final_goal_text = goal.get("mission") or goal.get("goal") or str(goal)
+        else:
+            final_goal_text = str(goal)
+
+        # 2. 清洗业务背景 (过滤框架注入的 dict 类型 context)
+        business_context = context if isinstance(context, str) else ""
+
+        # 3. PDF 自动检测:当 source="page" 时检查是否为 PDF 页面
+        available_files = []
+        if source.lower() == "page":
+            pdf_path = await _detect_and_download_pdf_via_cdp(browser)
+            if pdf_path:
+                source = pdf_path
+                available_files.append(pdf_path)
+
+        # 4. 验证并实例化
+        action_params = ReadContentAction(
+            goal=final_goal_text,
+            source=source,
+            context=business_context
+        )
+
+        # 5. 解包参数调用底层方法
+        result = await tools.read_long_content(
+            **action_params.model_dump(),
+            browser_session=browser,
+            page_extraction_llm=RunnableLambda(extraction_adapter),
+            available_file_paths=available_files
+        )
+
+        return action_result_to_tool_result(result, f"深度读取: {source}")
+
+    except Exception as e:
+        return ToolResult(
+            title="深度读取失败",
+            output="",
+            error=f"Read long content failed: {str(e)}",
+            long_term_memory="参数解析或校验失败,请检查输入"
+        )
+@tool()
+async def browser_get_page_html() -> ToolResult:
+    """
+    获取当前页面的完整 HTML
+    Get the full HTML of the current page
+
+    返回当前页面的完整 HTML 源代码。
+
+    Returns:
+        ToolResult: 包含页面 HTML 的工具返回对象
+
+    Example:
+        get_page_html()
+
+    Note:
+        - 返回的是完整的 HTML 源代码
+        - 输出会被限制在 10000 字符以内(完整内容保存在 metadata 中)
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        # 使用 CDP 获取页面 HTML
+        cdp = await browser.get_or_create_cdp_session()
+
+        # 获取页面内容
+        result = await cdp.cdp_client.send.Runtime.evaluate(
+            params={'expression': 'document.documentElement.outerHTML'},
+            session_id=cdp.session_id
+        )
+
+        html = result.get('result', {}).get('value', '')
+
+        # 获取 URL 和标题
+        url = await browser.get_current_page_url()
+
+        title_result = await cdp.cdp_client.send.Runtime.evaluate(
+            params={'expression': 'document.title'},
+            session_id=cdp.session_id
+        )
+        title = title_result.get('result', {}).get('value', '')
+
+        # 限制输出大小
+        output_html = html
+        if len(html) > 10000:
+            output_html = html[:10000] + "... (truncated)"
+
+        return ToolResult(
+            title=f"获取 HTML: {url}",
+            output=f"页面: {title}\nURL: {url}\n\nHTML:\n{output_html}",
+            long_term_memory=f"获取 HTML: {url}",
+            metadata={"url": url, "title": title, "html": html}
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="获取 HTML 失败",
+            output="",
+            error=f"Failed to get page HTML: {str(e)}",
+            long_term_memory="获取 HTML 失败"
+        )
+
+
+@tool()
+async def browser_get_selector_map() -> ToolResult:
+    """
+    获取当前页面的元素索引映射
+    Get the selector map of interactive elements on the current page
+
+    返回页面所有可交互元素的索引字典,用于后续的元素操作。
+
+    Returns:
+        ToolResult: 包含元素映射的工具返回对象
+
+    Example:
+        get_selector_map()
+
+    Note:
+        返回的索引可以用于 click_element, input_text 等操作
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        # 关键修复:先触发 BrowserStateRequestEvent 来更新 DOM 状态
+        # 这会触发 DOM watchdog 重新构建 DOM 树并更新 selector_map
+        from browser_use.browser.events import BrowserStateRequestEvent
+
+        # 触发事件并等待结果
+        event = browser.event_bus.dispatch(
+            BrowserStateRequestEvent(
+                include_dom=True,
+                include_screenshot=False,  # 不需要截图,节省时间
+                include_recent_events=False
+            )
+        )
+
+        # 等待 DOM 更新完成
+        browser_state = await event.event_result(raise_if_none=True, raise_if_any=True)
+
+        # 从更新后的状态中获取 selector_map
+        selector_map = browser_state.dom_state.selector_map if browser_state.dom_state else {}
+
+        # 构建输出信息
+        elements_info = []
+        for index, node in list(selector_map.items())[:20]:  # 只显示前20个
+            tag = node.tag_name
+            attrs = node.attributes or {}
+            text = attrs.get('aria-label') or attrs.get('placeholder') or attrs.get('value', '')
+            elements_info.append(f"索引 {index}: <{tag}> {text[:50]}")
+
+        output = f"找到 {len(selector_map)} 个交互元素\n\n"
+        output += "\n".join(elements_info)
+        if len(selector_map) > 20:
+            output += f"\n... 还有 {len(selector_map) - 20} 个元素"
+
+        return ToolResult(
+            title="获取元素映射",
+            output=output,
+            long_term_memory=f"获取到 {len(selector_map)} 个交互元素",
+            metadata={"selector_map": {k: str(v) for k, v in list(selector_map.items())[:100]}}
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="获取元素映射失败",
+            output="",
+            error=f"Failed to get selector map: {str(e)}",
+            long_term_memory="获取元素映射失败"
+        )
+
+
+# ============================================================
+# JavaScript 执行工具 (JavaScript Tools)
+# ============================================================
+
+@tool()
+async def browser_evaluate(code: str) -> ToolResult:
+    """
+    在页面中执行 JavaScript 代码
+    Execute JavaScript code in the page context
+
+    允许在当前页面中执行任意 JavaScript 代码,用于复杂的页面操作或数据提取。
+
+    Args:
+        code: 要执行的 JavaScript 代码字符串
+
+    Returns:
+        ToolResult: 包含执行结果的工具返回对象
+
+    Example:
+        evaluate("document.title")
+        evaluate("document.querySelectorAll('a').length")
+
+    Note:
+        - 代码在页面上下文中执行,可以访问 DOM 和全局变量
+        - 返回值会被自动序列化为字符串
+        - 执行结果限制在 20k 字符以内
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        result = await tools.evaluate(
+            code=code,
+            browser_session=browser
+        )
+
+        return action_result_to_tool_result(result, "执行 JavaScript")
+
+    except Exception as e:
+        return ToolResult(
+            title="JavaScript 执行失败",
+            output="",
+            error=f"Failed to execute JavaScript: {str(e)}",
+            long_term_memory="JavaScript 执行失败"
+        )
+
+
+@tool()
+async def browser_ensure_login_with_cookies(cookie_type: str, url: str = "https://www.xiaohongshu.com") -> ToolResult:
+    """
+    检查登录状态并在需要时注入 cookies
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        if url:
+            await tools.navigate(url=url, browser_session=browser)
+            await tools.wait(seconds=2, browser_session=browser)
+
+        check_login_js = """
+        (function() {
+            const loginBtn = document.querySelector('[class*="login"]') ||
+                           document.querySelector('[href*="login"]') ||
+                           Array.from(document.querySelectorAll('button, a')).find(el => (el.textContent || '').includes('登录'));
+
+            const userInfo = document.querySelector('[class*="user"]') ||
+                           document.querySelector('[class*="avatar"]');
+
+            return {
+                needLogin: !!loginBtn && !userInfo,
+                hasLoginBtn: !!loginBtn,
+                hasUserInfo: !!userInfo
+            };
+        })()
+        """
+
+        result = await tools.evaluate(code=check_login_js, browser_session=browser)
+        status_output = result.extracted_content
+        if isinstance(status_output, str) and status_output.startswith("Result: "):
+            status_output = status_output[8:]
+        login_info: Dict[str, Any] = {}
+        if isinstance(status_output, str):
+            try:
+                login_info = json.loads(status_output)
+            except Exception:
+                login_info = {}
+        elif isinstance(status_output, dict):
+            login_info = status_output
+
+        if not login_info.get("needLogin"):
+            output = json.dumps({"need_login": False}, ensure_ascii=False)
+            return ToolResult(
+                title="已登录",
+                output=output,
+                long_term_memory=output
+            )
+
+        row = _fetch_cookie_row(cookie_type)
+        cookie_value = _extract_cookie_value(row)
+        if not cookie_value:
+            output = json.dumps({"need_login": True, "cookies_count": 0}, ensure_ascii=False)
+            return ToolResult(
+                title="未找到 cookies",
+                output=output,
+                error="未找到 cookies",
+                long_term_memory=output
+            )
+
+        domain, base_url = _cookie_domain_for_type(cookie_type, url)
+        cookies = _normalize_cookies(cookie_value, domain, base_url)
+        if not cookies:
+            output = json.dumps({"need_login": True, "cookies_count": 0}, ensure_ascii=False)
+            return ToolResult(
+                title="cookies 解析失败",
+                output=output,
+                error="cookies 解析失败",
+                long_term_memory=output
+            )
+
+        await browser._cdp_set_cookies(cookies)
+        if url:
+            await tools.navigate(url=url, browser_session=browser)
+            await tools.wait(seconds=2, browser_session=browser)
+
+        output = json.dumps({"need_login": True, "cookies_count": len(cookies)}, ensure_ascii=False)
+        return ToolResult(
+            title="已注入 cookies",
+            output=output,
+            long_term_memory=output
+        )
+    except Exception as e:
+        return ToolResult(
+            title="登录检查失败",
+            output="",
+            error=str(e),
+            long_term_memory="登录检查失败"
+        )
+
+
+# ============================================================
+# 等待用户操作工具 (Wait for User Action)
+# ============================================================
+
+@tool()
+async def browser_wait_for_user_action(message: str = "Please complete the action in browser",
+                               timeout: int = 300) -> ToolResult:
+    """
+    等待用户在浏览器中完成操作(如登录)
+    Wait for user to complete an action in the browser (e.g., login)
+
+    暂停自动化流程,等待用户手动完成某些操作(如登录、验证码等)。
+
+    Args:
+        message: 提示用户需要完成的操作
+        timeout: 最大等待时间(秒),默认 300 秒(5 分钟)
+
+    Returns:
+        ToolResult: 包含等待结果的工具返回对象
+
+    Example:
+        wait_for_user_action("Please login to Xiaohongshu", timeout=180)
+        wait_for_user_action("Please complete the CAPTCHA", timeout=60)
+
+    Note:
+        - 用户需要在浏览器窗口中手动完成操作
+        - 完成后按回车键继续
+        - 超时后会自动继续执行
+    """
+    try:
+        import asyncio
+
+        print(f"\n{'='*60}")
+        print(f"⏸️  WAITING FOR USER ACTION")
+        print(f"{'='*60}")
+        print(f"📝 {message}")
+        print(f"⏱️  Timeout: {timeout} seconds")
+        print(f"\n👉 Please complete the action in the browser window")
+        print(f"👉 Press ENTER when done, or wait for timeout")
+        print(f"{'='*60}\n")
+
+        # Wait for user input or timeout
+        try:
+            loop = asyncio.get_event_loop()
+
+            # Wait for either user input or timeout
+            await asyncio.wait_for(
+                loop.run_in_executor(None, input),
+                timeout=timeout
+            )
+
+            return ToolResult(
+                title="用户操作完成",
+                output=f"User completed: {message}",
+                long_term_memory=f"用户完成操作: {message}"
+            )
+        except asyncio.TimeoutError:
+            return ToolResult(
+                title="用户操作超时",
+                output=f"Timeout waiting for: {message}",
+                long_term_memory=f"等待用户操作超时: {message}"
+            )
+
+    except Exception as e:
+        return ToolResult(
+            title="等待用户操作失败",
+            output="",
+            error=f"Failed to wait for user action: {str(e)}",
+            long_term_memory="等待用户操作失败"
+        )
+
+
+# ============================================================
+# 任务完成工具 (Task Completion)
+# ============================================================
+
+@tool()
+async def browser_done(text: str, success: bool = True,
+              files_to_display: Optional[List[str]] = None) -> ToolResult:
+    """
+    标记任务完成并返回最终消息
+    Mark the task as complete and return final message to user
+
+    Args:
+        text: 给用户的最终消息
+        success: 任务是否成功完成
+        files_to_display: 可选的要显示的文件路径列表
+
+    Returns:
+        ToolResult: 完成结果
+
+    Example:
+        done("任务已完成,提取了10个产品信息", success=True)
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        result = await tools.done(
+            text=text,
+            success=success,
+            files_to_display=files_to_display,
+            file_system=_file_system
+        )
+
+        return action_result_to_tool_result(result, "任务完成")
+
+    except Exception as e:
+        return ToolResult(
+            title="标记任务完成失败",
+            output="",
+            error=f"Failed to complete task: {str(e)}",
+            long_term_memory="标记任务完成失败"
+        )
+
+
+# ============================================================
+# Cookie 持久化工具
+# ============================================================
+
+_COOKIES_DIR = Path(__file__).parent.parent.parent.parent.parent / ".cache/.cookies"
+
+@tool()
+async def browser_export_cookies(name: str = "", account: str = "") -> ToolResult:
+    """
+    导出当前浏览器的所有 Cookie 到本地 .cookies/ 目录。
+    文件命名格式:{域名}_{账号名}.json,如 bilibili.com_zhangsan.json
+    登录成功后调用此工具,下次可通过 browser_load_cookies 恢复登录态。
+
+    Args:
+        name: 自定义文件名(可选,提供则忽略自动命名)
+        account: 账号名称(可选,用于区分同一网站的不同账号)
+    """
+    try:
+        browser, _ = await get_browser_session()
+
+        # 获取所有 Cookie(CDP 格式)
+        all_cookies = await browser._cdp_get_cookies()
+        if not all_cookies:
+            return ToolResult(title="Cookie 导出", output="当前浏览器没有 Cookie", long_term_memory="无 Cookie 可导出")
+
+        # 获取当前域名,用于过滤和命名
+        from urllib.parse import urlparse
+        current_url = await browser.get_current_page_url() or ''
+        domain = urlparse(current_url).netloc.replace("www.", "") or "default"
+
+        if not name:
+            name = f"{domain}_{account}" if account else domain
+
+        # 只保留当前域名的 cookie(过滤第三方)
+        cookies = [c for c in all_cookies if domain in c.get("domain", "").lstrip(".")]
+
+        # 保存
+        _COOKIES_DIR.mkdir(parents=True, exist_ok=True)
+        cookie_file = _COOKIES_DIR / f"{name}.json"
+        cookie_file.write_text(json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8")
+
+        return ToolResult(
+            title="Cookie 已导出",
+            output=f"已保存 {len(cookies)} 条 Cookie 到 .cookies/{name}.json(从 {len(all_cookies)} 条中过滤当前域名)",
+            long_term_memory=f"导出 {len(cookies)} 条 Cookie 到 .cookies/{name}.json"
+        )
+    except Exception as e:
+        return ToolResult(title="Cookie 导出失败", output="", error=str(e), long_term_memory="导出 Cookie 失败")
+
+
+@tool()
+async def browser_load_cookies(url: str, name: str = "", auto_navigate: bool = True) -> ToolResult:
+    """
+    根据目标 URL 自动查找本地 Cookie 文件,注入浏览器并导航到目标页面恢复登录态。
+    如果找不到 Cookie 文件,会根据 auto_navigate 参数决定是否直接导航到目标页面。
+
+    重要:此工具会自动完成导航,调用前不需要先调用 browser_navigate_to_url。
+
+    Args:
+        url: 目标 URL(必须提供,同时用于自动匹配 Cookie 文件)
+        name: Cookie 文件名(可选,不传则根据 URL 域名自动查找)
+        auto_navigate: 找不到 Cookie 时是否自动导航到目标页面(默认 True)
+    """
+    try:
+        browser, tools = await get_browser_session()
+
+        if not url.startswith("http"):
+            url = f"https://{url}"
+
+        # 根据域名自动查找 Cookie 文件
+        if not name:
+            from urllib.parse import urlparse
+            domain = urlparse(url).netloc.replace("www.", "")
+            if _COOKIES_DIR.exists():
+                # 尝试多种匹配模式
+                matches = []
+
+                # 1. 精确匹配完整域名(如 xiaohongshu.com.json)
+                exact_match = _COOKIES_DIR / f"{domain}.json"
+                if exact_match.exists():
+                    matches.append(exact_match)
+                    logger.info(f"Cookie 精确匹配成功: {exact_match.name}")
+
+                # 2. 匹配域名前缀(如 xiaohongshu.com*.json)
+                if not matches:
+                    prefix_matches = list(_COOKIES_DIR.glob(f"{domain}*.json"))
+                    if prefix_matches:
+                        matches = prefix_matches
+                        logger.info(f"Cookie 前缀匹配成功: {[m.name for m in matches]}")
+
+                # 3. 模糊匹配:提取主域名(如 xiaohongshu)
+                if not matches:
+                    main_domain = domain.split('.')[0]  # 提取第一部分
+                    fuzzy_matches = list(_COOKIES_DIR.glob(f"{main_domain}*.json"))
+                    if fuzzy_matches:
+                        matches = fuzzy_matches
+                        logger.info(f"Cookie 模糊匹配成功: {[m.name for m in matches]} (主域名: {main_domain})")
+
+                if matches:
+                    cookie_file = matches[0]  # 取第一个匹配的
+                    logger.info(f"使用 Cookie 文件: {cookie_file.name}")
+                else:
+                    available = [f.stem for f in _COOKIES_DIR.glob("*.json")]
+                    logger.warning(f"未找到匹配的 Cookie 文件。域名: {domain}, 可用: {available}")
+                    hint = f"可用的 Cookie 文件: {available}" if available else "提示:首次使用需要先手动登录,然后使用 browser_export_cookies 保存 Cookie"
+
+                    # 如果启用自动导航,直接访问目标页面
+                    if auto_navigate:
+                        await tools.navigate(url=url, browser_session=browser)
+                        await tools.wait(seconds=2, browser_session=browser)
+                        return ToolResult(
+                            title="未找到 Cookie,已导航到目标页面",
+                            output=f"没有找到 {domain} 的 Cookie 文件,已自动导航到 {url}。\n\n{hint}\n\n建议:如需保持登录态,请手动登录后使用 browser_export_cookies 保存 Cookie。",
+                            error=None,
+                            long_term_memory=f"未找到 {domain} 的 Cookie,已导航到 {url}"
+                        )
+                    else:
+                        return ToolResult(
+                            title="未找到 Cookie",
+                            output=f"没有匹配 {domain} 的 Cookie 文件。{hint}\n\n建议:使用 browser_navigate_to_url 访问 {url} 并手动登录,或使用 browser_export_cookies 保存当前 Cookie。",
+                            error=None,
+                            long_term_memory=f"未找到 {domain} 的 Cookie 文件"
+                        )
+            else:
+                # Cookie 目录不存在
+                if auto_navigate:
+                    await tools.navigate(url=url, browser_session=browser)
+                    await tools.wait(seconds=2, browser_session=browser)
+                    return ToolResult(
+                        title="首次使用 Cookie 功能,已导航到目标页面",
+                        output=f"这是首次使用 Cookie 功能,已自动导航到 {url}。\n\n建议:手动完成登录后,使用 browser_export_cookies 保存 Cookie 供下次使用。",
+                        error=None,
+                        long_term_memory="首次使用 Cookie 功能,已导航到目标页面"
+                    )
+                else:
+                    return ToolResult(
+                        title="Cookie 目录不存在",
+                        output=f"这是首次使用 Cookie 功能。建议:\n1. 使用 browser_navigate_to_url 访问 {url}\n2. 手动完成登录\n3. 使用 browser_export_cookies 保存 Cookie 供下次使用",
+                        error=None,
+                        long_term_memory="Cookie 目录不存在,这是首次使用"
+                    )
+        else:
+            cookie_file = _COOKIES_DIR / f"{name}.json"
+            if not cookie_file.exists():
+                available = [f.stem for f in _COOKIES_DIR.glob("*.json")] if _COOKIES_DIR.exists() else []
+                hint = f"可用的 Cookie 文件: {available}" if available else "提示:使用 browser_export_cookies 保存 Cookie"
+
+                if auto_navigate:
+                    await tools.navigate(url=url, browser_session=browser)
+                    await tools.wait(seconds=2, browser_session=browser)
+                    return ToolResult(
+                        title="Cookie 文件不存在,已导航到目标页面",
+                        output=f"未找到 .cookies/{name}.json,已自动导航到 {url}。\n\n{hint}",
+                        error=None,
+                        long_term_memory=f"未找到 {name}.json,已导航到目标页面"
+                    )
+                else:
+                    return ToolResult(
+                        title="Cookie 文件不存在",
+                        output=f"未找到 .cookies/{name}.json。{hint}",
+                        error=None,
+                        long_term_memory=f"未找到 {name}.json Cookie 文件"
+                    )
+
+        cookies = json.loads(cookie_file.read_text(encoding="utf-8"))
+
+        # 直接注入(export 和 load 使用相同的 CDP 格式,无需标准化)
+        await browser._cdp_set_cookies(cookies)
+
+        # 导航到目标页面(带上刚注入的 Cookie)
+        if url:
+            if not url.startswith("http"):
+                url = f"https://{url}"
+            await tools.navigate(url=url, browser_session=browser)
+            await tools.wait(seconds=3, browser_session=browser)
+
+        return ToolResult(
+            title="Cookie 注入并导航完成",
+            output=f"从 {cookie_file.name} 注入 {len(cookies)} 条 Cookie,已导航到 {url}",
+            long_term_memory=f"已从 {cookie_file.name} 注入 Cookie 并导航到 {url},登录态已恢复"
+        )
+    except Exception as e:
+        return ToolResult(title="Cookie 加载失败", output="", error=str(e), long_term_memory="加载 Cookie 失败")
+
+
+# ============================================================
+# 导出所有工具函数(供外部使用)
+# ============================================================
+
+__all__ = [
+    # 会话管理
+    'init_browser_session',
+    'get_browser_session',
+    'cleanup_browser_session',
+    'kill_browser_session',
+
+    # 导航类工具
+    'browser_navigate_to_url',
+    'browser_search_web',
+    'browser_go_back',
+    'browser_wait',
+
+    # 元素交互工具
+    'browser_click_element',
+    'browser_input_text',
+    'browser_send_keys',
+    'browser_upload_file',
+
+    # 滚动和视图工具
+    'browser_scroll_page',
+    'browser_find_text',
+    'browser_screenshot',
+
+    # 标签页管理工具
+    'browser_switch_tab',
+    'browser_close_tab',
+
+    # 下拉框工具
+    'browser_get_dropdown_options',
+    'browser_select_dropdown_option',
+
+    # 内容提取工具
+    'browser_extract_content',
+    'browser_get_page_html',
+    'browser_read_long_content',
+    'browser_download_direct_url',
+    'browser_get_selector_map',
+    'browser_get_visual_selector_map',
+
+    # JavaScript 执行工具
+    'browser_evaluate',
+    'browser_ensure_login_with_cookies',
+
+    # 等待用户操作
+    'browser_wait_for_user_action',
+
+    # 任务完成
+    'browser_done',
+
+    # Cookie 持久化
+    'browser_export_cookies',
+    'browser_load_cookies',
+]

+ 86 - 0
agent/tools/builtin/browser/sync_mysql_help.py

@@ -0,0 +1,86 @@
+import pymysql
+
+
+from typing import Tuple, Any, Dict, Literal, Optional
+from dbutils.pooled_db import PooledDB, PooledDedicatedDBConnection
+from dbutils.steady_db import SteadyDBCursor
+from pymysql.cursors import DictCursor
+
+
+class SyncMySQLHelper(object):
+    _pool: PooledDB = None
+    _instance = None
+
+    def __new__(cls, *args, **kwargs):
+        """单例"""
+        if cls._instance is None:
+            cls._instance = super().__new__(cls, *args, **kwargs)
+        return cls._instance
+
+    def get_pool(self):
+        if self._pool is None:
+            self._pool = PooledDB(
+                creator=pymysql,
+                mincached=10,
+                maxconnections=20,
+                blocking=True,
+                host='rm-t4na9qj85v7790tf84o.mysql.singapore.rds.aliyuncs.com',
+                port=3306,
+                user='crawler_admin',
+                password='cyber#crawler_2023',
+                database='aigc-admin-prod')
+
+        return self._pool
+
+    def fetchone(self, sql: str, data: Optional[Tuple[Any, ...]] = None) -> Dict[str, Any]:
+        pool = self.get_pool()
+        with pool.connection() as conn:  
+            with conn.cursor(DictCursor) as cursor: 
+                cursor.execute(sql, data)
+                result = cursor.fetchone()
+                return result
+
+    def fetchall(self, sql: str, data: Optional[Tuple[Any, ...]] = None) -> Tuple[Dict[str, Any]]:
+        pool = self.get_pool()
+        with pool.connection() as conn: 
+            with conn.cursor(DictCursor) as cursor: 
+                cursor.execute(sql, data)
+                result = cursor.fetchall()
+                return result
+
+    def fetchmany(self,
+                  sql: str,
+                  data: Optional[Tuple[Any, ...]] = None,
+                  size: Optional[int] = None) -> Tuple[Dict[str, Any]]:
+        pool = self.get_pool()
+        with pool.connection() as conn:  
+            with conn.cursor(DictCursor) as cursor: 
+                cursor.execute(sql, data)
+                result = cursor.fetchmany(size=size)
+                return result
+
+    def execute(self, sql: str, data: Optional[Tuple[Any, ...]] = None):
+        pool = self.get_pool()
+        with pool.connection() as conn:  
+            with conn.cursor(DictCursor) as cursor:  
+                try:
+                    cursor.execute(sql, data)
+                    result = conn.commit()
+                    return result
+                except pymysql.err.IntegrityError as e:
+                    if e.args[0] == 1062:  # 重复值
+                        return None
+                    else:
+                        raise e
+                except pymysql.err.OperationalError as e:
+                    if e.args[0] == 1205:  # 死锁
+                        conn.rollback()
+                        return None
+                    else:
+                        raise e
+
+
+mysql = SyncMySQLHelper()
+
+
+

+ 90 - 0
agent/tools/builtin/feishu/FEISHU_TOOLS_PROMPT.md

@@ -0,0 +1,90 @@
+# 飞书通讯工具使用指南
+
+你可以通过飞书工具与预设的联系人进行沟通。在使用这些工具前,请仔细阅读以下指南。
+
+## 可用工具
+
+| 工具名称 | 功能 |
+|---------|------|
+| `feishu_get_contact_list` | 获取所有联系人的名称和描述 |
+| `feishu_send_message_to_contact` | 向指定联系人发送消息(支持文本和图片) |
+| `feishu_get_contact_replies` | 获取指定联系人的最新回复(支持等待) |
+| `feishu_get_chat_history` | 获取与指定联系人的完整历史聊天记录 |
+
+## 通讯决策流程
+
+### 1. 确定联系对象
+
+在发起任何通讯前,必须先调用 `feishu_get_contact_list` 获取联系人列表。每个联系人包含:
+- `name`: 联系人姓名
+- `description`: 联系人描述(职责、专长、适用场景等)
+
+根据当前任务需求,结合联系人的 `description` 字段判断应该联系谁。例如:
+- 技术问题 → 联系技术负责人
+- 审批事项 → 联系相关审批人
+- 日常协调 → 联系对应业务负责人
+
+### 2. 确定通讯模式
+
+根据任务性质选择合适的通讯模式:
+
+**单向通知模式**
+- 适用场景:状态汇报、任务完成通知、信息同步
+- 操作:仅调用 `feishu_send_message_to_contact` 发送消息,无需等待回复
+- 示例:「已完成数据备份,通知运维人员」
+
+**双向沟通模式**
+- 适用场景:需要确认、需要对方提供信息、需要决策审批
+- 操作流程:
+  1. 调用 `feishu_send_message_to_contact` 发送消息
+  2. 调用 `feishu_get_contact_replies` 获取回复(可设置 `wait_time_seconds` 等待)
+  3. 根据回复内容继续处理或再次沟通
+- 示例:「询问用户需求细节,等待对方回复后继续」
+
+**轮询等待模式**
+- 适用场景:紧急事项、需要即时响应的交互
+- 操作:使用 `feishu_get_contact_replies` 的 `wait_time_seconds` 参数
+- 注意:合理设置等待时间,避免无限等待
+
+## 聊天记录的使用
+
+系统会自动维护与每个联系人的聊天记录文件,存储在 `chat_history/` 目录下。
+
+### 何时查阅聊天记录
+
+- **上下文恢复**:当需要了解之前与某人的沟通内容时
+- **信息追溯**:查找之前讨论过的决策、约定或信息
+- **避免重复**:确认某个问题是否已经问过或已得到答复
+- **连续对话**:在多轮对话中保持上下文连贯性
+
+
+## 消息格式
+
+发送消息时支持以下格式:
+
+**纯文本**
+```
+"你好,请问项目进度如何?"
+```
+
+**多模态(文本+图片)**
+```json
+[
+  {"type": "text", "text": "请查看以下截图:"},
+  {"type": "image_url", "image_url": {"url": "https://xxx"}}
+]
+```
+
+## 最佳实践
+
+1. **先查后发**:发送消息前,考虑是否需要先查看历史记录了解上下文
+2. **明确意图**:消息内容应清晰表达目的,便于对方快速理解和响应
+3. **合理等待**:双向沟通时设置合理的等待时间,通常 30-120 秒
+4. **记录利用**:善用聊天记录避免重复询问,提升沟通效率
+5. **选对人**:根据联系人描述选择最合适的沟通对象
+
+## 注意事项
+
+- 联系人信息存储在配置文件中,首次与某人通讯后会自动建立会话
+- 未读消息计数会在你发送消息后自动重置
+- 图片消息会自动转换为 base64 格式存储在聊天记录中

+ 9 - 0
agent/tools/builtin/feishu/__init__.py

@@ -0,0 +1,9 @@
+from agent.tools.builtin.feishu.chat import (feishu_get_chat_history, feishu_get_contact_replies,
+                                         feishu_send_message_to_contact,feishu_get_contact_list)
+
+__all__ = [
+    "feishu_get_chat_history",
+    "feishu_get_contact_replies",
+    "feishu_send_message_to_contact",
+    "feishu_get_contact_list"
+]

+ 491 - 0
agent/tools/builtin/feishu/chat.py

@@ -0,0 +1,491 @@
+import json
+import os
+import base64
+import httpx
+import asyncio
+from typing import Optional, List, Dict, Any, Union
+from .feishu_client import FeishuClient, FeishuDomain
+from agent.tools import tool, ToolResult, ToolContext
+from agent.trace.models import MessageContent
+
+# 从环境变量获取飞书配置
+# 也可以在此设置硬编码的默认值,但推荐使用环境变量
+FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a90fe317987a9cc9")
+FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "nn2dWuXTiRA2N6xodbm4g0qz1AfM2ayi")
+
+CONTACTS_FILE = os.path.join(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")), "config", "feishu_contacts.json")
+CHAT_HISTORY_DIR = os.path.join(os.path.dirname(__file__), "chat_history")
+UNREAD_SUMMARY_FILE = os.path.join(CHAT_HISTORY_DIR, "chat_summary.json")
+
+# ==================== 一、文件内使用的功能函数 ====================
+
+def load_contacts() -> List[Dict[str, Any]]:
+    """读取 contacts.json 中的所有联系人"""
+    if not os.path.exists(CONTACTS_FILE):
+        return []
+    try:
+        with open(CONTACTS_FILE, 'r', encoding='utf-8') as f:
+            return json.load(f)
+    except Exception:
+        return []
+
+def save_contacts(contacts: List[Dict[str, Any]]):
+    """保存联系人信息到 contacts.json"""
+    try:
+        with open(CONTACTS_FILE, 'w', encoding='utf-8') as f:
+            json.dump(contacts, f, ensure_ascii=False, indent=2)
+    except Exception as e:
+        print(f"保存联系人失败: {e}")
+
+def list_contacts_info() -> List[Dict[str, str]]:
+    """
+    1. 列出所有联系人信息
+    读取 contacts.json 中的每一个联系人的 name、description,以字典列表返回
+    """
+    contacts = load_contacts()
+    return [{"name": c.get("name", ""), "description": c.get("description", "")} for c in contacts]
+
+def get_contact_full_info(name: str) -> Optional[Dict[str, Any]]:
+    """
+    2. 根据联系人名称获取联系人完整字典信息
+    从 contacts.json 中读取每一个联系人做名称匹配,返回数据中的所有字段为一个字典对象
+    """
+    contacts = load_contacts()
+    for c in contacts:
+        if c.get("name") == name:
+            return c
+    return None
+
+def get_contact_by_id(id_value: str) -> Optional[Dict[str, Any]]:
+    """根据 chat_id 或 open_id 获取联系人信息"""
+    contacts = load_contacts()
+    for c in contacts:
+        if c.get("chat_id") == id_value or c.get("open_id") == id_value:
+            return c
+    return None
+
+def update_contact_chat_id(name: str, chat_id: str):
+    """
+    3. 更新某一个联系人的 chat_id
+    """
+    contacts = load_contacts()
+    updated = False
+    for c in contacts:
+        if c.get("name") == name:
+            if not c.get("chat_id"):
+                c["chat_id"] = chat_id
+                updated = True
+            break
+    if updated:
+        save_contacts(contacts)
+
+# ==================== 二、聊天记录文件管理 ====================
+
+def _ensure_chat_history_dir():
+    if not os.path.exists(CHAT_HISTORY_DIR):
+        os.makedirs(CHAT_HISTORY_DIR)
+
+def get_chat_file_path(contact_name: str) -> str:
+    _ensure_chat_history_dir()
+    return os.path.join(CHAT_HISTORY_DIR, f"chat_{contact_name}.json")
+
+def load_chat_history(contact_name: str) -> List[Dict[str, Any]]:
+    path = get_chat_file_path(contact_name)
+    if os.path.exists(path):
+        try:
+            with open(path, 'r', encoding='utf-8') as f:
+                return json.load(f)
+        except Exception:
+            return []
+    return []
+
+def save_chat_history(contact_name: str, history: List[Dict[str, Any]]):
+    path = get_chat_file_path(contact_name)
+    try:
+        with open(path, 'w', encoding='utf-8') as f:
+            json.dump(history, f, ensure_ascii=False, indent=2)
+    except Exception as e:
+        print(f"保存聊天记录失败: {e}")
+
+def update_unread_count(contact_name: str, increment: int = 1, reset: bool = False):
+    """更新未读消息摘要"""
+    _ensure_chat_history_dir()
+    summary = {}
+    if os.path.exists(UNREAD_SUMMARY_FILE):
+        try:
+            with open(UNREAD_SUMMARY_FILE, 'r', encoding='utf-8') as f:
+                summary = json.load(f)
+        except Exception:
+            summary = {}
+    
+    if reset:
+        summary[contact_name] = 0
+    else:
+        summary[contact_name] = summary.get(contact_name, 0) + increment
+    
+    try:
+        with open(UNREAD_SUMMARY_FILE, 'w', encoding='utf-8') as f:
+            json.dump(summary, f, ensure_ascii=False, indent=2)
+    except Exception as e:
+        print(f"更新未读摘要失败: {e}")
+
+# ==================== 三、@tool 工具 ====================
+
+@tool(
+    hidden_params=["context"],
+    display={
+        "zh": {
+            "name": "获取飞书联系人列表",
+            "params": {}
+        },
+        "en": {
+            "name": "Get Feishu Contact List",
+            "params": {}
+        }
+    }
+)
+async def feishu_get_contact_list(context: Optional[ToolContext] = None) -> ToolResult:
+    """
+    获取所有联系人的名称和描述。
+
+    Args:
+        context: 工具执行上下文(可选)
+    """
+    contacts = list_contacts_info()
+    return ToolResult(
+        title="获取联系人列表成功",
+        output=json.dumps(contacts, ensure_ascii=False, indent=2),
+        metadata={"contacts": contacts}
+    )
+
+@tool(
+    hidden_params=["context"],
+    display={
+        "zh": {
+            "name": "给飞书联系人发送消息",
+            "params": {
+                "contact_name": "联系人名称",
+                "content": "消息内容。OpenAI 多模态格式列表 (例如: [{'type': 'text', 'text': '你好'}, {'type': 'image_url', 'image_url': {'url': '...'}}])"
+            }
+        },
+        "en": {
+            "name": "Send Message to Feishu Contact",
+            "params": {
+                "contact_name": "Contact Name",
+                "content": "Message content. OpenAI multimodal list format."
+            }
+        }
+    }
+)
+async def feishu_send_message_to_contact(
+    contact_name: str,
+    content: MessageContent,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    给指定的联系人发送消息。支持发送文本和图片,OpenAI 多模态格式,会自动转换为飞书相应的格式并发起多次发送。
+
+    Args:
+        contact_name: 飞书联系人的名称
+        content: 消息内容。OpenAI 多模态列表格式。
+    """
+    contact = get_contact_full_info(contact_name)
+    if not contact:
+        return ToolResult(title="发送失败", output=f"未找到联系人: {contact_name}", error="Contact not found")
+
+    client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET)
+    
+    # 确定接收者 ID (优先使用 chat_id,否则使用 open_id)
+    receive_id = contact.get("chat_id") or contact.get("open_id") or contact.get("user_id")
+    if not receive_id:
+        return ToolResult(title="发送失败", output="联系人 ID 信息缺失", error="Receiver ID not found in contacts.json")
+
+    # 如果 content 是字符串,尝试解析为 JSON
+    if isinstance(content, str):
+        try:
+            parsed = json.loads(content)
+            if isinstance(parsed, (list, dict)):
+                content = parsed
+        except (json.JSONDecodeError, TypeError):
+            pass
+
+    try:
+        last_res = None
+        if isinstance(content, str):
+            last_res = client.send_message(to=receive_id, text=content)
+        elif isinstance(content, list):
+            for item in content:
+                item_type = item.get("type")
+                if item_type == "text":
+                    last_res = client.send_message(to=receive_id, text=item.get("text", ""))
+                elif item_type == "image_url":
+                    img_info = item.get("image_url", {})
+                    url = img_info.get("url")
+                    if url.startswith("data:image"):
+                        # 处理 base64 图片
+                        try:
+                            if "," in url:
+                                _, encoded = url.split(",", 1)
+                            else:
+                                encoded = url
+                            image_bytes = base64.b64decode(encoded)
+                            last_res = client.send_image(to=receive_id, image=image_bytes)
+                        except Exception as e:
+                            print(f"解析 base64 图片失败: {e}")
+                    else:
+                        # 处理网络 URL
+                        try:
+                            async with httpx.AsyncClient() as httpx_client:
+                                img_resp = await httpx_client.get(url, timeout=15.0)
+                                img_resp.raise_for_status()
+                                last_res = client.send_image(to=receive_id, image=img_resp.content)
+                        except Exception as e:
+                            print(f"下载图片失败: {e}")
+        elif isinstance(content, dict):
+            # 如果是单块格式也支持一下
+            item_type = content.get("type")
+            if item_type == "text":
+                last_res = client.send_message(to=receive_id, text=content.get("text", ""))
+            elif item_type == "image_url":
+                # ... 逻辑与上面类似,为了简洁这里也可以统一转成 list 处理
+                content = [content]
+                # 此处递归或重写逻辑,这里选择简单地重新判断
+                return await feishu_send_message_to_contact(contact_name, content, context)
+        else:
+            return ToolResult(title="发送失败", output="不支持的内容格式", error="Invalid content format")
+
+        if last_res:
+            # 更新 chat_id
+            update_contact_chat_id(contact_name, last_res.chat_id)
+
+            # [待开启] 发送即记录:为了维护完整的聊天记录,将机器人发出的消息也保存到本地文件
+            try:
+                history = load_chat_history(contact_name)
+                history.append({
+                    "role": "assistant",
+                    "message_id": last_res.message_id,
+                    "content": content if isinstance(content, list) else [{"type": "text", "text": content}]
+                })
+                save_chat_history(contact_name, history)
+                # 机器人回复了,将该联系人的未读计数重置为 0
+                update_unread_count(contact_name, reset=True)
+            except Exception as e:
+                print(f"记录发送的消息失败: {e}")
+
+            return ToolResult(
+                title=f"消息已成功发送至 {contact_name}",
+                output=f"发送成功。消息 ID: {last_res.message_id}",
+                metadata={"message_id": last_res.message_id, "chat_id": last_res.chat_id}
+            )
+        return ToolResult(title="发送失败", output="没有执行成功的发送操作")
+    except Exception as e:
+        return ToolResult(title="发送异常", output=str(e), error=str(e))
+
+@tool(
+    hidden_params=["context"],
+    display={
+        "zh": {
+            "name": "获取飞书联系人回复",
+            "params": {
+                "contact_name": "联系人名称",
+                "wait_time_seconds": "可选,如果当前没有新回复,则最多等待指定的秒数。在等待期间会每秒检查一次,一旦有新回复则立即返回。超过时长仍无回复则返回空。"
+            }
+        },
+        "en": {
+            "name": "Get Feishu Contact Replies",
+            "params": {
+                "contact_name": "Contact Name",
+                "wait_time_seconds": "Optional. If there are no new replies, wait up to the specified number of seconds. It will check every second and return immediately if a new reply is detected. If no reply is received after the duration, it returns empty."
+            }
+        }
+    }
+)
+async def feishu_get_contact_replies(
+    contact_name: str,
+    wait_time_seconds: Optional[int] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    获取指定联系人的最新回复消息。
+    返回的数据格式为 OpenAI 多模态消息内容列表。
+    只抓取自上一个机器人消息之后的用户回复。
+
+    Args:
+        contact_name: 飞书联系人的名称
+        wait_time_seconds: 可选的最大轮询等待时间。如果暂时没有新回复,将每秒检查一次直到有回复或超时。
+        context: 工具执行上下文(可选)
+    """
+    contact = get_contact_full_info(contact_name)
+    if not contact:
+        return ToolResult(title="获取失败", output=f"未找到联系人: {contact_name}", error="Contact not found")
+
+    chat_id = contact.get("chat_id")
+    if not chat_id:
+        return ToolResult(title="获取失败", output=f"联系人 {contact_name} 尚未建立会话 (无 chat_id)", error="No chat_id")
+
+    client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET)
+    
+    try:
+        def get_replies():
+            msg_list_res = client.get_message_list(chat_id=chat_id)
+            if not msg_list_res or "items" not in msg_list_res:
+                return []
+
+            openai_blocks = []
+            # 遍历消息列表 (最新的在前)
+            for msg in msg_list_res["items"]:
+                if msg.get("sender_type") == "app":
+                    # 碰到机器人的消息即停止
+                    break
+                
+                content_blocks = _convert_feishu_msg_to_openai_content(client, msg)
+                openai_blocks.extend(content_blocks)
+
+            # 反转列表以保持时间正序 (旧 -> 新)
+            openai_blocks.reverse()
+            return openai_blocks
+
+        openai_blocks = get_replies()
+        
+        # 如果初始没有获取到回复,且设置了等待时间,则开始轮询
+        if not openai_blocks and wait_time_seconds and wait_time_seconds > 0:
+            for _ in range(int(wait_time_seconds)):
+                await asyncio.sleep(1)
+                openai_blocks = get_replies()
+                if openai_blocks:
+                    break
+
+        return ToolResult(
+            title=f"获取 {contact_name} 回复成功",
+            output=json.dumps(openai_blocks, ensure_ascii=False, indent=2) if openai_blocks else "目前没有新的用户回复",
+            metadata={"replies": openai_blocks}
+        )
+    except Exception as e:
+        return ToolResult(title="获取回复异常", output=str(e), error=str(e))
+
+def _convert_feishu_msg_to_openai_content(client: FeishuClient, msg: Dict[str, Any]) -> List[Dict[str, Any]]:
+    """将单条飞书消息内容转换为 OpenAI 多模态格式块列表"""
+    blocks = []
+    msg_type = msg.get("content_type")
+    raw_content = msg.get("content", "")
+    message_id = msg.get("message_id")
+
+    if msg_type == "text":
+        blocks.append({"type": "text", "text": raw_content})
+    elif msg_type == "image":
+        try:
+            content_dict = json.loads(raw_content)
+            image_key = content_dict.get("image_key")
+            if image_key and message_id:
+                img_bytes = client.download_message_resource(
+                    message_id=message_id,
+                    file_key=image_key,
+                    resource_type="image"
+                )
+                b64_str = base64.b64encode(img_bytes).decode('utf-8')
+                blocks.append({
+                    "type": "image_url",
+                    "image_url": {"url": f"data:image/png;base64,{b64_str}"}
+                })
+        except Exception as e:
+            print(f"转换图片消息失败: {e}")
+            blocks.append({"type": "text", "text": "[图片内容获取失败]"})
+    elif msg_type == "post":
+        blocks.append({"type": "text", "text": raw_content})
+    else:
+        blocks.append({"type": "text", "text": f"[{msg_type} 消息]: {raw_content}"})
+    
+    return blocks
+
+@tool(
+    hidden_params=["context"],
+    display={
+        "zh": {
+            "name": "获取飞书聊天历史记录",
+            "params": {
+                "contact_name": "联系人名称",
+                "start_time": "起始时间戳 (秒),可选",
+                "end_time": "结束时间戳 (秒),可选",
+                "page_size": "分页大小,默认 20",
+                "page_token": "分页令牌,用于加载下一页,可选"
+            }
+        },
+        "en": {
+            "name": "Get Feishu Chat History",
+            "params": {
+                "contact_name": "Contact Name",
+                "start_time": "Start timestamp (seconds), optional",
+                "end_time": "End timestamp (seconds), optional",
+                "page_size": "Page size, default 20",
+                "page_token": "Page token for next page, optional"
+            }
+        }
+    }
+)
+async def feishu_get_chat_history(
+    contact_name: str,
+    start_time: Optional[int] = None,
+    end_time: Optional[int] = None,
+    page_size: int = 20,
+    page_token: Optional[str] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    根据联系人名称获取完整的历史聊天记录。
+    支持通过时间戳进行范围筛选,并支持分页获取。
+    返回的消息按时间倒序排列(最新的在前面)。
+
+    Args:
+        contact_name: 飞书联系人的名称
+        start_time: 筛选起始时间的时间戳(秒),可选
+        end_time: 筛选结束时间的时间戳(秒),可选
+        page_size: 每页消息数量,默认为 20
+        page_token: 分页令牌,用于加载上一页/下一页,可选
+        context: 工具执行上下文(可选)
+    """
+    contact = get_contact_full_info(contact_name)
+    if not contact:
+        return ToolResult(title="获取历史失败", output=f"未找到联系人: {contact_name}", error="Contact not found")
+
+    chat_id = contact.get("chat_id")
+    if not chat_id:
+        return ToolResult(title="获取历史失败", output=f"联系人 {contact_name} 尚未建立会话 (无 chat_id)", error="No chat_id")
+
+    client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET)
+
+    try:
+        res = client.get_message_list(
+            chat_id=chat_id,
+            start_time=start_time,
+            end_time=end_time,
+            page_size=page_size,
+            page_token=page_token
+        )
+
+        if not res or "items" not in res:
+            return ToolResult(title="获取历史失败", output="请求接口失败或返回为空")
+
+        # 将所有消息转换为 OpenAI 多模态格式
+        formatted_messages = []
+        for msg in res["items"]:
+            formatted_messages.append({
+                "message_id": msg.get("message_id"),
+                "sender_id": msg.get("sender_id"),
+                "sender_type": "assistant" if msg.get("sender_type") == "app" else "user",
+                "create_time": msg.get("create_time"),
+                "content": _convert_feishu_msg_to_openai_content(client, msg)
+            })
+
+        result_data = {
+            "messages": formatted_messages,
+            "page_token": res.get("page_token"),
+            "has_more": res.get("has_more")
+        }
+
+        return ToolResult(
+            title=f"获取 {contact_name} 历史记录成功",
+            output=json.dumps(result_data, ensure_ascii=False, indent=2),
+            metadata=result_data
+        )
+    except Exception as e:
+        return ToolResult(title="获取历史异常", output=str(e), error=str(e))

+ 79 - 0
agent/tools/builtin/feishu/chat_test.py

@@ -0,0 +1,79 @@
+import asyncio
+import json
+import os
+import sys
+
+# 将项目根目录添加到 python 路径,确保可以正确导入 agent 包
+PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
+if PROJECT_ROOT not in sys.path:
+    sys.path.append(PROJECT_ROOT)
+
+from agent.tools.builtin.feishu.chat import (
+    feishu_get_contact_list,
+    feishu_send_message_to_contact,
+    feishu_get_contact_replies,
+    feishu_get_chat_history
+)
+
+async def feishu_tools():
+    print("开始测试飞书工具...\n")
+
+    # # 1. 测试获取联系人列表
+    # print("--- 测试: feishu_get_contact_list ---")
+    # result_list = await feishu_get_contact_list()
+    # print(f"标题: {result_list.title}")
+    # print(f"输出: {result_list.output}")
+    # print("-" * 30 + "\n")
+    #
+    # # 2. 测试发送消息 (以 '谭景玉' 为例,请确保 contacts.json 中有此人且信息正确)
+    contact_name = "谭景玉"
+    # print(f"--- 测试: feishu_send_message_to_contact (对象: {contact_name}) ---")
+    #
+    # 测试发送纯文本
+    text_content = "干活"
+    print(f"正在发送文本: {text_content}")
+    result_send_text = await feishu_send_message_to_contact(contact_name, text_content)
+    print(f"标题: {result_send_text.title}")
+    print(f"输出: {result_send_text.output}")
+    if result_send_text.error:
+        print(f"错误: {result_send_text.error}")
+
+    # 测试发送多模态消息 (文本 + 图片)
+    # 注意:这里的图片 URL 需要是一个可访问的地址,或者你可以使用 base64 格式
+    # multimodal_content = [
+    #     {"type": "text", "text": "这是一条多模态测试消息:"},
+    #     {"type": "image_url", "image_url": {"url": "https://www.baidu.com/img/flexible/logo/pc/result.png"}}
+    # ]
+    # print(f"\n正在发送多模态消息...")
+    # result_send_multi = await feishu_send_message_to_contact(contact_name, multimodal_content)
+    # print(f"标题: {result_send_multi.title}")
+    # # print(f"输出: {result_send_multi.output}")
+    # if result_send_multi.error:
+    #     print(f"错误: {result_send_multi.error}")
+    # print("-" * 30 + "\n")
+
+    # # 3. 测试获取回复
+    # print(f"--- 测试: feishu_get_contact_replies (对象: {contact_name}) ---")
+    # result_replies = await feishu_get_contact_replies(contact_name)
+    # print(f"标题: {result_replies.title}")
+    # print(f"消息详情: {result_replies.output}")
+    # print("-" * 30 + "\n")
+
+    # # 4. 测试获取历史记录
+    # print(f"--- 测试: feishu_get_chat_history (对象: {contact_name}) ---")
+    # result_history = await feishu_get_chat_history(contact_name, page_size=5, page_token="4cXSlmN7uFAnWWU5yfIGMNvUNrBPLlXZREzLcnvUtOcmK2QFKfwEqfbui_UDsR-y8ne0BkzXABiYTAQASh-n7my_3zQp6o3ERRz0bZ4LB5zMvahf8x7OQoso1rjrMaKM")
+    # print(f"标题: {result_history.title}")
+    # print(f"历史记录输出: {result_history.output}")
+    # print("-" * 30 + "\n")
+
+if __name__ == "__main__":
+    # 模拟环境变量 (如果在系统环境变量中已设置,此处可省略)
+    os.environ["FEISHU_APP_ID"] = "cli_a90fe317987a9cc9"
+    os.environ["FEISHU_APP_SECRET"] = "nn2dWuXTiRA2N6xodbm4g0qz1AfM2ayi"
+    
+    try:
+        asyncio.run(feishu_tools())
+    except KeyboardInterrupt:
+        pass
+    except Exception as e:
+        print(f"测试过程中出现异常: {e}")

+ 945 - 0
agent/tools/builtin/feishu/feishu_client.py

@@ -0,0 +1,945 @@
+"""
+飞书消息处理客户端
+基于 OpenClaw 项目的飞书集成代码整理
+
+依赖安装:
+    pip install lark-oapi websocket-client requests
+
+使用示例:
+    client = FeishuClient(app_id="cli_xxx", app_secret="xxx")
+
+    # 发送消息
+    client.send_message(to="ou_xxx", text="Hello!")
+
+    # 监听消息
+    client.start_websocket(on_message=my_handler)
+"""
+
+import json
+import io
+import logging
+import os
+import tempfile
+import threading
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any, Callable, Dict, List, Optional, Union
+
+import lark_oapi as lark
+from lark_oapi.api.contact.v3 import GetUserRequest, GetUserResponse
+from lark_oapi.api.im.v1 import (
+    CreateMessageRequest, CreateMessageRequestBody,
+    ReplyMessageRequest, ReplyMessageRequestBody,
+    GetMessageRequest, GetMessageResponse,
+    PatchMessageRequest, PatchMessageRequestBody,
+    CreateImageRequest, CreateImageRequestBody,
+    GetImageRequest,
+    CreateFileRequest, CreateFileRequestBody,
+    GetMessageResourceRequest, GetMessageResourceResponse,
+    ListMessageRequest, ListMessageResponse
+)
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+class FeishuDomain(Enum):
+    """飞书域名"""
+    FEISHU = "https://open.feishu.cn"      # 中国版
+    LARK = "https://open.larksuite.com"    # 国际版
+
+
+class ChatType(Enum):
+    """聊天类型"""
+    P2P = "p2p"      # 私聊
+    GROUP = "group"  # 群聊
+
+
+class ReceiveIdType(Enum):
+    """接收者ID类型"""
+    OPEN_ID = "open_id"
+    USER_ID = "user_id"
+    UNION_ID = "union_id"
+    EMAIL = "email"
+    CHAT_ID = "chat_id"
+
+
+@dataclass
+class FeishuMessageEvent:
+    """飞书消息事件"""
+    message_id: str
+    chat_id: str
+    chat_type: ChatType
+    content: str
+    content_type: str  # text, image, file, post, etc.
+    sender_open_id: str
+    sender_user_id: Optional[str] = None
+    sender_name: Optional[str] = None
+    root_id: Optional[str] = None      # 根消息ID(话题)
+    parent_id: Optional[str] = None    # 父消息ID(回复)
+    mentions: List[Dict] = field(default_factory=list)
+    mentioned_bot: bool = False
+
+
+@dataclass
+class SendResult:
+    """发送结果"""
+    message_id: str
+    chat_id: str
+
+
+class FeishuClient:
+    """
+    飞书客户端
+
+    功能:
+    - 发送/接收消息
+    - 上传/下载媒体文件
+    - WebSocket 实时监听
+    """
+
+    def __init__(
+        self,
+        app_id: str,
+        app_secret: str,
+        domain: FeishuDomain = FeishuDomain.FEISHU,
+        encrypt_key: Optional[str] = None,
+        verification_token: Optional[str] = None,
+    ):
+        """
+        初始化飞书客户端
+
+        Args:
+            app_id: 飞书应用 App ID
+            app_secret: 飞书应用 App Secret
+            domain: 飞书域名 (FEISHU 或 LARK)
+            encrypt_key: 事件加密密钥 (可选)
+            verification_token: 事件验证令牌 (可选)
+        """
+        self.app_id = app_id
+        self.app_secret = app_secret
+        self.domain = domain
+        self.encrypt_key = encrypt_key
+        self.verification_token = verification_token
+
+        # 创建 Lark 客户端
+        self.client = lark.Client.builder() \
+            .app_id(app_id) \
+            .app_secret(app_secret) \
+            .domain(domain.value) \
+            .build()
+
+        # 缓存
+        self._bot_open_id: Optional[str] = None
+        self._sender_name_cache: Dict[str, str] = {}
+
+    # ==================== 消息发送 ====================
+
+    def send_message(
+        self,
+        to: str,
+        text: str,
+        reply_to_message_id: Optional[str] = None,
+        receive_id_type: Optional[ReceiveIdType] = None,
+    ) -> SendResult:
+        """
+        发送文本消息
+
+        Args:
+            to: 接收者ID (open_id, user_id, chat_id 等)
+            text: 消息文本
+            reply_to_message_id: 回复的消息ID (可选)
+            receive_id_type: 接收者ID类型 (可选,自动推断)
+
+        Returns:
+            SendResult: 发送结果
+        """
+        if receive_id_type is None:
+            receive_id_type = self._resolve_receive_id_type(to)
+
+        # 构建富文本消息 (支持 Markdown)
+        content = json.dumps({
+            "zh_cn": {
+                "content": [[{"tag": "md", "text": text}]]
+            }
+        })
+
+        if reply_to_message_id:
+            # 回复消息
+            request = ReplyMessageRequest.builder() \
+                .message_id(reply_to_message_id) \
+                .request_body(ReplyMessageRequestBody.builder()
+                    .content(content)
+                    .msg_type("post")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.reply(request)
+        else:
+            # 新消息
+            request = CreateMessageRequest.builder() \
+                .receive_id_type(receive_id_type.value) \
+                .request_body(CreateMessageRequestBody.builder()
+                    .receive_id(to)
+                    .content(content)
+                    .msg_type("post")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.create(request)
+
+        if not response.success():
+            raise Exception(f"发送消息失败: {response.msg} (code: {response.code})")
+
+        return SendResult(
+            message_id=response.data.message_id,
+            chat_id=response.data.chat_id
+        )
+
+    def send_card(
+        self,
+        to: str,
+        card: Dict[str, Any],
+        reply_to_message_id: Optional[str] = None,
+        receive_id_type: Optional[ReceiveIdType] = None,
+    ) -> SendResult:
+        """
+        发送卡片消息 (交互式消息)
+
+        Args:
+            to: 接收者ID
+            card: 卡片内容 (JSON 结构)
+            reply_to_message_id: 回复的消息ID (可选)
+            receive_id_type: 接收者ID类型 (可选)
+
+        Returns:
+            SendResult: 发送结果
+        """
+        if receive_id_type is None:
+            receive_id_type = self._resolve_receive_id_type(to)
+
+        content = json.dumps(card)
+
+        if reply_to_message_id:
+            request = ReplyMessageRequest.builder() \
+                .message_id(reply_to_message_id) \
+                .request_body(ReplyMessageRequestBody.builder()
+                    .content(content)
+                    .msg_type("interactive")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.reply(request)
+        else:
+            request = CreateMessageRequest.builder() \
+                .receive_id_type(receive_id_type.value) \
+                .request_body(CreateMessageRequestBody.builder()
+                    .receive_id(to)
+                    .content(content)
+                    .msg_type("interactive")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.create(request)
+
+        if not response.success():
+            raise Exception(f"发送卡片失败: {response.msg}")
+
+        return SendResult(
+            message_id=response.data.message_id,
+            chat_id=response.data.chat_id
+        )
+
+    def send_markdown_card(
+        self,
+        to: str,
+        text: str,
+        reply_to_message_id: Optional[str] = None,
+    ) -> SendResult:
+        """
+        发送 Markdown 卡片 (更好的格式渲染)
+
+        Args:
+            to: 接收者ID
+            text: Markdown 文本
+            reply_to_message_id: 回复的消息ID (可选)
+
+        Returns:
+            SendResult: 发送结果
+        """
+        card = {
+            "config": {"wide_screen_mode": True},
+            "elements": [{"tag": "markdown", "content": text}]
+        }
+        return self.send_card(to, card, reply_to_message_id)
+
+    # ==================== 媒体处理 ====================
+
+    def upload_image(
+            self,
+            image: Union[bytes, str],
+            image_type: str = "message"
+    ) -> str:
+        """
+        上传图片
+        """
+        file_obj = None
+
+        try:
+            # 1. 准备文件对象
+            if isinstance(image, str):
+                # 如果是路径,直接打开
+                file_obj = open(image, "rb")
+            else:
+                # 如果是二进制数据,使用内存文件 (避免写磁盘)
+                file_obj = io.BytesIO(image)
+                # 某些 SDK/API 依赖文件名来判断 Content-Type,我们手动给一个名字
+                # 如果知道真实格式更好,不知道则默认 .png 或 .bin
+                file_obj.name = "upload.png"
+
+                # 2. 构建请求
+            # 注意:这里直接传入 file_obj
+            request = CreateImageRequest.builder() \
+                .request_body(CreateImageRequestBody.builder()
+                              .image_type(image_type)
+                              .image(file_obj)
+                              .build()) \
+                .build()
+
+            # 3. 发起请求
+            response = self.client.im.v1.image.create(request)
+
+            if not response.success():
+                raise Exception(f"上传图片失败: {response.msg}")
+
+            return response.data.image_key
+
+        finally:
+            # 4. 显式关闭文件句柄
+            if file_obj and not isinstance(file_obj, io.BytesIO):
+                file_obj.close()
+
+    def download_image(self, image_key: str) -> bytes:
+        """
+        下载图片
+
+        Args:
+            image_key: 图片 key
+
+        Returns:
+            bytes: 图片数据
+        """
+        request = GetImageRequest.builder() \
+            .image_key(image_key) \
+            .build()
+
+        response = self.client.im.v1.image.get(request)
+
+        if not response.success():
+            raise Exception(f"下载图片失败: {response.msg}")
+
+        return response.file.read()
+
+    def send_image(
+        self,
+        to: str,
+        image: Union[bytes, str],
+        reply_to_message_id: Optional[str] = None,
+    ) -> SendResult:
+        """
+        发送图片消息
+
+        Args:
+            to: 接收者ID
+            image: 图片数据或文件路径
+            reply_to_message_id: 回复的消息ID (可选)
+
+        Returns:
+            SendResult: 发送结果
+        """
+        image_key = self.upload_image(image)
+        content = json.dumps({"image_key": image_key})
+
+        receive_id_type = self._resolve_receive_id_type(to)
+
+        if reply_to_message_id:
+            request = ReplyMessageRequest.builder() \
+                .message_id(reply_to_message_id) \
+                .request_body(ReplyMessageRequestBody.builder()
+                    .content(content)
+                    .msg_type("image")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.reply(request)
+        else:
+            request = CreateMessageRequest.builder() \
+                .receive_id_type(receive_id_type.value) \
+                .request_body(CreateMessageRequestBody.builder()
+                    .receive_id(to)
+                    .content(content)
+                    .msg_type("image")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.create(request)
+
+        if not response.success():
+            raise Exception(f"发送图片失败: {response.msg}")
+
+        return SendResult(
+            message_id=response.data.message_id,
+            chat_id=response.data.chat_id
+        )
+
+    def upload_file(
+        self,
+        file: Union[bytes, str],
+        file_name: str,
+        file_type: str = "stream",
+    ) -> str:
+        """
+        上传文件
+
+        Args:
+            file: 文件数据或路径
+            file_name: 文件名
+            file_type: 文件类型 (opus/mp4/pdf/doc/xls/ppt/stream)
+
+        Returns:
+            str: file_key
+        """
+        if isinstance(file, str):
+            with open(file, "rb") as f:
+                file_data = f.read()
+            if not file_name:
+                file_name = os.path.basename(file)
+        else:
+            file_data = file
+
+        with tempfile.NamedTemporaryFile(delete=False) as tmp:
+            tmp.write(file_data)
+            tmp_path = tmp.name
+
+        try:
+            request = CreateFileRequest.builder() \
+                .request_body(CreateFileRequestBody.builder()
+                    .file_type(file_type)
+                    .file_name(file_name)
+                    .file(open(tmp_path, "rb"))
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.file.create(request)
+
+            if not response.success():
+                raise Exception(f"上传文件失败: {response.msg}")
+
+            return response.data.file_key
+        finally:
+            os.unlink(tmp_path)
+
+    def send_file(
+        self,
+        to: str,
+        file: Union[bytes, str],
+        file_name: str,
+        reply_to_message_id: Optional[str] = None,
+    ) -> SendResult:
+        """
+        发送文件消息
+
+        Args:
+            to: 接收者ID
+            file: 文件数据或路径
+            file_name: 文件名
+            reply_to_message_id: 回复的消息ID (可选)
+
+        Returns:
+            SendResult: 发送结果
+        """
+        file_type = self._detect_file_type(file_name)
+        file_key = self.upload_file(file, file_name, file_type)
+        content = json.dumps({"file_key": file_key})
+
+        receive_id_type = self._resolve_receive_id_type(to)
+
+        if reply_to_message_id:
+            request = ReplyMessageRequest.builder() \
+                .message_id(reply_to_message_id) \
+                .request_body(ReplyMessageRequestBody.builder()
+                    .content(content)
+                    .msg_type("file")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.reply(request)
+        else:
+            request = CreateMessageRequest.builder() \
+                .receive_id_type(receive_id_type.value) \
+                .request_body(CreateMessageRequestBody.builder()
+                    .receive_id(to)
+                    .content(content)
+                    .msg_type("file")
+                    .build()) \
+                .build()
+
+            response = self.client.im.v1.message.create(request)
+
+        if not response.success():
+            raise Exception(f"发送文件失败: {response.msg}")
+
+        return SendResult(
+            message_id=response.data.message_id,
+            chat_id=response.data.chat_id
+        )
+
+    def download_message_resource(
+        self,
+        message_id: str,
+        file_key: str,
+        resource_type: str = "file"
+    ) -> bytes:
+        """
+        下载消息中的资源文件
+
+        Args:
+            message_id: 消息ID
+            file_key: 文件 key
+            resource_type: 资源类型 ("image" 或 "file")
+
+        Returns:
+            bytes: 文件数据
+        """
+        request = GetMessageResourceRequest.builder() \
+            .message_id(message_id) \
+            .file_key(file_key) \
+            .type(resource_type) \
+            .build()
+
+        response = self.client.im.v1.message_resource.get(request)
+
+        if not response.success():
+            raise Exception(f"下载资源失败: {response.msg}")
+
+        return response.file.read()
+
+    # ==================== 消息获取 ====================
+
+    def get_message(self, message_id: str) -> Optional[Dict]:
+        """
+        获取消息详情
+
+        Args:
+            message_id: 消息ID
+
+        Returns:
+            Dict: 消息详情,失败返回 None
+        """
+        request = GetMessageRequest.builder() \
+            .message_id(message_id) \
+            .build()
+
+        response = self.client.im.v1.message.get(request)
+
+        if not response.success():
+            return None
+
+        items = response.data.items
+        if not items:
+            return None
+
+        item = items[0]
+        content = item.body.content if item.body else ""
+
+        # 解析文本内容
+        try:
+            parsed = json.loads(content)
+            if item.msg_type == "text" and "text" in parsed:
+                content = parsed["text"]
+        except:
+            pass
+
+        return {
+            "message_id": item.message_id,
+            "chat_id": item.chat_id,
+            "sender_id": item.sender.id if item.sender else None,
+            "sender_type": item.sender.sender_type if item.sender else None,
+            "content": content,
+            "content_type": item.msg_type,
+            "create_time": item.create_time,
+        }
+
+    def get_message_list(self, chat_id: str, start_time: Optional[Union[str, int]] = None, end_time: Optional[Union[str, int]] = None, page_size: int = 20, page_token: Optional[str] = None) -> Optional[Dict]:
+        """
+        获取消息列表
+
+        Args:
+            chat_id: 会话 ID
+            start_time: 起始时间 (可选)
+            end_time: 结束时间 (可选)
+            page_size: 分页大小 (默认 20)
+            page_token: 分页令牌 (可选)
+
+        Returns:
+            Dict: 包含消息列表和分页信息,失败返回 None
+        """
+        builder = ListMessageRequest.builder() \
+            .container_id_type("chat") \
+            .container_id(chat_id) \
+            .sort_type("ByCreateTimeDesc") \
+            .page_size(page_size)
+
+        if start_time is not None:
+            builder.start_time(str(start_time))
+        if end_time is not None:
+            builder.end_time(str(end_time))
+        if page_token:
+            builder.page_token(page_token)
+
+        request = builder.build()
+
+        # 发起请求
+        response: ListMessageResponse = self.client.im.v1.message.list(request)
+
+        # 处理失败返回
+        if not response.success():
+            logger.error(
+                f"client.im.v1.message.list failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}")
+            return None
+
+        # 构建返回结果
+        messages = []
+        if response.data.items:
+            for item in response.data.items:
+                content = item.body.content if item.body else ""
+                # 解析文本内容
+                try:
+                    parsed = json.loads(content)
+                    if item.msg_type == "text" and "text" in parsed:
+                        content = parsed["text"]
+                except:
+                    pass
+
+                messages.append({
+                    "message_id": item.message_id,
+                    "chat_id": item.chat_id,
+                    "sender_id": item.sender.id if item.sender else None,
+                    "sender_type": item.sender.sender_type if item.sender else None,
+                    "content": content,
+                    "content_type": item.msg_type,
+                    "create_time": item.create_time,
+                })
+
+        return {
+            "items": messages,
+            "page_token": response.data.page_token,
+            "has_more": response.data.has_more
+        }
+
+    # ==================== 用户信息 ====================
+
+    def get_user_info(self, open_id: str) -> Optional[Dict]:
+        """
+        获取用户信息
+
+        Args:
+            open_id: 用户 open_id
+
+        Returns:
+            Dict: 用户信息,失败返回 None
+        """
+        # 检查缓存
+        if open_id in self._sender_name_cache:
+            return {"name": self._sender_name_cache[open_id]}
+
+        request = GetUserRequest.builder() \
+            .user_id(open_id) \
+            .user_id_type("open_id") \
+            .build()
+
+        response = self.client.contact.v3.user.get(request)
+
+        if not response.success():
+            return None
+
+        user = response.data.user
+        name = user.name or user.en_name or user.nickname
+
+        if name:
+            self._sender_name_cache[open_id] = name
+
+        return {
+            "open_id": user.open_id,
+            "user_id": user.user_id,
+            "name": name,
+            "en_name": user.en_name,
+            "nickname": user.nickname,
+            "email": user.email,
+            "mobile": user.mobile,
+            "avatar": user.avatar.avatar_origin if user.avatar else None,
+        }
+
+    # ==================== WebSocket 监听 ====================
+
+    def start_websocket(
+        self,
+        on_message: Callable[[FeishuMessageEvent], None],
+        on_bot_added: Optional[Callable[[str], None]] = None,
+        on_bot_removed: Optional[Callable[[str], None]] = None,
+        blocking: bool = True,
+    ):
+        """
+        启动 WebSocket 监听消息
+
+        Args:
+            on_message: 消息回调函数
+            on_bot_added: 机器人被添加到群的回调 (可选)
+            on_bot_removed: 机器人被移出群的回调 (可选)
+            blocking: 是否阻塞当前线程
+        """
+        # 创建事件处理器
+        # 注意: lark-oapi SDK 的回调函数只接受一个参数 (data)
+        event_handler = lark.EventDispatcherHandler.builder(
+            self.encrypt_key or "",
+            self.verification_token or ""
+        ).register_p2_im_message_receive_v1(
+            lambda data: self._handle_message_event(data, on_message)
+        )
+
+        if on_bot_added:
+            event_handler = event_handler.register_p2_im_chat_member_bot_added_v1(
+                lambda data: on_bot_added(data.event.chat_id)
+            )
+
+        if on_bot_removed:
+            event_handler = event_handler.register_p2_im_chat_member_bot_deleted_v1(
+                lambda data: on_bot_removed(data.event.chat_id)
+            )
+
+        handler = event_handler.build()
+
+        # 创建 WebSocket 客户端
+        ws_client = lark.ws.Client(
+            self.app_id,
+            self.app_secret,
+            event_handler=handler,
+            domain=lark.FEISHU_DOMAIN if self.domain == FeishuDomain.FEISHU else lark.LARK_DOMAIN,
+            log_level=lark.LogLevel.INFO,
+        )
+
+        logger.info("启动飞书 WebSocket 监听...")
+
+        if blocking:
+            ws_client.start()
+        else:
+            thread = threading.Thread(target=ws_client.start, daemon=True)
+            thread.start()
+            return thread
+
+    def _handle_message_event(
+        self,
+        data,
+        callback: Callable[[FeishuMessageEvent], None]
+    ):
+        """处理消息事件"""
+        try:
+            # data 结构: P2ImMessageReceiveV1 对象
+            # data.event 包含实际的事件数据
+            event = data.event
+            msg = event.message
+            sender = event.sender
+
+            # 解析消息内容
+            content = self._parse_message_content(msg.content, msg.message_type)
+
+            # 检查是否 @了机器人
+            mentioned_bot = self._check_bot_mentioned(msg.mentions)
+
+            # 去除 @机器人 的文本
+            if msg.mentions:
+                content = self._strip_bot_mention(content, msg.mentions)
+
+            # 构建事件对象
+            message_event = FeishuMessageEvent(
+                message_id=msg.message_id,
+                chat_id=msg.chat_id,
+                chat_type=ChatType(msg.chat_type),
+                content=content,
+                content_type=msg.message_type,
+                sender_open_id=sender.sender_id.open_id if sender.sender_id else "",
+                sender_user_id=sender.sender_id.user_id if sender.sender_id else None,
+                root_id=msg.root_id,
+                parent_id=msg.parent_id,
+                mentions=[],  # 简化处理
+                mentioned_bot=mentioned_bot,
+            )
+
+            # 尝试获取发送者名称 (可能会失败,不影响主流程)
+            try:
+                if message_event.sender_open_id:
+                    user_info = self.get_user_info(message_event.sender_open_id)
+                    if user_info:
+                        message_event.sender_name = user_info.get("name")
+            except Exception as e:
+                logger.debug(f"获取用户信息失败: {e}")
+
+            callback(message_event)
+
+        except Exception as e:
+            logger.error(f"处理消息事件失败: {e}", exc_info=True)
+
+    # ==================== 辅助方法 ====================
+
+    def _resolve_receive_id_type(self, receive_id: str) -> ReceiveIdType:
+        """推断接收者ID类型"""
+        if receive_id.startswith("ou_"):
+            return ReceiveIdType.OPEN_ID
+        elif receive_id.startswith("on_"):
+            return ReceiveIdType.UNION_ID
+        elif receive_id.startswith("oc_"):
+            return ReceiveIdType.CHAT_ID
+        elif "@" in receive_id:
+            return ReceiveIdType.EMAIL
+        else:
+            return ReceiveIdType.USER_ID
+
+    def _parse_message_content(self, content: str, message_type: str) -> str:
+        """解析消息内容"""
+        try:
+            parsed = json.loads(content)
+            if message_type == "text":
+                return parsed.get("text", "")
+            elif message_type == "post":
+                return self._parse_post_content(parsed)
+            return content
+        except:
+            return content
+
+    def _parse_post_content(self, parsed: Dict) -> str:
+        """解析富文本消息"""
+        title = parsed.get("title", "")
+        content_blocks = parsed.get("content", [])
+
+        text_parts = [title] if title else []
+
+        for paragraph in content_blocks:
+            if isinstance(paragraph, list):
+                for element in paragraph:
+                    if element.get("tag") == "text":
+                        text_parts.append(element.get("text", ""))
+                    elif element.get("tag") == "a":
+                        text_parts.append(element.get("text", element.get("href", "")))
+                    elif element.get("tag") == "at":
+                        text_parts.append(f"@{element.get('user_name', '')}")
+
+        return "\n".join(text_parts).strip() or "[富文本消息]"
+
+    def _check_bot_mentioned(self, mentions: Optional[List]) -> bool:
+        """检查是否 @了机器人"""
+        if not mentions:
+            return False
+
+        if not self._bot_open_id:
+            # 如果没有缓存机器人 open_id,假设有 mention 就是 @了机器人
+            return len(mentions) > 0
+
+        return any(m.id.open_id == self._bot_open_id for m in mentions)
+
+    def _strip_bot_mention(self, text: str, mentions: List) -> str:
+        """去除 @机器人 的文本"""
+        result = text
+        for mention in mentions:
+            name = mention.name if hasattr(mention, 'name') else ""
+            key = mention.key if hasattr(mention, 'key') else ""
+            if name:
+                result = result.replace(f"@{name}", "").strip()
+            if key:
+                result = result.replace(key, "").strip()
+        return result
+
+    def _detect_file_type(self, file_name: str) -> str:
+        """检测文件类型"""
+        ext = os.path.splitext(file_name)[1].lower()
+        type_map = {
+            ".opus": "opus", ".ogg": "opus",
+            ".mp4": "mp4", ".mov": "mp4", ".avi": "mp4",
+            ".pdf": "pdf",
+            ".doc": "doc", ".docx": "doc",
+            ".xls": "xls", ".xlsx": "xls",
+            ".ppt": "ppt", ".pptx": "ppt",
+        }
+        return type_map.get(ext, "stream")
+
+
+# ==================== 使用示例 ====================
+
+if __name__ == "__main__":
+    # 从环境变量获取配置
+    APP_ID = os.getenv("FEISHU_APP_ID", "cli_a90fe317987a9cc9")
+    APP_SECRET = os.getenv("FEISHU_APP_SECRET", "nn2dWuXTiRA2N6xodbm4g0qz1AfM2ayi")
+
+    if not APP_ID or not APP_SECRET:
+        print("请设置环境变量 FEISHU_APP_ID 和 FEISHU_APP_SECRET")
+        exit(1)
+
+    # 创建客户端
+    client = FeishuClient(
+        app_id=APP_ID,
+        app_secret=APP_SECRET,
+        domain=FeishuDomain.FEISHU,
+    )
+
+    # 消息处理回调
+    def handle_message(event: FeishuMessageEvent):
+        print(f"\n收到消息:")
+        print(f"  发送者: {event.sender_name or event.sender_open_id}")
+        print(f"  类型: {event.chat_type.value}")
+        print(f"  内容: {event.content}")
+        print(f"  @机器人: {event.mentioned_bot}")
+
+        # 自动回复示例
+        if event.chat_type == ChatType.P2P or event.mentioned_bot:
+            # 先回复文字
+            reply_text = f"收到你的消息: {event.content}"
+            chat_id = event.chat_id
+            content = event.content
+            content_type = event.content_type # image、text等
+            open_id = event.sender_open_id
+            client.send_message(
+                to=event.chat_id,
+                text=reply_text,
+                reply_to_message_id=event.message_id
+            )
+            print(f"  已回复文字: {reply_text}")
+
+            # 再回复一张图片 (读取当前目录下的 hanli.png)
+            try:
+                image_path = os.path.join(os.path.dirname(__file__) or ".", "hanli.png")
+                if os.path.exists(image_path):
+                    client.send_image(
+                        to=event.chat_id,
+                        image=image_path,
+                    )
+                    print(f"  已回复图片: {image_path}")
+                else:
+                    print(f"  图片不存在: {image_path}")
+            except Exception as e:
+                print(f"  回复图片失败: {e}")
+
+    # 启动 WebSocket 监听
+    print("启动飞书消息监听...")
+    print("按 Ctrl+C 退出")
+
+    try:
+        client.start_websocket(
+            on_message=handle_message,
+            on_bot_added=lambda chat_id: print(f"机器人被添加到群: {chat_id}"),
+            on_bot_removed=lambda chat_id: print(f"机器人被移出群: {chat_id}"),
+            blocking=True
+        )
+
+        # res = client.get_message_list(chat_id='oc_56e85f0e2c97405d176729b62d8f56e5', start_time=0, end_time=1770623620)
+        # print(f"获取消息列表结果: {json.dumps(res, indent=4, ensure_ascii=False)}")
+    except KeyboardInterrupt:
+        print("\n退出")

+ 92 - 0
agent/tools/builtin/feishu/websocket_event.py

@@ -0,0 +1,92 @@
+import os
+import json
+import logging
+import asyncio
+import sys
+from typing import Optional
+
+# 将项目根目录添加到 python 路径,确保可以作为独立脚本运行
+PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
+if PROJECT_ROOT not in sys.path:
+    sys.path.append(PROJECT_ROOT)
+
+from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuMessageEvent, FeishuDomain
+from agent.tools.builtin.feishu.chat import (
+    FEISHU_APP_ID, 
+    FEISHU_APP_SECRET, 
+    get_contact_by_id, 
+    load_chat_history, 
+    save_chat_history, 
+    update_unread_count,
+    _convert_feishu_msg_to_openai_content
+)
+
+# 配置日志
+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+logger = logging.getLogger("FeishuWebsocket")
+
+class FeishuMessageListener:
+    def __init__(self):
+        self.client = FeishuClient(
+            app_id=FEISHU_APP_ID,
+            app_secret=FEISHU_APP_SECRET,
+            domain=FeishuDomain.FEISHU
+        )
+
+    def handle_incoming_message(self, event: FeishuMessageEvent):
+        """处理收到的飞书消息事件"""
+        # 1. 识别联系人
+        # 优先使用 sender_open_id 匹配联系人,如果没有则尝试 chat_id
+        contact = get_contact_by_id(event.sender_open_id) or get_contact_by_id(event.chat_id)
+        
+        if not contact:
+            logger.warning(f"收到未知发送者的消息: open_id={event.sender_open_id}, chat_id={event.chat_id}")
+            # 对于未知联系人,我们可以选择忽略,或者记录到 'unknown' 分类
+            contact = {"name": "未知联系人", "open_id": event.sender_open_id}
+
+        contact_name = contact.get("name")
+        logger.info(f"收到来自 [{contact_name}] 的消息: {event.content[:50]}...")
+
+        # 2. 转换为 OpenAI 多模态格式
+        # 构造一个类似 get_message_list 返回的字典对象,以便重用转换逻辑
+        msg_dict = {
+            "message_id": event.message_id,
+            "content_type": event.content_type,
+            "content": event.content, # 对于 text, websocket 传来的已经是解析后的字符串;对于 image 则是原始 JSON 字符串
+            "sender_id": event.sender_open_id,
+            "sender_type": "user" # WebSocket 收到的一般是用户消息,除非是机器人自己的回显(通常会过滤)
+        }
+        
+        openai_content = _convert_feishu_msg_to_openai_content(self.client, msg_dict)
+
+        # 3. 维护聊天记录
+        history = load_chat_history(contact_name)
+        new_message = {
+            "role": "user",
+            "message_id": event.message_id,
+            "timestamp": os.path.getmtime(os.path.join(os.path.dirname(__file__), "chat.py")), # 简单模拟一个时间戳,实际应使用事件时间
+            "content": openai_content
+        }
+        history.append(new_message)
+        save_chat_history(contact_name, history)
+
+        # 4. 更新未读计数
+        update_unread_count(contact_name, increment=1)
+        logger.info(f"已更新 [{contact_name}] 的聊天记录并增加未读计数")
+
+    def start(self):
+        """启动监听"""
+        logger.info("正在启动飞书消息实时监听...")
+        try:
+            self.client.start_websocket(
+                on_message=self.handle_incoming_message,
+                blocking=True
+            )
+        except KeyboardInterrupt:
+            logger.info("监听已停止")
+        except Exception as e:
+            logger.error(f"监听过程中出现错误: {e}")
+
+if __name__ == "__main__":
+    listener = FeishuMessageListener()
+    listener.start()

+ 19 - 0
agent/tools/builtin/file/__init__.py

@@ -0,0 +1,19 @@
+"""
+File tools - 文件操作工具
+
+包含:read, write, edit, glob, grep
+"""
+
+from .read import read_file
+from .write import write_file
+from .edit import edit_file
+from .glob import glob_files
+from .grep import grep_content
+
+__all__ = [
+    "read_file",
+    "write_file",
+    "edit_file",
+    "glob_files",
+    "grep_content",
+]

+ 531 - 0
agent/tools/builtin/file/edit.py

@@ -0,0 +1,531 @@
+"""
+Edit Tool - 文件编辑工具
+
+参考 OpenCode 的 edit.ts 完整实现。
+
+核心功能:
+- 精确字符串替换
+- 9 种智能匹配策略(按优先级依次尝试)
+- 生成 diff 预览
+"""
+
+from pathlib import Path
+from typing import Optional, Generator
+import difflib
+import re
+
+from agent.tools import tool, ToolResult, ToolContext
+
+
+@tool(description="编辑文件,使用精确字符串替换。支持多种智能匹配策略。", hidden_params=["context"])
+async def edit_file(
+    file_path: str,
+    old_string: str,
+    new_string: str,
+    replace_all: bool = False,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    编辑文件内容
+
+    使用 9 种智能匹配策略,按优先级尝试:
+    1. SimpleReplacer - 精确匹配
+    2. LineTrimmedReplacer - 忽略行首尾空白
+    3. BlockAnchorReplacer - 基于首尾锚点的块匹配(使用 Levenshtein 相似度)
+    4. WhitespaceNormalizedReplacer - 空白归一化
+    5. IndentationFlexibleReplacer - 灵活缩进匹配
+    6. EscapeNormalizedReplacer - 转义序列归一化
+    7. TrimmedBoundaryReplacer - 边界空白裁剪
+    8. ContextAwareReplacer - 上下文感知匹配
+    9. MultiOccurrenceReplacer - 多次出现匹配
+
+    Args:
+        file_path: 文件路径
+        old_string: 要替换的文本
+        new_string: 替换后的文本
+        replace_all: 是否替换所有匹配(默认 False,只替换唯一匹配)
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 编辑结果和 diff
+    """
+    if old_string == new_string:
+        return ToolResult(
+            title="无需编辑",
+            output="old_string 和 new_string 相同",
+            error="Strings are identical"
+        )
+
+    # 解析路径
+    path = Path(file_path)
+    if not path.is_absolute():
+        path = Path.cwd() / path
+
+    # 检查文件
+    if not path.exists():
+        return ToolResult(
+            title="文件未找到",
+            output=f"文件不存在: {file_path}",
+            error="File not found"
+        )
+
+    if path.is_dir():
+        return ToolResult(
+            title="路径错误",
+            output=f"路径是目录,不是文件: {file_path}",
+            error="Path is a directory"
+        )
+
+    # 读取文件
+    try:
+        with open(path, 'r', encoding='utf-8') as f:
+            content_old = f.read()
+    except Exception as e:
+        return ToolResult(
+            title="读取失败",
+            output=f"无法读取文件: {str(e)}",
+            error=str(e)
+        )
+
+    # 执行替换
+    try:
+        content_new = replace(content_old, old_string, new_string, replace_all)
+    except ValueError as e:
+        return ToolResult(
+            title="替换失败",
+            output=str(e),
+            error=str(e)
+        )
+
+    # 生成 diff
+    diff = _create_diff(file_path, content_old, content_new)
+
+    # 写入文件
+    try:
+        with open(path, 'w', encoding='utf-8') as f:
+            f.write(content_new)
+    except Exception as e:
+        return ToolResult(
+            title="写入失败",
+            output=f"无法写入文件: {str(e)}",
+            error=str(e)
+        )
+
+    # 统计变更
+    old_lines = content_old.count('\n')
+    new_lines = content_new.count('\n')
+
+    return ToolResult(
+        title=path.name,
+        output=f"编辑成功\n\n{diff}",
+        metadata={
+            "diff": diff,
+            "old_lines": old_lines,
+            "new_lines": new_lines,
+            "additions": max(0, new_lines - old_lines),
+            "deletions": max(0, old_lines - new_lines)
+        },
+        long_term_memory=f"已编辑文件 {path.name}"
+    )
+
+
+# ============================================================================
+# 替换策略(Replacers)
+# ============================================================================
+
+def replace(content: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
+    """
+    使用多种策略尝试替换
+
+    按优先级尝试所有策略,直到找到匹配
+    """
+    if old_string == new_string:
+        raise ValueError("old_string 和 new_string 必须不同")
+
+    not_found = True
+
+    # 按优先级尝试策略
+    for replacer in [
+        simple_replacer,
+        line_trimmed_replacer,
+        block_anchor_replacer,
+        whitespace_normalized_replacer,
+        indentation_flexible_replacer,
+        escape_normalized_replacer,
+        trimmed_boundary_replacer,
+        context_aware_replacer,
+        multi_occurrence_replacer,
+    ]:
+        for search in replacer(content, old_string):
+            index = content.find(search)
+            if index == -1:
+                continue
+
+            not_found = False
+
+            if replace_all:
+                return content.replace(search, new_string)
+
+            # 检查唯一性
+            last_index = content.rfind(search)
+            if index != last_index:
+                continue
+
+            return content[:index] + new_string + content[index + len(search):]
+
+    if not_found:
+        raise ValueError("在文件中未找到匹配的文本")
+
+    raise ValueError(
+        "找到多个匹配。请在 old_string 中提供更多上下文以唯一标识,"
+        "或使用 replace_all=True 替换所有匹配。"
+    )
+
+
+# 1. SimpleReplacer - 精确匹配
+def simple_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """精确匹配"""
+    yield find
+
+
+# 2. LineTrimmedReplacer - 忽略行首尾空白
+def line_trimmed_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """忽略行首尾空白进行匹配"""
+    content_lines = content.split('\n')
+    find_lines = find.rstrip('\n').split('\n')
+
+    for i in range(len(content_lines) - len(find_lines) + 1):
+        # 检查所有行是否匹配(忽略首尾空白)
+        matches = all(
+            content_lines[i + j].strip() == find_lines[j].strip()
+            for j in range(len(find_lines))
+        )
+
+        if matches:
+            # 计算原始文本位置
+            match_start = sum(len(content_lines[k]) + 1 for k in range(i))
+            match_end = match_start + sum(
+                len(content_lines[i + k]) + (1 if k < len(find_lines) - 1 else 0)
+                for k in range(len(find_lines))
+            )
+            yield content[match_start:match_end]
+
+
+# 3. BlockAnchorReplacer - 基于首尾锚点的块匹配
+def block_anchor_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """
+    基于首尾行作为锚点进行块匹配
+    使用 Levenshtein 距离计算中间行的相似度
+    """
+    content_lines = content.split('\n')
+    find_lines = find.rstrip('\n').split('\n')
+
+    if len(find_lines) < 3:
+        return
+
+    first_line_find = find_lines[0].strip()
+    last_line_find = find_lines[-1].strip()
+    find_block_size = len(find_lines)
+
+    # 收集所有候选位置(首尾行都匹配)
+    candidates = []
+    for i in range(len(content_lines)):
+        if content_lines[i].strip() != first_line_find:
+            continue
+
+        # 查找匹配的尾行
+        for j in range(i + 2, len(content_lines)):
+            if content_lines[j].strip() == last_line_find:
+                candidates.append((i, j))
+                break
+
+    if not candidates:
+        return
+
+    # 单个候选:使用宽松阈值
+    if len(candidates) == 1:
+        start_line, end_line = candidates[0]
+        actual_block_size = end_line - start_line + 1
+
+        similarity = _calculate_block_similarity(
+            content_lines[start_line:end_line + 1],
+            find_lines
+        )
+
+        if similarity >= 0.0:  # SINGLE_CANDIDATE_SIMILARITY_THRESHOLD
+            match_start = sum(len(content_lines[k]) + 1 for k in range(start_line))
+            match_end = match_start + sum(
+                len(content_lines[k]) + (1 if k < end_line else 0)
+                for k in range(start_line, end_line + 1)
+            )
+            yield content[match_start:match_end]
+        return
+
+    # 多个候选:选择相似度最高的
+    best_match = None
+    max_similarity = -1
+
+    for start_line, end_line in candidates:
+        similarity = _calculate_block_similarity(
+            content_lines[start_line:end_line + 1],
+            find_lines
+        )
+
+        if similarity > max_similarity:
+            max_similarity = similarity
+            best_match = (start_line, end_line)
+
+    if max_similarity >= 0.3 and best_match:  # MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD
+        start_line, end_line = best_match
+        match_start = sum(len(content_lines[k]) + 1 for k in range(start_line))
+        match_end = match_start + sum(
+            len(content_lines[k]) + (1 if k < end_line else 0)
+            for k in range(start_line, end_line + 1)
+        )
+        yield content[match_start:match_end]
+
+
+def _calculate_block_similarity(content_block: list, find_block: list) -> float:
+    """计算块相似度(使用 Levenshtein 距离)"""
+    actual_size = len(content_block)
+    find_size = len(find_block)
+    lines_to_check = min(find_size - 2, actual_size - 2)
+
+    if lines_to_check <= 0:
+        return 1.0
+
+    similarity = 0.0
+    for j in range(1, min(find_size - 1, actual_size - 1)):
+        content_line = content_block[j].strip()
+        find_line = find_block[j].strip()
+        max_len = max(len(content_line), len(find_line))
+
+        if max_len == 0:
+            continue
+
+        distance = _levenshtein(content_line, find_line)
+        similarity += (1 - distance / max_len) / lines_to_check
+
+    return similarity
+
+
+def _levenshtein(a: str, b: str) -> int:
+    """Levenshtein 距离算法"""
+    if not a:
+        return len(b)
+    if not b:
+        return len(a)
+
+    matrix = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
+
+    for i in range(len(a) + 1):
+        matrix[i][0] = i
+    for j in range(len(b) + 1):
+        matrix[0][j] = j
+
+    for i in range(1, len(a) + 1):
+        for j in range(1, len(b) + 1):
+            cost = 0 if a[i - 1] == b[j - 1] else 1
+            matrix[i][j] = min(
+                matrix[i - 1][j] + 1,      # 删除
+                matrix[i][j - 1] + 1,      # 插入
+                matrix[i - 1][j - 1] + cost  # 替换
+            )
+
+    return matrix[len(a)][len(b)]
+
+
+# 4. WhitespaceNormalizedReplacer - 空白归一化
+def whitespace_normalized_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """空白归一化匹配(所有空白序列归一化为单个空格)"""
+    def normalize_ws(text: str) -> str:
+        return re.sub(r'\s+', ' ', text).strip()
+
+    normalized_find = normalize_ws(find)
+    lines = content.split('\n')
+
+    # 单行匹配
+    for line in lines:
+        if normalize_ws(line) == normalized_find:
+            yield line
+            continue
+
+        # 子串匹配
+        if normalized_find in normalize_ws(line):
+            words = find.strip().split()
+            if words:
+                pattern = r'\s+'.join(re.escape(word) for word in words)
+                match = re.search(pattern, line)
+                if match:
+                    yield match.group(0)
+
+    # 多行匹配
+    find_lines = find.split('\n')
+    if len(find_lines) > 1:
+        for i in range(len(lines) - len(find_lines) + 1):
+            block = lines[i:i + len(find_lines)]
+            if normalize_ws('\n'.join(block)) == normalized_find:
+                yield '\n'.join(block)
+
+
+# 5. IndentationFlexibleReplacer - 灵活缩进匹配
+def indentation_flexible_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """移除缩进后匹配"""
+    def remove_indentation(text: str) -> str:
+        lines = text.split('\n')
+        non_empty = [line for line in lines if line.strip()]
+
+        if not non_empty:
+            return text
+
+        min_indent = min(len(line) - len(line.lstrip()) for line in non_empty)
+        return '\n'.join(
+            line[min_indent:] if line.strip() else line
+            for line in lines
+        )
+
+    normalized_find = remove_indentation(find)
+    content_lines = content.split('\n')
+    find_lines = find.split('\n')
+
+    for i in range(len(content_lines) - len(find_lines) + 1):
+        block = '\n'.join(content_lines[i:i + len(find_lines)])
+        if remove_indentation(block) == normalized_find:
+            yield block
+
+
+# 6. EscapeNormalizedReplacer - 转义序列归一化
+def escape_normalized_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """反转义后匹配"""
+    def unescape_string(s: str) -> str:
+        replacements = {
+            r'\n': '\n',
+            r'\t': '\t',
+            r'\r': '\r',
+            r"\'": "'",
+            r'\"': '"',
+            r'\`': '`',
+            r'\\': '\\',
+            r'\$': '$',
+        }
+        result = s
+        for escaped, unescaped in replacements.items():
+            result = result.replace(escaped, unescaped)
+        return result
+
+    unescaped_find = unescape_string(find)
+
+    # 直接匹配
+    if unescaped_find in content:
+        yield unescaped_find
+
+    # 尝试反转义后匹配
+    lines = content.split('\n')
+    find_lines = unescaped_find.split('\n')
+
+    for i in range(len(lines) - len(find_lines) + 1):
+        block = '\n'.join(lines[i:i + len(find_lines)])
+        if unescape_string(block) == unescaped_find:
+            yield block
+
+
+# 7. TrimmedBoundaryReplacer - 边界空白裁剪
+def trimmed_boundary_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """裁剪边界空白后匹配"""
+    trimmed_find = find.strip()
+
+    if trimmed_find == find:
+        return  # 已经是 trimmed,无需尝试
+
+    # 尝试匹配 trimmed 版本
+    if trimmed_find in content:
+        yield trimmed_find
+
+    # 尝试块匹配
+    lines = content.split('\n')
+    find_lines = find.split('\n')
+
+    for i in range(len(lines) - len(find_lines) + 1):
+        block = '\n'.join(lines[i:i + len(find_lines)])
+        if block.strip() == trimmed_find:
+            yield block
+
+
+# 8. ContextAwareReplacer - 上下文感知匹配
+def context_aware_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """基于上下文(首尾行)匹配,允许中间行有差异"""
+    find_lines = find.split('\n')
+    if find_lines and find_lines[-1] == '':
+        find_lines.pop()
+
+    if len(find_lines) < 3:
+        return
+
+    content_lines = content.split('\n')
+    first_line = find_lines[0].strip()
+    last_line = find_lines[-1].strip()
+
+    # 查找首尾匹配的块
+    for i in range(len(content_lines)):
+        if content_lines[i].strip() != first_line:
+            continue
+
+        for j in range(i + 2, len(content_lines)):
+            if content_lines[j].strip() == last_line:
+                block_lines = content_lines[i:j + 1]
+
+                # 检查块长度是否匹配
+                if len(block_lines) == len(find_lines):
+                    # 计算中间行匹配率
+                    matching_lines = 0
+                    total_non_empty = 0
+
+                    for k in range(1, len(block_lines) - 1):
+                        block_line = block_lines[k].strip()
+                        find_line = find_lines[k].strip()
+
+                        if block_line or find_line:
+                            total_non_empty += 1
+                            if block_line == find_line:
+                                matching_lines += 1
+
+                    # 至少 50% 的中间行匹配
+                    if total_non_empty == 0 or matching_lines / total_non_empty >= 0.5:
+                        yield '\n'.join(block_lines)
+                        break
+                break
+
+
+# 9. MultiOccurrenceReplacer - 多次出现匹配
+def multi_occurrence_replacer(content: str, find: str) -> Generator[str, None, None]:
+    """yield 所有精确匹配,用于 replace_all"""
+    start_index = 0
+    while True:
+        index = content.find(find, start_index)
+        if index == -1:
+            break
+        yield find
+        start_index = index + len(find)
+
+
+# ============================================================================
+# 辅助函数
+# ============================================================================
+
+def _create_diff(filepath: str, old_content: str, new_content: str) -> str:
+    """生成 unified diff"""
+    old_lines = old_content.splitlines(keepends=True)
+    new_lines = new_content.splitlines(keepends=True)
+
+    diff_lines = list(difflib.unified_diff(
+        old_lines,
+        new_lines,
+        fromfile=f"a/{filepath}",
+        tofile=f"b/{filepath}",
+        lineterm=''
+    ))
+
+    if not diff_lines:
+        return "(无变更)"
+
+    return ''.join(diff_lines)

+ 108 - 0
agent/tools/builtin/file/glob.py

@@ -0,0 +1,108 @@
+"""
+Glob Tool - 文件模式匹配工具
+
+参考:vendor/opencode/packages/opencode/src/tool/glob.ts
+
+核心功能:
+- 使用 glob 模式匹配文件
+- 按修改时间排序
+- 限制返回数量
+"""
+
+import glob as glob_module
+from pathlib import Path
+from typing import Optional
+
+from agent.tools import tool, ToolResult, ToolContext
+
+# 常量
+LIMIT = 100  # 最大返回数量(参考 opencode glob.ts:35)
+
+
+@tool(description="使用 glob 模式匹配文件", hidden_params=["context"])
+async def glob_files(
+    pattern: str,
+    path: Optional[str] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    使用 glob 模式匹配文件
+
+    参考 OpenCode 实现
+
+    Args:
+        pattern: glob 模式(如 "*.py", "src/**/*.ts")
+        path: 搜索目录(默认当前目录)
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 匹配的文件列表
+    """
+    # 确定搜索路径
+    search_path = Path(path) if path else Path.cwd()
+    if not search_path.is_absolute():
+        search_path = Path.cwd() / search_path
+
+    if not search_path.exists():
+        return ToolResult(
+            title="目录不存在",
+            output=f"搜索目录不存在: {path}",
+            error="Directory not found"
+        )
+
+    # 执行 glob 搜索
+    try:
+        # 使用 pathlib 的 glob(支持 ** 递归)
+        if "**" in pattern:
+            matches = list(search_path.glob(pattern))
+        else:
+            # 使用标准 glob(更快)
+            pattern_path = search_path / pattern
+            matches = [Path(p) for p in glob_module.glob(str(pattern_path))]
+
+        # 过滤掉目录,只保留文件
+        file_matches = [m for m in matches if m.is_file()]
+
+        # 按修改时间排序(参考 opencode:47-56)
+        file_matches_with_mtime = []
+        for file_path in file_matches:
+            try:
+                mtime = file_path.stat().st_mtime
+                file_matches_with_mtime.append((file_path, mtime))
+            except Exception:
+                file_matches_with_mtime.append((file_path, 0))
+
+        # 按修改时间降序排序(最新的在前)
+        file_matches_with_mtime.sort(key=lambda x: x[1], reverse=True)
+
+        # 限制数量
+        truncated = len(file_matches_with_mtime) > LIMIT
+        file_matches_with_mtime = file_matches_with_mtime[:LIMIT]
+
+        # 格式化输出
+        if not file_matches_with_mtime:
+            output = "未找到匹配的文件"
+        else:
+            file_paths = [str(f[0]) for f in file_matches_with_mtime]
+            output = "\n".join(file_paths)
+
+            if truncated:
+                output += f"\n\n(结果已截断。考虑使用更具体的路径或模式。)"
+
+        return ToolResult(
+            title=f"匹配: {pattern}",
+            output=output,
+            metadata={
+                "count": len(file_matches_with_mtime),
+                "truncated": truncated,
+                "pattern": pattern,
+                "search_path": str(search_path)
+            }
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="Glob 错误",
+            output=f"glob 匹配失败: {str(e)}",
+            error=str(e)
+        )

+ 216 - 0
agent/tools/builtin/file/grep.py

@@ -0,0 +1,216 @@
+"""
+Grep Tool - 内容搜索工具
+
+参考:vendor/opencode/packages/opencode/src/tool/grep.ts
+
+核心功能:
+- 在文件中搜索正则表达式模式
+- 支持文件类型过滤
+- 按修改时间排序结果
+"""
+
+import re
+import subprocess
+from pathlib import Path
+from typing import Optional, List, Tuple
+
+from agent.tools import tool, ToolResult, ToolContext
+
+# 常量
+LIMIT = 100  # 最大返回匹配数(参考 opencode grep.ts:107)
+MAX_LINE_LENGTH = 2000  # 最大行长度(参考 opencode grep.ts:10)
+
+
+@tool(description="在文件内容中搜索模式", hidden_params=["context"])
+async def grep_content(
+    pattern: str,
+    path: Optional[str] = None,
+    include: Optional[str] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    在文件中搜索正则表达式模式
+
+    参考 OpenCode 实现
+
+    优先使用 ripgrep(如果可用),否则使用 Python 实现。
+
+    Args:
+        pattern: 正则表达式模式
+        path: 搜索目录(默认当前目录)
+        include: 文件模式(如 "*.py", "*.{ts,tsx}")
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 搜索结果
+    """
+    # 确定搜索路径
+    search_path = Path(path) if path else Path.cwd()
+    if not search_path.is_absolute():
+        search_path = Path.cwd() / search_path
+
+    if not search_path.exists():
+        return ToolResult(
+            title="目录不存在",
+            output=f"搜索目录不存在: {path}",
+            error="Directory not found"
+        )
+
+    # 尝试使用 ripgrep
+    try:
+        matches = await _ripgrep_search(pattern, search_path, include)
+    except Exception:
+        # ripgrep 不可用,使用 Python 实现
+        matches = await _python_search(pattern, search_path, include)
+
+    # 按修改时间排序(参考 opencode:105)
+    matches_with_mtime = []
+    for file_path, line_num, line_text in matches:
+        try:
+            mtime = file_path.stat().st_mtime
+            matches_with_mtime.append((file_path, line_num, line_text, mtime))
+        except Exception:
+            matches_with_mtime.append((file_path, line_num, line_text, 0))
+
+    matches_with_mtime.sort(key=lambda x: x[3], reverse=True)
+
+    # 限制数量
+    truncated = len(matches_with_mtime) > LIMIT
+    matches_with_mtime = matches_with_mtime[:LIMIT]
+
+    # 格式化输出(参考 opencode:118-133)
+    if not matches_with_mtime:
+        output = "未找到匹配"
+    else:
+        output = f"找到 {len(matches_with_mtime)} 个匹配\n"
+
+        current_file = None
+        for file_path, line_num, line_text, _ in matches_with_mtime:
+            if current_file != file_path:
+                if current_file is not None:
+                    output += "\n"
+                current_file = file_path
+                output += f"\n{file_path}:\n"
+
+            # 截断过长的行
+            if len(line_text) > MAX_LINE_LENGTH:
+                line_text = line_text[:MAX_LINE_LENGTH] + "..."
+
+            output += f"  Line {line_num}: {line_text}\n"
+
+        if truncated:
+            output += "\n(结果已截断。考虑使用更具体的路径或模式。)"
+
+    return ToolResult(
+        title=f"搜索: {pattern}",
+        output=output,
+        metadata={
+            "matches": len(matches_with_mtime),
+            "truncated": truncated,
+            "pattern": pattern
+        }
+    )
+
+
+async def _ripgrep_search(
+    pattern: str,
+    search_path: Path,
+    include: Optional[str]
+) -> List[Tuple[Path, int, str]]:
+    """
+    使用 ripgrep 搜索
+
+    参考 OpenCode 实现
+    """
+    args = [
+        "rg",
+        "-nH",  # 显示行号和文件名
+        "--hidden",
+        "--follow",
+        "--no-messages",
+        "--field-match-separator=|",
+        "--regexp", pattern
+    ]
+
+    if include:
+        args.extend(["--glob", include])
+
+    args.append(str(search_path))
+
+    # 执行 ripgrep
+    process = await subprocess.create_subprocess_exec(
+        *args,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE
+    )
+
+    stdout, stderr = await process.communicate()
+    exit_code = process.returncode
+
+    # Exit codes: 0 = matches, 1 = no matches, 2 = errors
+    if exit_code == 1:
+        return []
+
+    if exit_code != 0 and exit_code != 2:
+        raise RuntimeError(f"ripgrep failed: {stderr.decode()}")
+
+    # 解析输出
+    matches = []
+    for line in stdout.decode('utf-8', errors='replace').strip().split('\n'):
+        if not line:
+            continue
+
+        parts = line.split('|', 2)
+        if len(parts) < 3:
+            continue
+
+        file_path_str, line_num_str, line_text = parts
+        matches.append((
+            Path(file_path_str),
+            int(line_num_str),
+            line_text
+        ))
+
+    return matches
+
+
+async def _python_search(
+    pattern: str,
+    search_path: Path,
+    include: Optional[str]
+) -> List[Tuple[Path, int, str]]:
+    """
+    使用 Python 正则实现搜索(fallback)
+    """
+    try:
+        regex = re.compile(pattern)
+    except Exception as e:
+        raise ValueError(f"无效的正则表达式: {e}")
+
+    matches = []
+
+    # 确定要搜索的文件
+    if include:
+        # 简单的 glob 匹配
+        import glob
+        file_pattern = str(search_path / "**" / include)
+        files = [Path(f) for f in glob.glob(file_pattern, recursive=True)]
+    else:
+        # 搜索所有文本文件
+        files = [f for f in search_path.rglob("*") if f.is_file()]
+
+    # 搜索文件内容
+    for file_path in files:
+        try:
+            with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
+                for line_num, line in enumerate(f, 1):
+                    if regex.search(line):
+                        matches.append((file_path, line_num, line.rstrip('\n')))
+
+                    # 限制数量避免过多搜索
+                    if len(matches) >= LIMIT * 2:
+                        return matches
+        except Exception:
+            continue
+
+    return matches

+ 319 - 0
agent/tools/builtin/file/read.py

@@ -0,0 +1,319 @@
+"""
+Read Tool - 文件读取工具
+
+参考 OpenCode read.ts 完整实现。
+
+核心功能:
+- 支持文本文件、图片、PDF
+- 分页读取(offset/limit)
+- 二进制文件检测
+- 行长度和字节限制
+"""
+
+import os
+import base64
+import mimetypes
+from pathlib import Path
+from typing import Optional
+from urllib.parse import urlparse
+
+import httpx
+
+from agent.tools import tool, ToolResult, ToolContext
+
+# 常量(参考 opencode)
+DEFAULT_READ_LIMIT = 2000
+MAX_LINE_LENGTH = 2000
+MAX_BYTES = 50 * 1024  # 50KB
+
+
+@tool(description="读取文件内容,支持文本文件、图片、PDF 等多种格式,也支持 HTTP/HTTPS URL", hidden_params=["context"])
+async def read_file(
+    file_path: str,
+    offset: int = 0,
+    limit: int = DEFAULT_READ_LIMIT,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    读取文件内容
+
+    参考 OpenCode 实现
+
+    Args:
+        file_path: 文件路径(绝对路径、相对路径或 HTTP/HTTPS URL)
+        offset: 起始行号(从 0 开始)
+        limit: 读取行数(默认 2000 行)
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 文件内容
+    """
+    # 检测是否为 HTTP/HTTPS URL
+    parsed = urlparse(file_path)
+    if parsed.scheme in ("http", "https"):
+        return await _read_from_url(file_path)
+
+    # 解析路径
+    path = Path(file_path)
+    if not path.is_absolute():
+        path = Path.cwd() / path
+
+    # 检查文件是否存在
+    if not path.exists():
+        # 尝试提供建议(参考 opencode:44-60)
+        parent_dir = path.parent
+        if parent_dir.exists():
+            candidates = [
+                f for f in parent_dir.iterdir()
+                if path.name.lower() in f.name.lower() or f.name.lower() in path.name.lower()
+            ][:3]
+
+            if candidates:
+                suggestions = "\n".join(str(c) for c in candidates)
+                return ToolResult(
+                    title=f"文件未找到: {path.name}",
+                    output=f"文件不存在: {file_path}\n\n你是否想要:\n{suggestions}",
+                    error="File not found"
+                )
+
+        return ToolResult(
+            title="文件未找到",
+            output=f"文件不存在: {file_path}",
+            error="File not found"
+        )
+
+    # 检测文件类型
+    mime_type, _ = mimetypes.guess_type(str(path))
+    mime_type = mime_type or ""
+
+    # 图片文件(参考 opencode:66-91)
+    if mime_type.startswith("image/") and mime_type not in ["image/svg+xml", "image/vnd.fastbidsheet"]:
+        try:
+            raw = path.read_bytes()
+            b64_data = base64.b64encode(raw).decode("ascii")
+            return ToolResult(
+                title=path.name,
+                output=f"图片文件: {path.name} (MIME: {mime_type}, {len(raw)} bytes)",
+                metadata={"mime_type": mime_type, "truncated": False},
+                images=[{
+                    "type": "base64",
+                    "media_type": mime_type,
+                    "data": b64_data,
+                }],
+            )
+        except Exception as e:
+            return ToolResult(
+                title=path.name,
+                output=f"图片文件读取失败: {path.name}: {e}",
+                error=str(e),
+            )
+
+    # PDF 文件
+    if mime_type == "application/pdf":
+        return ToolResult(
+            title=path.name,
+            output=f"PDF 文件: {path.name}",
+            metadata={"mime_type": mime_type, "truncated": False}
+        )
+
+    # 二进制文件检测(参考 opencode:156-211)
+    if _is_binary_file(path):
+        return ToolResult(
+            title="二进制文件",
+            output=f"无法读取二进制文件: {path.name}",
+            error="Binary file"
+        )
+
+    # 读取文本文件(参考 opencode:96-143)
+    try:
+        with open(path, 'r', encoding='utf-8') as f:
+            lines = f.readlines()
+
+        total_lines = len(lines)
+        end_line = min(offset + limit, total_lines)
+
+        # 截取行并处理长度限制
+        output_lines = []
+        total_bytes = 0
+        truncated_by_bytes = False
+
+        for i in range(offset, end_line):
+            line = lines[i].rstrip('\n\r')
+
+            # 行长度限制(参考 opencode:104)
+            if len(line) > MAX_LINE_LENGTH:
+                line = line[:MAX_LINE_LENGTH] + "..."
+
+            # 字节限制(参考 opencode:105-112)
+            line_bytes = len(line.encode('utf-8')) + (1 if output_lines else 0)
+            if total_bytes + line_bytes > MAX_BYTES:
+                truncated_by_bytes = True
+                break
+
+            output_lines.append(line)
+            total_bytes += line_bytes
+
+        # 格式化输出(参考 opencode:114-134)
+        formatted = []
+        for idx, line in enumerate(output_lines):
+            line_num = offset + idx + 1
+            formatted.append(f"{line_num:5d}| {line}")
+
+        output = "<file>\n" + "\n".join(formatted)
+
+        last_read_line = offset + len(output_lines)
+        has_more = total_lines > last_read_line
+        truncated = has_more or truncated_by_bytes
+
+        # 添加提示
+        if truncated_by_bytes:
+            output += f"\n\n(输出在 {MAX_BYTES} 字节处被截断。使用 'offset' 参数读取第 {last_read_line} 行之后的内容)"
+        elif has_more:
+            output += f"\n\n(文件还有更多内容。使用 'offset' 参数读取第 {last_read_line} 行之后的内容)"
+        else:
+            output += f"\n\n(文件结束 - 共 {total_lines} 行)"
+
+        output += "\n</file>"
+
+        # 预览(前 20 行)
+        preview = "\n".join(output_lines[:20])
+
+        return ToolResult(
+            title=path.name,
+            output=output,
+            metadata={
+                "preview": preview,
+                "truncated": truncated,
+                "total_lines": total_lines,
+                "read_lines": len(output_lines)
+            }
+        )
+
+    except UnicodeDecodeError:
+        return ToolResult(
+            title="编码错误",
+            output=f"无法解码文件(非 UTF-8 编码): {path.name}",
+            error="Encoding error"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="读取错误",
+            output=f"读取文件时出错: {str(e)}",
+            error=str(e)
+        )
+
+
+def _is_binary_file(path: Path) -> bool:
+    """
+    检测是否为二进制文件
+
+    参考 OpenCode 实现
+    """
+    # 常见二进制扩展名
+    binary_exts = {
+        '.zip', '.tar', '.gz', '.exe', '.dll', '.so', '.class',
+        '.jar', '.war', '.7z', '.doc', '.docx', '.xls', '.xlsx',
+        '.ppt', '.pptx', '.odt', '.ods', '.odp', '.bin', '.dat',
+        '.obj', '.o', '.a', '.lib', '.wasm', '.pyc', '.pyo'
+    }
+
+    if path.suffix.lower() in binary_exts:
+        return True
+
+    # 检查文件内容
+    try:
+        file_size = path.stat().st_size
+        if file_size == 0:
+            return False
+
+        # 读取前 4KB
+        buffer_size = min(4096, file_size)
+        with open(path, 'rb') as f:
+            buffer = f.read(buffer_size)
+
+        # 检测 null 字节
+        if b'\x00' in buffer:
+            return True
+
+        # 统计非打印字符(参考 opencode:202-210)
+        non_printable = 0
+        for byte in buffer:
+            if byte < 9 or (13 < byte < 32):
+                non_printable += 1
+
+        # 如果超过 30% 是非打印字符,认为是二进制
+        return non_printable / len(buffer) > 0.3
+
+    except Exception:
+        return False
+
+
+async def _read_from_url(url: str) -> ToolResult:
+    """
+    从 HTTP/HTTPS URL 读取文件内容。
+
+    主要用于图片等多媒体资源,自动转换为 base64。
+    """
+    try:
+        async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
+            response = await client.get(url)
+            response.raise_for_status()
+
+            content_type = response.headers.get("content-type", "")
+            raw = response.content
+
+            # 从 URL 提取文件名
+            from urllib.parse import urlparse
+            parsed = urlparse(url)
+            filename = Path(parsed.path).name or "downloaded_file"
+
+            # 图片文件
+            if content_type.startswith("image/") or any(url.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]):
+                mime_type = content_type.split(";")[0] if content_type else "image/jpeg"
+                b64_data = base64.b64encode(raw).decode("ascii")
+                return ToolResult(
+                    title=filename,
+                    output=f"图片文件: {filename} (URL: {url}, MIME: {mime_type}, {len(raw)} bytes)",
+                    metadata={"mime_type": mime_type, "url": url, "truncated": False},
+                    images=[{
+                        "type": "base64",
+                        "media_type": mime_type,
+                        "data": b64_data,
+                    }],
+                )
+
+            # 文本文件
+            if content_type.startswith("text/") or content_type == "application/json":
+                text = raw.decode("utf-8", errors="replace")
+                lines = text.split("\n")
+                preview = "\n".join(lines[:20])
+                return ToolResult(
+                    title=filename,
+                    output=f"<file>\n{text}\n</file>",
+                    metadata={
+                        "preview": preview,
+                        "url": url,
+                        "mime_type": content_type,
+                        "total_lines": len(lines),
+                    }
+                )
+
+            # 其他二进制文件
+            return ToolResult(
+                title=filename,
+                output=f"二进制文件: {filename} (URL: {url}, {len(raw)} bytes)",
+                metadata={"url": url, "mime_type": content_type, "size": len(raw)}
+            )
+
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="HTTP 错误",
+            output=f"无法下载文件: {url}\nHTTP {e.response.status_code}: {e.response.reason_phrase}",
+            error=str(e)
+        )
+    except Exception as e:
+        return ToolResult(
+            title="下载失败",
+            output=f"无法从 URL 读取文件: {url}\n错误: {str(e)}",
+            error=str(e)
+        )

+ 129 - 0
agent/tools/builtin/file/write.py

@@ -0,0 +1,129 @@
+"""
+Write Tool - 文件写入工具
+
+参考:vendor/opencode/packages/opencode/src/tool/write.ts
+
+核心功能:
+- 创建新文件或覆盖现有文件
+- 支持追加模式(append)
+- 生成 diff 预览
+"""
+
+from pathlib import Path
+from typing import Optional
+import difflib
+
+from agent.tools import tool, ToolResult, ToolContext
+
+
+@tool(description="写入文件内容(创建新文件、覆盖现有文件或追加内容)", hidden_params=["context"])
+async def write_file(
+    file_path: str,
+    content: str,
+    append: bool = False,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    写入文件
+
+    参考 OpenCode 实现,并添加追加模式支持
+
+    Args:
+        file_path: 文件路径
+        content: 文件内容
+        append: 是否追加模式(默认 False,覆盖写入)
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 写入结果
+    """
+    # 解析路径
+    path = Path(file_path)
+    if not path.is_absolute():
+        path = Path.cwd() / path
+
+    # 检查是否为目录
+    if path.exists() and path.is_dir():
+        return ToolResult(
+            title="路径错误",
+            output=f"路径是目录,不是文件: {file_path}",
+            error="Path is a directory"
+        )
+
+    # 读取旧内容(如果存在)
+    existed = path.exists()
+    old_content = ""
+    if existed:
+        try:
+            with open(path, 'r', encoding='utf-8') as f:
+                old_content = f.read()
+        except Exception:
+            old_content = ""
+
+    # 确定最终内容
+    if append and existed:
+        new_content = old_content + content
+    else:
+        new_content = content
+
+    # 生成 diff
+    if existed and old_content:
+        diff = _create_diff(str(path), old_content, new_content)
+    else:
+        diff = f"(新建文件: {path.name})"
+
+    # 确保父目录存在
+    path.parent.mkdir(parents=True, exist_ok=True)
+
+    # 写入文件
+    try:
+        with open(path, 'w', encoding='utf-8') as f:
+            f.write(new_content)
+    except Exception as e:
+        return ToolResult(
+            title="写入失败",
+            output=f"无法写入文件: {str(e)}",
+            error=str(e)
+        )
+
+    # 统计
+    lines = new_content.count('\n')
+
+    # 构建操作描述
+    if append and existed:
+        operation = "追加内容到"
+    elif existed:
+        operation = "覆盖"
+    else:
+        operation = "创建"
+
+    return ToolResult(
+        title=path.name,
+        output=f"文件写入成功 ({operation})\n\n{diff}",
+        metadata={
+            "existed": existed,
+            "append": append,
+            "lines": lines,
+            "diff": diff
+        },
+        long_term_memory=f"{operation}文件 {path.name}"
+    )
+
+
+def _create_diff(filepath: str, old_content: str, new_content: str) -> str:
+    """生成 unified diff"""
+    old_lines = old_content.splitlines(keepends=True)
+    new_lines = new_content.splitlines(keepends=True)
+
+    diff_lines = list(difflib.unified_diff(
+        old_lines,
+        new_lines,
+        fromfile=f"a/{filepath}",
+        tofile=f"b/{filepath}",
+        lineterm=''
+    ))
+
+    if not diff_lines:
+        return "(无变更)"
+
+    return ''.join(diff_lines)

+ 108 - 0
agent/tools/builtin/glob_tool.py

@@ -0,0 +1,108 @@
+"""
+Glob Tool - 文件模式匹配工具
+
+参考:vendor/opencode/packages/opencode/src/tool/glob.ts
+
+核心功能:
+- 使用 glob 模式匹配文件
+- 按修改时间排序
+- 限制返回数量
+"""
+
+import glob as glob_module
+from pathlib import Path
+from typing import Optional
+
+from agent.tools import tool, ToolResult, ToolContext
+
+# 常量
+LIMIT = 100  # 最大返回数量(参考 opencode glob.ts:35)
+
+
+@tool(description="使用 glob 模式匹配文件", hidden_params=["context"])
+async def glob_files(
+    pattern: str,
+    path: Optional[str] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    使用 glob 模式匹配文件
+
+    参考 OpenCode 实现
+
+    Args:
+        pattern: glob 模式(如 "*.py", "src/**/*.ts")
+        path: 搜索目录(默认当前目录)
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 匹配的文件列表
+    """
+    # 确定搜索路径
+    search_path = Path(path) if path else Path.cwd()
+    if not search_path.is_absolute():
+        search_path = Path.cwd() / search_path
+
+    if not search_path.exists():
+        return ToolResult(
+            title="目录不存在",
+            output=f"搜索目录不存在: {path}",
+            error="Directory not found"
+        )
+
+    # 执行 glob 搜索
+    try:
+        # 使用 pathlib 的 glob(支持 ** 递归)
+        if "**" in pattern:
+            matches = list(search_path.glob(pattern))
+        else:
+            # 使用标准 glob(更快)
+            pattern_path = search_path / pattern
+            matches = [Path(p) for p in glob_module.glob(str(pattern_path))]
+
+        # 过滤掉目录,只保留文件
+        file_matches = [m for m in matches if m.is_file()]
+
+        # 按修改时间排序(参考 opencode:47-56)
+        file_matches_with_mtime = []
+        for file_path in file_matches:
+            try:
+                mtime = file_path.stat().st_mtime
+                file_matches_with_mtime.append((file_path, mtime))
+            except Exception:
+                file_matches_with_mtime.append((file_path, 0))
+
+        # 按修改时间降序排序(最新的在前)
+        file_matches_with_mtime.sort(key=lambda x: x[1], reverse=True)
+
+        # 限制数量
+        truncated = len(file_matches_with_mtime) > LIMIT
+        file_matches_with_mtime = file_matches_with_mtime[:LIMIT]
+
+        # 格式化输出
+        if not file_matches_with_mtime:
+            output = "未找到匹配的文件"
+        else:
+            file_paths = [str(f[0]) for f in file_matches_with_mtime]
+            output = "\n".join(file_paths)
+
+            if truncated:
+                output += f"\n\n(结果已截断。考虑使用更具体的路径或模式。)"
+
+        return ToolResult(
+            title=f"匹配: {pattern}",
+            output=output,
+            metadata={
+                "count": len(file_matches_with_mtime),
+                "truncated": truncated,
+                "pattern": pattern,
+                "search_path": str(search_path)
+            }
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="Glob 错误",
+            output=f"glob 匹配失败: {str(e)}",
+            error=str(e)
+        )

+ 541 - 0
agent/tools/builtin/knowledge.py

@@ -0,0 +1,541 @@
+"""
+知识管理工具 - KnowHub API 封装
+
+所有工具通过 HTTP API 调用 KnowHub Server。
+"""
+
+import os
+import logging
+import httpx
+from typing import List, Dict, Optional, Any
+from agent.tools import tool, ToolResult, ToolContext
+
+logger = logging.getLogger(__name__)
+
+# KnowHub Server API 地址
+KNOWHUB_API = os.getenv("KNOWHUB_API", "http://localhost:8000")
+
+
+@tool(hidden_params=["context"])
+async def knowledge_search(
+    query: str,
+    top_k: int = 5,
+    min_score: int = 3,
+    types: Optional[List[str]] = None,
+    owner: Optional[str] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    检索知识(两阶段:语义路由 + 质量精排)
+
+    Args:
+        query: 搜索查询(任务描述)
+        top_k: 返回数量(默认 5)
+        min_score: 最低评分过滤(默认 3)
+        types: 按类型过滤(user_profile/strategy/tool/usecase/definition/plan)
+        owner: 按所有者过滤(可选)
+        context: 工具上下文
+
+    Returns:
+        相关知识列表
+    """
+    try:
+        params = {
+            "q": query,
+            "top_k": top_k,
+            "min_score": min_score,
+        }
+        if types:
+            params["types"] = ",".join(types)
+        if owner:
+            params["owner"] = owner
+
+        async with httpx.AsyncClient(timeout=60.0) as client:
+            response = await client.get(f"{KNOWHUB_API}/api/knowledge/search", params=params)
+            response.raise_for_status()
+            data = response.json()
+
+        results = data.get("results", [])
+        count = data.get("count", 0)
+
+        if not results:
+            return ToolResult(
+                title="🔍 未找到相关知识",
+                output=f"查询: {query}\n\n知识库中暂无相关的高质量知识。",
+                long_term_memory=f"知识检索: 未找到相关知识 - {query[:50]}"
+            )
+
+        # 格式化输出
+        output_lines = [f"查询: {query}\n", f"找到 {count} 条相关知识:\n"]
+
+        for idx, item in enumerate(results, 1):
+            eval_data = item.get("eval", {})
+            score = eval_data.get("score", 3)
+            output_lines.append(f"\n### {idx}. [{item['id']}] (⭐ {score})")
+            output_lines.append(f"**任务**: {item['task'][:150]}...")
+            output_lines.append(f"**内容**: {item['content'][:200]}...")
+
+        return ToolResult(
+            title="✅ 知识检索成功",
+            output="\n".join(output_lines),
+            long_term_memory=f"知识检索: 找到 {count} 条相关知识 - {query[:50]}",
+            metadata={
+                "count": count,
+                "knowledge_ids": [item["id"] for item in results],
+                "items": results
+            }
+        )
+
+    except Exception as e:
+        logger.error(f"知识检索失败: {e}")
+        return ToolResult(
+            title="❌ 检索失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(
+    hidden_params=["context", "owner"],
+    inject_params={
+        "owner": lambda ctx: ctx.get("knowledge_config", {}).get("owner") if ctx else None,
+        "tags": lambda ctx, args: {
+            **ctx.get("knowledge_config", {}).get("default_tags", {}),
+            **(args.get("tags") or {})
+        } if ctx else args.get("tags"),
+        "scopes": lambda ctx, args: (args.get("scopes") or []) + (ctx.get("knowledge_config", {}).get("default_scopes") or []) if ctx else args.get("scopes"),
+    }
+)
+async def knowledge_save(
+    task: str,
+    content: str,
+    types: List[str],
+    tags: Optional[Dict[str, str]] = None,
+    scopes: Optional[List[str]] = None,
+    owner: Optional[str] = None,
+    resource_ids: Optional[List[str]] = None,
+    source_name: str = "",
+    source_category: str = "exp",
+    urls: List[str] = None,
+    agent_id: str = "research_agent",
+    submitted_by: str = "",
+    score: int = 3,
+    message_id: str = "",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    保存新知识
+
+    Args:
+        task: 任务描述(在什么情景下 + 要完成什么目标)
+        content: 核心内容
+        types: 知识类型标签,可选:user_profile, strategy, tool, usecase, definition, plan
+        tags: 业务标签(JSON 对象)
+        scopes: 可见范围(默认 ["org:cybertogether"])
+        owner: 所有者(默认 agent:{agent_id})
+        resource_ids: 关联的资源 ID 列表(可选)
+        source_name: 来源名称
+        source_category: 来源类别(paper/exp/skill/book)
+        urls: 参考来源链接列表
+        agent_id: 执行此调研的 agent ID
+        submitted_by: 提交者
+        score: 初始评分 1-5(默认 3)
+        message_id: 来源 Message ID
+        context: 工具上下文
+
+    Returns:
+        保存结果
+    """
+    try:
+        # 设置默认值(在 agent 代码中,不是服务器端)
+        if scopes is None:
+            scopes = ["org:cybertogether"]
+        if owner is None:
+            owner = f"agent:{agent_id}"
+
+        payload = {
+            "message_id": message_id,
+            "types": types,
+            "task": task,
+            "tags": tags or {},
+            "scopes": scopes,
+            "owner": owner,
+            "content": content,
+            "resource_ids": resource_ids or [],
+            "source": {
+                "name": source_name,
+                "category": source_category,
+                "urls": urls or [],
+                "agent_id": agent_id,
+                "submitted_by": submitted_by,
+            },
+            "eval": {
+                "score": score,
+                "helpful": 1,
+                "harmful": 0,
+                "confidence": 0.5,
+            }
+        }
+
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            response = await client.post(f"{KNOWHUB_API}/api/knowledge", json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        knowledge_id = data.get("knowledge_id", "unknown")
+
+        return ToolResult(
+            title="✅ 知识已保存",
+            output=f"知识 ID: {knowledge_id}\n\n任务:\n{task[:100]}...",
+            long_term_memory=f"保存知识: {knowledge_id} - {task[:50]}",
+            metadata={"knowledge_id": knowledge_id}
+        )
+
+    except Exception as e:
+        logger.error(f"保存知识失败: {e}")
+        return ToolResult(
+            title="❌ 保存失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(hidden_params=["context"])
+async def knowledge_update(
+    knowledge_id: str,
+    add_helpful_case: Optional[Dict] = None,
+    add_harmful_case: Optional[Dict] = None,
+    update_score: Optional[int] = None,
+    evolve_feedback: Optional[str] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    更新已有知识的评估反馈
+
+    Args:
+        knowledge_id: 知识 ID
+        add_helpful_case: 添加好用的案例
+        add_harmful_case: 添加不好用的案例
+        update_score: 更新评分(1-5)
+        evolve_feedback: 经验进化反馈(触发 LLM 重写)
+        context: 工具上下文
+
+    Returns:
+        更新结果
+    """
+    try:
+        payload = {}
+        if add_helpful_case:
+            payload["add_helpful_case"] = add_helpful_case
+        if add_harmful_case:
+            payload["add_harmful_case"] = add_harmful_case
+        if update_score is not None:
+            payload["update_score"] = update_score
+        if evolve_feedback:
+            payload["evolve_feedback"] = evolve_feedback
+
+        if not payload:
+            return ToolResult(
+                title="⚠️ 无更新",
+                output="未指定任何更新内容",
+                long_term_memory="尝试更新知识但未指定更新内容"
+            )
+
+        async with httpx.AsyncClient(timeout=60.0) as client:
+            response = await client.put(f"{KNOWHUB_API}/api/knowledge/{knowledge_id}", json=payload)
+            response.raise_for_status()
+
+        summary = []
+        if add_helpful_case:
+            summary.append("添加 helpful 案例")
+        if add_harmful_case:
+            summary.append("添加 harmful 案例")
+        if update_score is not None:
+            summary.append(f"更新评分: {update_score}")
+        if evolve_feedback:
+            summary.append("知识进化: 基于反馈重写内容")
+
+        return ToolResult(
+            title="✅ 知识已更新",
+            output=f"知识 ID: {knowledge_id}\n\n更新内容:\n" + "\n".join(f"- {s}" for s in summary),
+            long_term_memory=f"更新知识: {knowledge_id}"
+        )
+
+    except Exception as e:
+        logger.error(f"更新知识失败: {e}")
+        return ToolResult(
+            title="❌ 更新失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(hidden_params=["context"])
+async def knowledge_batch_update(
+    feedback_list: List[Dict[str, Any]],
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    批量反馈知识的有效性
+
+    Args:
+        feedback_list: 评价列表,每个元素包含:
+            - knowledge_id: (str) 知识 ID
+            - is_effective: (bool) 是否有效
+            - feedback: (str, optional) 改进建议,若有效且有建议则触发知识进化
+        context: 工具上下文
+
+    Returns:
+        批量更新结果
+    """
+    try:
+        if not feedback_list:
+            return ToolResult(
+                title="⚠️ 反馈列表为空",
+                output="未提供任何反馈",
+                long_term_memory="批量更新知识: 反馈列表为空"
+            )
+
+        payload = {"feedback_list": feedback_list}
+
+        async with httpx.AsyncClient(timeout=120.0) as client:
+            response = await client.post(f"{KNOWHUB_API}/api/knowledge/batch_update", json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        updated = data.get("updated", 0)
+
+        return ToolResult(
+            title="✅ 批量更新完成",
+            output=f"成功更新 {updated} 条知识",
+            long_term_memory=f"批量更新知识: 成功 {updated} 条"
+        )
+
+    except Exception as e:
+        logger.error(f"列出知识失败: {e}")
+        return ToolResult(
+            title="❌ 列表失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(hidden_params=["context"])
+async def knowledge_list(
+    limit: int = 10,
+    types: Optional[List[str]] = None,
+    scopes: Optional[List[str]] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    列出已保存的知识
+
+    Args:
+        limit: 返回数量限制(默认 10)
+        types: 按类型过滤(可选)
+        scopes: 按范围过滤(可选)
+        context: 工具上下文
+
+    Returns:
+        知识列表
+    """
+    try:
+        params = {"limit": limit}
+        if types:
+            params["types"] = ",".join(types)
+        if scopes:
+            params["scopes"] = ",".join(scopes)
+
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            response = await client.get(f"{KNOWHUB_API}/api/knowledge", params=params)
+            response.raise_for_status()
+            data = response.json()
+
+        results = data.get("results", [])
+        count = data.get("count", 0)
+
+        if not results:
+            return ToolResult(
+                title="📂 知识库为空",
+                output="还没有保存任何知识",
+                long_term_memory="知识库为空"
+            )
+
+        output_lines = [f"共找到 {count} 条知识:\n"]
+        for item in results:
+            eval_data = item.get("eval", {})
+            score = eval_data.get("score", 3)
+            output_lines.append(f"- [{item['id']}] (⭐{score}) {item['task'][:60]}...")
+
+        return ToolResult(
+            title="📚 知识列表",
+            output="\n".join(output_lines),
+            long_term_memory=f"列出 {count} 条知识"
+        )
+
+    except Exception as e:
+        logger.error(f"列出知识失败: {e}")
+        return ToolResult(
+            title="❌ 列表失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(hidden_params=["context"])
+async def knowledge_slim(
+    model: str = "google/gemini-2.0-flash-001",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    知识库瘦身:调用顶级大模型,将知识库中语义相似的知识合并精简
+
+    Args:
+        model: 使用的模型(默认 gemini-2.0-flash-001)
+        context: 工具上下文
+
+    Returns:
+        瘦身结果报告
+    """
+    try:
+        async with httpx.AsyncClient(timeout=300.0) as client:
+            response = await client.post(f"{KNOWHUB_API}/api/knowledge/slim", params={"model": model})
+            response.raise_for_status()
+            data = response.json()
+
+        before = data.get("before", 0)
+        after = data.get("after", 0)
+        report = data.get("report", "")
+
+        result = f"瘦身完成:{before} → {after} 条知识"
+        if report:
+            result += f"\n{report}"
+
+        return ToolResult(
+            title="✅ 知识库瘦身完成",
+            output=result,
+            long_term_memory=f"知识库瘦身: {before} → {after} 条"
+        )
+
+    except Exception as e:
+        logger.error(f"知识库瘦身失败: {e}")
+        return ToolResult(
+            title="❌ 瘦身失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )
+
+
+# ==================== Resource 资源管理工具 ====================
+
+@tool(hidden_params=["context"])
+async def resource_save(
+    resource_id: str,
+    title: str,
+    body: str,
+    content_type: str = "text",
+    secure_body: str = "",
+    metadata: Optional[Dict[str, Any]] = None,
+    submitted_by: str = "",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    保存资源(代码片段、凭证、Cookie 等)
+
+    Args:
+        resource_id: 资源 ID(层级路径,如 "code/selenium/login" 或 "credentials/website_a")
+        title: 资源标题
+        body: 公开内容(明文存储,可搜索)
+        content_type: 内容类型(text/code/credential/cookie)
+        secure_body: 敏感内容(加密存储,需要组织密钥访问)
+        metadata: 元数据(如 {"language": "python", "acquired_at": "2026-03-06T10:00:00Z"})
+        submitted_by: 提交者
+        context: 工具上下文
+
+    Returns:
+        保存结果
+    """
+    try:
+        payload = {
+            "id": resource_id,
+            "title": title,
+            "body": body,
+            "secure_body": secure_body,
+            "content_type": content_type,
+            "metadata": metadata or {},
+            "submitted_by": submitted_by,
+        }
+
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            response = await client.post(f"{KNOWHUB_API}/api/resource", json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        return ToolResult(
+            title="✅ 资源已保存",
+            output=f"资源 ID: {resource_id}\n类型: {content_type}\n标题: {title}",
+            long_term_memory=f"保存资源: {resource_id} ({content_type})",
+            metadata={"resource_id": resource_id}
+        )
+
+    except Exception as e:
+        logger.error(f"保存资源失败: {e}")
+        return ToolResult(
+            title="❌ 保存失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(hidden_params=["context"])
+async def resource_get(
+    resource_id: str,
+    org_key: Optional[str] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    获取资源内容
+
+    Args:
+        resource_id: 资源 ID(层级路径)
+        org_key: 组织密钥(用于解密敏感内容,可选)
+        context: 工具上下文
+
+    Returns:
+        资源内容
+    """
+    try:
+        headers = {}
+        if org_key:
+            headers["X-Org-Key"] = org_key
+
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            response = await client.get(
+                f"{KNOWHUB_API}/api/resource/{resource_id}",
+                headers=headers
+            )
+            response.raise_for_status()
+            data = response.json()
+
+        output = f"资源 ID: {data['id']}\n"
+        output += f"标题: {data['title']}\n"
+        output += f"类型: {data['content_type']}\n"
+        output += f"\n公开内容:\n{data['body']}\n"
+
+        if data.get('secure_body'):
+            output += f"\n敏感内容:\n{data['secure_body']}\n"
+
+        return ToolResult(
+            title=f"📦 {data['title']}",
+            output=output,
+            metadata=data
+        )
+
+    except Exception as e:
+        logger.error(f"获取资源失败: {e}")
+        return ToolResult(
+            title="❌ 获取失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )
+

+ 418 - 0
agent/tools/builtin/sandbox.py

@@ -0,0 +1,418 @@
+"""
+Sandbox Tools (Async)
+通过 HTTP 异步调用沙盒管理服务的客户端库。
+"""
+
+import json
+import httpx
+from typing import Optional, List, Dict, Any
+
+from agent.tools import tool, ToolResult, ToolContext
+
+
+# 服务地址,可根据实际部署情况修改
+# SANDBOX_SERVER_URL = "http://192.168.100.20:9998"
+SANDBOX_SERVER_URL = "http://61.48.133.26:9998"
+
+# 默认超时时间(秒)
+DEFAULT_TIMEOUT = 300.0
+
+
+@tool(
+    hidden_params=["context"],
+    display={
+        "zh": {
+            "name": "创建沙盒环境",
+            "params": {
+                "image": "Docker 镜像",
+                "mem_limit": "内存限制",
+                "nano_cpus": "CPU 限制",
+                "ports": "端口列表",
+                "use_gpu": "启用 GPU",
+                "gpu_count": "GPU 数量"
+            }
+        },
+        "en": {
+            "name": "Create Sandbox",
+            "params": {
+                "image": "Docker image",
+                "mem_limit": "Memory limit",
+                "nano_cpus": "CPU limit",
+                "ports": "Port list",
+                "use_gpu": "Enable GPU",
+                "gpu_count": "GPU count"
+            }
+        }
+    }
+)
+async def sandbox_create_environment(
+    image: str = "agent-sandbox:latest",
+    mem_limit: str = "512m",
+    nano_cpus: int = 500000000,
+    ports: Optional[List[int]] = None,
+    use_gpu: bool = False,
+    gpu_count: int = -1,
+    server_url: str = None,
+    timeout: float = DEFAULT_TIMEOUT,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    创建一个隔离的 Docker 开发环境。
+
+    Args:
+        image: Docker 镜像名称,默认为 "agent-sandbox:latest"。
+               可以使用其他镜像如 "python:3.12-slim", "node:18-slim" 等。
+        mem_limit: 容器最大内存限制,默认为 "512m"。
+        nano_cpus: 容器最大 CPU 限制(纳秒),默认为 500000000(0.5 CPU)。
+        ports: 需要映射的端口列表,如 [8080, 3000]。
+        use_gpu: 是否启用 GPU 支持,默认为 False。需要宿主机安装 nvidia-container-toolkit。
+        gpu_count: 使用的 GPU 数量,-1 表示使用所有可用 GPU,默认为 -1。
+        server_url: 服务地址,默认使用全局配置 SANDBOX_SERVER_URL。
+        timeout: 请求超时时间(秒),默认 300 秒。
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 包含沙盒创建结果
+    """
+    url = f"{server_url or SANDBOX_SERVER_URL}/api/create_environment"
+    payload = {
+        "image": image,
+        "mem_limit": mem_limit,
+        "nano_cpus": nano_cpus,
+        "use_gpu": use_gpu,
+        "gpu_count": gpu_count
+    }
+    if ports:
+        payload["ports"] = ports
+
+    try:
+        async with httpx.AsyncClient(timeout=timeout) as client:
+            response = await client.post(url, json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        sandbox_id = data.get("sandbox_id", "")
+        port_mapping = data.get("port_mapping", {})
+        access_urls = data.get("access_urls", [])
+
+        output_parts = [f"沙盒 ID: {sandbox_id}"]
+        if port_mapping:
+            output_parts.append(f"端口映射: {json.dumps(port_mapping)}")
+        if access_urls:
+            output_parts.append(f"访问地址: {', '.join(access_urls)}")
+
+        return ToolResult(
+            title="沙盒环境创建成功",
+            output="\n".join(output_parts),
+            metadata=data
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="沙盒创建失败",
+            output=f"HTTP 错误: {e.response.status_code}",
+            error=str(e)
+        )
+    except httpx.RequestError as e:
+        return ToolResult(
+            title="沙盒创建失败",
+            output=f"网络请求失败: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(
+    hidden_params=["context"],
+    display={
+        "zh": {
+            "name": "执行沙盒命令",
+            "params": {
+                "sandbox_id": "沙盒 ID",
+                "command": "Shell 命令",
+                "is_background": "后台执行",
+                "timeout": "超时时间"
+            }
+        },
+        "en": {
+            "name": "Run Shell in Sandbox",
+            "params": {
+                "sandbox_id": "Sandbox ID",
+                "command": "Shell command",
+                "is_background": "Run in background",
+                "timeout": "Timeout"
+            }
+        }
+    }
+)
+async def sandbox_run_shell(
+    sandbox_id: str,
+    command: str,
+    is_background: bool = False,
+    timeout: int = 120,
+    server_url: str = None,
+    request_timeout: float = DEFAULT_TIMEOUT,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    在指定的沙盒中执行 Shell 命令。
+
+    Args:
+        sandbox_id: 沙盒 ID,由 create_environment 返回。
+        command: 要执行的 Shell 命令,如 "pip install flask" 或 "python app.py"。
+        is_background: 是否后台执行,默认为 False。
+            - False:前台执行,等待命令完成并返回输出
+            - True:后台执行,适合启动长期运行的服务
+        timeout: 前台命令的超时时间(秒),默认 120 秒。后台命令不受此限制。
+        server_url: 服务地址,默认使用全局配置 SANDBOX_SERVER_URL。
+        request_timeout: HTTP 请求超时时间(秒),默认 300 秒。
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 命令执行结果
+    """
+    url = f"{server_url or SANDBOX_SERVER_URL}/api/run_shell"
+    payload = {
+        "sandbox_id": sandbox_id,
+        "command": command,
+        "is_background": is_background,
+        "timeout": timeout
+    }
+
+    try:
+        async with httpx.AsyncClient(timeout=request_timeout) as client:
+            response = await client.post(url, json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        if is_background:
+            status = data.get("status", "")
+            message = data.get("message", "")
+            log_file = data.get("log_file", "")
+            output = f"状态: {status}\n消息: {message}"
+            if log_file:
+                output += f"\n日志文件: {log_file}"
+            return ToolResult(
+                title=f"后台命令已启动: {command[:50]}",
+                output=output,
+                metadata=data
+            )
+        else:
+            exit_code = data.get("exit_code", -1)
+            stdout = data.get("stdout", "")
+            stderr = data.get("stderr", "")
+
+            output_parts = []
+            if stdout:
+                output_parts.append(stdout)
+            if stderr:
+                if output_parts:
+                    output_parts.append("\n--- stderr ---")
+                output_parts.append(stderr)
+            if not output_parts:
+                output_parts.append("(命令无输出)")
+
+            success = exit_code == 0
+            title = f"命令: {command[:50]}"
+            if not success:
+                title += f" (exit code: {exit_code})"
+
+            return ToolResult(
+                title=title,
+                output="\n".join(output_parts),
+                metadata=data,
+                error=None if success else f"Command failed with exit code {exit_code}"
+            )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="命令执行失败",
+            output=f"HTTP 错误: {e.response.status_code}",
+            error=str(e)
+        )
+    except httpx.RequestError as e:
+        return ToolResult(
+            title="命令执行失败",
+            output=f"网络请求失败: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(
+    hidden_params=["context"],
+    display={
+        "zh": {
+            "name": "重建沙盒端口",
+            "params": {
+                "sandbox_id": "沙盒 ID",
+                "ports": "端口列表",
+                "mem_limit": "内存限制",
+                "nano_cpus": "CPU 限制",
+                "use_gpu": "启用 GPU",
+                "gpu_count": "GPU 数量"
+            }
+        },
+        "en": {
+            "name": "Rebuild Sandbox Ports",
+            "params": {
+                "sandbox_id": "Sandbox ID",
+                "ports": "Port list",
+                "mem_limit": "Memory limit",
+                "nano_cpus": "CPU limit",
+                "use_gpu": "Enable GPU",
+                "gpu_count": "GPU count"
+            }
+        }
+    }
+)
+async def sandbox_rebuild_with_ports(
+    sandbox_id: str,
+    ports: List[int],
+    mem_limit: str = "1g",
+    nano_cpus: int = 1000000000,
+    use_gpu: bool = False,
+    gpu_count: int = -1,
+    server_url: str = None,
+    timeout: float = DEFAULT_TIMEOUT,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    重建沙盒并应用新的端口映射。
+
+    使用场景:先创建沙盒克隆项目,阅读 README 后才知道需要暴露哪些端口,
+    此时调用此函数重建沙盒,应用正确的端口映射。
+
+    注意:重建后会返回新的 sandbox_id,后续操作需要使用新 ID。
+    容器内的所有文件(克隆的代码、安装的依赖等)都会保留。
+
+    Args:
+        sandbox_id: 当前沙盒 ID。
+        ports: 需要映射的端口列表,如 [8080, 3306, 6379]。
+        mem_limit: 容器最大内存限制,默认为 "1g"。
+        nano_cpus: 容器最大 CPU 限制(纳秒),默认为 1000000000(1 CPU)。
+        use_gpu: 是否启用 GPU 支持,默认为 False。需要宿主机安装 nvidia-container-toolkit。
+        gpu_count: 使用的 GPU 数量,-1 表示使用所有可用 GPU,默认为 -1。
+        server_url: 服务地址,默认使用全局配置 SANDBOX_SERVER_URL。
+        timeout: 请求超时时间(秒),默认 300 秒。
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 重建结果
+    """
+    url = f"{server_url or SANDBOX_SERVER_URL}/api/rebuild_with_ports"
+    payload = {
+        "sandbox_id": sandbox_id,
+        "ports": ports,
+        "mem_limit": mem_limit,
+        "nano_cpus": nano_cpus,
+        "use_gpu": use_gpu,
+        "gpu_count": gpu_count
+    }
+
+    try:
+        async with httpx.AsyncClient(timeout=timeout) as client:
+            response = await client.post(url, json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        old_id = data.get("old_sandbox_id", "")
+        new_id = data.get("new_sandbox_id", "")
+        port_mapping = data.get("port_mapping", {})
+        access_urls = data.get("access_urls", [])
+
+        output_parts = [
+            f"旧沙盒 ID: {old_id} (已销毁)",
+            f"新沙盒 ID: {new_id}"
+        ]
+        if port_mapping:
+            output_parts.append(f"端口映射: {json.dumps(port_mapping)}")
+        if access_urls:
+            output_parts.append(f"访问地址: {', '.join(access_urls)}")
+
+        return ToolResult(
+            title="沙盒重建成功",
+            output="\n".join(output_parts),
+            metadata=data
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="沙盒重建失败",
+            output=f"HTTP 错误: {e.response.status_code}",
+            error=str(e)
+        )
+    except httpx.RequestError as e:
+        return ToolResult(
+            title="沙盒重建失败",
+            output=f"网络请求失败: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(
+    requires_confirmation=True,
+    display={
+        "zh": {
+            "name": "销毁沙盒环境",
+            "params": {
+                "sandbox_id": "沙盒 ID"
+            }
+        },
+        "en": {
+            "name": "Destroy Sandbox",
+            "params": {
+                "sandbox_id": "Sandbox ID"
+            }
+        }
+    }
+)
+async def sandbox_destroy_environment(
+    sandbox_id: str,
+    server_url: str = None,
+    timeout: float = DEFAULT_TIMEOUT,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    销毁沙盒环境,释放资源。
+
+    Args:
+        sandbox_id: 沙盒 ID。
+        server_url: 服务地址,默认使用全局配置 SANDBOX_SERVER_URL。
+        timeout: 请求超时时间(秒),默认 300 秒。
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 销毁结果
+    """
+    url = f"{server_url or SANDBOX_SERVER_URL}/api/destroy_environment"
+    payload = {
+        "sandbox_id": sandbox_id
+    }
+
+    try:
+        async with httpx.AsyncClient(timeout=timeout) as client:
+            response = await client.post(url, json=payload)
+            response.raise_for_status()
+            data = response.json()
+
+        status = data.get("status", "")
+        message = data.get("message", "")
+        removed_tools = data.get("removed_tools", [])
+
+        output_parts = [f"状态: {status}", f"消息: {message}"]
+        if removed_tools:
+            output_parts.append(f"已移除的工具: {', '.join(removed_tools)}")
+
+        return ToolResult(
+            title="沙盒环境已销毁",
+            output="\n".join(output_parts),
+            metadata=data
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="沙盒销毁失败",
+            output=f"HTTP 错误: {e.response.status_code}",
+            error=str(e)
+        )
+    except httpx.RequestError as e:
+        return ToolResult(
+            title="沙盒销毁失败",
+            output=f"网络请求失败: {str(e)}",
+            error=str(e)
+        )

+ 260 - 0
agent/tools/builtin/search.py

@@ -0,0 +1,260 @@
+"""
+搜索工具模块
+
+提供帖子搜索和建议词搜索功能,支持多个渠道平台。
+
+主要功能:
+1. search_posts - 帖子搜索
+2. get_search_suggestions - 获取平台的搜索补全建议词
+"""
+
+import json
+from enum import Enum
+from typing import Any, Dict
+
+import httpx
+
+from agent.tools import tool, ToolResult
+
+
+# API 基础配置
+BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
+DEFAULT_TIMEOUT = 60.0
+
+
+class PostSearchChannel(str, Enum):
+    """
+    帖子搜索支持的渠道类型
+    """
+    XHS = "xhs"           # 小红书
+    GZH = "gzh"           # 公众号
+    SPH = "sph"           # 视频号
+    GITHUB = "github"     # GitHub
+    TOUTIAO = "toutiao"   # 头条
+    DOUYIN = "douyin"     # 抖音
+    BILI = "bili"         # B站
+    ZHIHU = "zhihu"       # 知乎
+    WEIBO = "weibo"       # 微博
+
+
+class SuggestSearchChannel(str, Enum):
+    """
+    建议词搜索支持的渠道类型
+    """
+    XHS = "xhs"           # 小红书
+    WX = "wx"             # 微信
+    GITHUB = "github"     # GitHub
+    TOUTIAO = "toutiao"   # 头条
+    DOUYIN = "douyin"     # 抖音
+    BILI = "bili"         # B站
+    ZHIHU = "zhihu"       # 知乎
+
+
+@tool(
+    display={
+        "zh": {
+            "name": "帖子搜索",
+            "params": {
+                "keyword": "搜索关键词",
+                "channel": "搜索渠道",
+                "cursor": "分页游标",
+                "max_count": "返回条数"
+            }
+        },
+        "en": {
+            "name": "Search Posts",
+            "params": {
+                "keyword": "Search keyword",
+                "channel": "Search channel",
+                "cursor": "Pagination cursor",
+                "max_count": "Max results"
+            }
+        }
+    }
+)
+async def search_posts(
+    keyword: str,
+    channel: str = "xhs",
+    cursor: str = "0",
+    max_count: int = 5,
+) -> ToolResult:
+    """
+    帖子搜索
+
+    根据关键词在指定渠道平台搜索帖子内容。
+
+    Args:
+        keyword: 搜索关键词
+        channel: 搜索渠道,支持的渠道有:
+            - xhs: 小红书
+            - gzh: 公众号
+            - sph: 视频号
+            - github: GitHub
+            - toutiao: 头条
+            - douyin: 抖音
+            - bili: B站
+            - zhihu: 知乎
+            - weibo: 微博
+        cursor: 分页游标,默认为 "0"(第一页)
+        max_count: 返回的最大条数,默认为 5
+
+    Returns:
+        ToolResult 包含搜索结果:
+        {
+            "code": 0,                    # 状态码,0 表示成功
+            "message": "success",         # 状态消息
+            "data": [                     # 帖子列表
+                {
+                    "channel_content_id": "68dd03db000000000303beb2",  # 内容唯一ID
+                    "title": "",                                       # 标题
+                    "content_type": "note",                            # 内容类型
+                    "body_text": "",                                   # 正文内容
+                    "like_count": 127,                                 # 点赞数
+                    "publish_timestamp": 1759314907000,                # 发布时间戳(毫秒)
+                    "images": ["https://xxx.webp"],                    # 图片列表
+                    "videos": [],                                      # 视频列表
+                    "channel": "xhs",                                  # 来源渠道
+                    "link": "xxx"                                      # 原文链接
+                }
+            ]
+        }
+    """
+    try:
+        # 处理 channel 参数,支持枚举和字符串
+        channel_value = channel.value if isinstance(channel, PostSearchChannel) else channel
+
+        url = f"{BASE_URL}/data"
+        payload = {
+            "type": channel_value,
+            "keyword": keyword,
+            "cursor": cursor,
+            "max_count": max_count,
+        }
+
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(
+                url,
+                json=payload,
+                headers={"Content-Type": "application/json"},
+            )
+            response.raise_for_status()
+            data = response.json()
+
+        # 计算结果数量
+        result_count = len(data.get("data", []))
+
+        return ToolResult(
+            title=f"搜索结果: {keyword} ({channel_value})",
+            output=json.dumps(data, ensure_ascii=False, indent=2),
+            long_term_memory=f"Searched '{keyword}' on {channel_value}, found {result_count} posts"
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="搜索失败",
+            output="",
+            error=f"HTTP error {e.response.status_code}: {e.response.text}"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="搜索失败",
+            output="",
+            error=str(e)
+        )
+
+
+@tool(
+    display={
+        "zh": {
+            "name": "获取搜索关键词补全建议",
+            "params": {
+                "keyword": "搜索关键词",
+                "channel": "搜索渠道"
+            }
+        },
+        "en": {
+            "name": "Get Search Suggestions",
+            "params": {
+                "keyword": "Search keyword",
+                "channel": "Search channel"
+            }
+        }
+    }
+)
+async def get_search_suggestions(
+    keyword: str,
+    channel: str = "xhs",
+) -> ToolResult:
+    """
+    获取搜索关键词补全建议
+
+    根据关键词在指定渠道平台获取搜索建议词。
+
+    Args:
+        keyword: 搜索关键词
+        channel: 搜索渠道,支持的渠道有:
+            - xhs: 小红书
+            - wx: 微信
+            - github: GitHub
+            - toutiao: 头条
+            - douyin: 抖音
+            - bili: B站
+            - zhihu: 知乎
+
+    Returns:
+        ToolResult 包含建议词数据:
+        {
+            "code": 0,                    # 状态码,0 表示成功
+            "message": "success",         # 状态消息
+            "data": [                     # 建议词数据
+                {
+                    "type": "xhs",        # 渠道类型
+                    "list": [             # 建议词列表
+                        {
+                            "name": "彩虹染发"  # 建议词
+                        }
+                    ]
+                }
+            ]
+        }
+    """
+    try:
+        # 处理 channel 参数,支持枚举和字符串
+        channel_value = channel.value if isinstance(channel, SuggestSearchChannel) else channel
+
+        url = f"{BASE_URL}/suggest"
+        payload = {
+            "type": channel_value,
+            "keyword": keyword,
+        }
+
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(
+                url,
+                json=payload,
+                headers={"Content-Type": "application/json"},
+            )
+            response.raise_for_status()
+            data = response.json()
+
+        # 计算建议词数量
+        suggestion_count = 0
+        for item in data.get("data", []):
+            suggestion_count += len(item.get("list", []))
+
+        return ToolResult(
+            title=f"建议词: {keyword} ({channel_value})",
+            output=json.dumps(data, ensure_ascii=False, indent=2),
+            long_term_memory=f"Got {suggestion_count} suggestions for '{keyword}' on {channel_value}"
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="获取建议词失败",
+            output="",
+            error=f"HTTP error {e.response.status_code}: {e.response.text}"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="获取建议词失败",
+            output="",
+            error=str(e)
+        )

+ 258 - 0
agent/tools/builtin/skill.py

@@ -0,0 +1,258 @@
+"""
+Skill 工具 - 按需加载 Skill 文件
+
+Agent 可以调用此工具来加载特定的 skill 文档
+"""
+
+import os
+import subprocess
+from pathlib import Path
+from typing import Optional
+
+from agent.tools import tool, ToolResult
+from agent.memory.skill_loader import SkillLoader
+
+# 默认 skills 目录(优先级:项目 skills > 框架 skills)
+DEFAULT_SKILLS_DIRS = [
+    os.getenv("SKILLS_DIR", "./skills"),      # 项目特定 skills(优先)
+    "./agent/memory/skills"                    # 框架内置 skills
+]
+
+# 默认单一目录(用于 list_skills)
+DEFAULT_SKILLS_DIR = DEFAULT_SKILLS_DIRS[0]
+
+
+def _check_skill_setup(skill_name: str) -> Optional[str]:
+    """
+    检查 skill 的环境配置,返回缺失依赖的警告信息
+
+    Args:
+        skill_name: Skill 名称
+
+    Returns:
+        警告信息(如果有缺失的依赖),否则返回 None
+    """
+    # 特殊处理:browser-use skill
+    if skill_name in ["browser-use", "browser_use"]:
+        try:
+            # 动态导入 browser-use skill 的 setup 模块
+            from agent.memory.skills.browser_use.setup import (
+                _check_browser_use_cli,
+                _check_chromium_installed
+            )
+
+            cli_installed = _check_browser_use_cli()
+            chromium_installed = _check_chromium_installed()
+
+            if not cli_installed or not chromium_installed:
+                warning = "\n⚠️ **Setup Required**\n\n"
+                warning += "The following dependencies are missing:\n\n"
+
+                if not cli_installed:
+                    warning += "- `pip install browser-use`\n"
+                if not chromium_installed:
+                    warning += "- `uvx browser-use install`\n"
+
+                warning += "\nYou can also use the setup tools:\n"
+                warning += "- `check_browser_use()` - Check dependency status\n"
+                warning += "- `install_browser_use_chromium()` - Auto-install Chromium\n\n"
+
+                return warning
+        except ImportError:
+            # Setup 模块不存在,跳过检查
+            pass
+
+    return None
+
+
+@tool(
+    description="加载指定的 skill 文档。Skills 提供领域知识和最佳实践指导。"
+)
+async def skill(
+    skill_name: str,
+    skills_dir: Optional[str] = None,
+) -> ToolResult:
+    """
+    加载指定的 skill 文档
+
+    Args:
+        skill_name: Skill 名称(如 "browser-use", "error-handling")
+        skills_dir: Skills 目录路径(可选,默认按优先级查找)
+
+    Returns:
+        ToolResult: 包含 skill 的详细内容
+
+    加载顺序:
+    1. 如果指定 skills_dir,只在该目录查找
+    2. 否则按优先级查找:./skills/ (项目) -> ./config/skills/ (框架)
+    """
+    # 确定要搜索的目录列表
+    if skills_dir:
+        search_paths = [Path(skills_dir)]
+    else:
+        search_paths = [Path(d) for d in DEFAULT_SKILLS_DIRS]
+
+    # 在目录中查找 skill 文件
+    skill_file = None
+    found_in_dir = None
+
+    for skills_path in search_paths:
+        if not skills_path.exists():
+            continue
+
+        # 查找文件(支持 skill-name.md 或 skill_name.md)
+        for ext in [".md"]:
+            for name_format in [skill_name, skill_name.replace("-", "_"), skill_name.replace("_", "-")]:
+                candidate = skills_path / f"{name_format}{ext}"
+                if candidate.exists():
+                    skill_file = candidate
+                    found_in_dir = skills_path
+                    break
+            if skill_file:
+                break
+
+        if skill_file:
+            break
+
+    if not skill_file:
+        # 列出所有可用的 skills
+        available_skills = []
+        for skills_path in search_paths:
+            if skills_path.exists():
+                available_skills.extend([f.stem for f in skills_path.glob("**/*.md")])
+
+        return ToolResult(
+            title=f"Skill '{skill_name}' 未找到",
+            output=f"可用的 skills: {', '.join(set(available_skills))}\n\n"
+                   f"查找路径: {', '.join([str(p) for p in search_paths])}",
+            error=f"Skill not found: {skill_name}"
+        )
+
+    # 加载 skill
+    try:
+        loader = SkillLoader(str(skills_path))
+        skill_obj = loader.load_file(skill_file)
+
+        if not skill_obj:
+            return ToolResult(
+                title="加载失败",
+                output=f"无法解析 skill 文件: {skill_file.name}",
+                error="Failed to parse skill file"
+            )
+
+        # 格式化输出
+        output = f"# {skill_obj.name}\n\n"
+        output += f"**Category**: {skill_obj.category}\n\n"
+
+        if skill_obj.description:
+            output += f"## Description\n\n{skill_obj.description}\n\n"
+
+        # 检查 skill 的环境配置
+        setup_warning = _check_skill_setup(skill_name)
+        if setup_warning:
+            output += setup_warning
+
+        if skill_obj.guidelines:
+            output += f"## Guidelines\n\n"
+            for i, guideline in enumerate(skill_obj.guidelines, 1):
+                output += f"{i}. {guideline}\n"
+            output += "\n"
+
+        return ToolResult(
+            title=f"Skill: {skill_obj.name}",
+            output=output,
+            long_term_memory=f"已加载 skill: {skill_obj.name} ({skill_obj.category}) from {found_in_dir}",
+            include_output_only_once=True,  # skill 内容只展示一次
+            metadata={
+                "skill_name": skill_obj.name,
+                "category": skill_obj.category,
+                "scope": skill_obj.scope,
+                "guidelines_count": len(skill_obj.guidelines),
+                "loaded_from": str(found_in_dir)
+            }
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="加载错误",
+            output=f"加载 skill 时出错: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(
+    description="列出所有可用的 skills"
+)
+async def list_skills(
+    skills_dir: Optional[str] = None,
+) -> ToolResult:
+    """
+    列出所有可用的 skills
+
+    Args:
+        skills_dir: Skills 目录路径(可选)
+
+    Returns:
+        ToolResult: 包含所有 skills 的列表
+    """
+    skills_path = Path(skills_dir or DEFAULT_SKILLS_DIR)
+
+    if not skills_path.exists():
+        return ToolResult(
+            title="Skills 目录不存在",
+            output=f"找不到 skills 目录: {skills_path}",
+            error=f"Directory not found: {skills_path}"
+        )
+
+    try:
+        loader = SkillLoader(str(skills_path))
+        skills = loader.load_all()
+
+        if not skills:
+            return ToolResult(
+                title="没有可用的 Skills",
+                output="skills 目录中没有找到任何 .md 文件",
+                metadata={"count": 0}
+            )
+
+        # 按 category 分组
+        by_category = {}
+        for skill_obj in skills:
+            category = skill_obj.category or "general"
+            if category not in by_category:
+                by_category[category] = []
+            by_category[category].append(skill_obj)
+
+        # 格式化输出
+        output = f"# 可用的 Skills ({len(skills)} 个)\n\n"
+
+        for category in sorted(by_category.keys()):
+            output += f"## {category.title()}\n\n"
+            for skill_obj in by_category[category]:
+                skill_id = skill_obj.skill_id or skill_obj.name.lower().replace(' ', '-')
+                output += f"- **{skill_obj.name}** (`{skill_id}`)\n"
+                if skill_obj.description:
+                    desc = skill_obj.description.split('\n')[0]  # 第一行
+                    output += f"  {desc[:100]}{'...' if len(desc) > 100 else ''}\n"
+            output += "\n"
+
+        output += "\n使用 `skill` 工具加载具体的 skill:`skill(skill_name=\"browser-use\")`"
+
+        return ToolResult(
+            title=f"可用 Skills ({len(skills)} 个)",
+            output=output,
+            long_term_memory=f"找到 {len(skills)} 个可用 skills,分为 {len(by_category)} 个类别",
+            include_output_only_once=True,
+            metadata={
+                "count": len(skills),
+                "categories": list(by_category.keys()),
+                "skills": [{"name": s.name, "category": s.category} for s in skills]
+            }
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="列出 Skills 错误",
+            output=f"列出 skills 时出错: {str(e)}",
+            error=str(e)
+        )

+ 809 - 0
agent/tools/builtin/subagent.py

@@ -0,0 +1,809 @@
+"""
+Sub-Agent 工具 - agent / evaluate
+
+agent: 创建 Agent 执行任务(单任务 delegate 或多任务并行 explore)
+evaluate: 评估目标执行结果是否满足要求
+"""
+
+import asyncio
+from datetime import datetime
+from typing import Any, Dict, List, Optional, Union
+
+from agent.tools import tool
+from agent.trace.models import Trace, Messages
+from agent.trace.trace_id import generate_sub_trace_id
+from agent.trace.goal_models import GoalTree
+from agent.trace.websocket import broadcast_sub_trace_started, broadcast_sub_trace_completed
+
+
+# ===== prompts =====
+
+# ===== 评估任务 =====
+
+EVALUATE_PROMPT_TEMPLATE = """# 评估任务
+
+请评估以下任务的执行结果是否满足要求。
+
+## 目标描述
+
+{goal_description}
+
+## 执行结果
+
+{result_text}
+
+## 输出格式
+
+## 评估结论
+[通过/不通过]
+
+## 评估理由
+[详细说明通过或不通过原因]
+
+## 修改建议(如果不通过)
+1. [建议1]
+2. [建议2]
+"""
+
+# ===== 结果格式化 =====
+
+DELEGATE_RESULT_HEADER = "## 委托任务完成\n"
+
+DELEGATE_SAVED_KNOWLEDGE_HEADER = "**保存的知识** ({count} 条):"
+
+DELEGATE_STATS_HEADER = "**执行统计**:"
+
+EXPLORE_RESULT_HEADER = "## 探索结果\n"
+
+EXPLORE_BRANCH_TEMPLATE = "### 方案 {branch_name}: {task}"
+
+EXPLORE_STATUS_SUCCESS = "**状态**: ✓ 完成"
+
+EXPLORE_STATUS_FAILED = "**状态**: ✗ 失败"
+
+EXPLORE_STATUS_ERROR = "**状态**: ✗ 异常"
+
+EXPLORE_SUMMARY_HEADER = "## 总结"
+
+def build_evaluate_prompt(goal_description: str, result_text: str) -> str:
+    return EVALUATE_PROMPT_TEMPLATE.format(
+        goal_description=goal_description,
+        result_text=result_text or "(无执行结果)",
+    )
+
+
+def _make_run_config(**kwargs):
+    """延迟导入 RunConfig 以避免循环导入"""
+    from agent.core.runner import RunConfig
+    return RunConfig(**kwargs)
+
+
+# ===== 辅助函数 =====
+
+async def _update_collaborator(
+    store, trace_id: str,
+    name: str, sub_trace_id: str,
+    status: str, summary: str = "",
+) -> None:
+    """
+    更新 trace.context["collaborators"] 中的协作者信息。
+
+    如果同名协作者已存在则更新,否则追加。
+    """
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        return
+
+    collaborators = trace.context.get("collaborators", [])
+
+    # 查找已有记录
+    existing = None
+    for c in collaborators:
+        if c.get("trace_id") == sub_trace_id:
+            existing = c
+            break
+
+    if existing:
+        existing["status"] = status
+        if summary:
+            existing["summary"] = summary
+    else:
+        collaborators.append({
+            "name": name,
+            "type": "agent",
+            "trace_id": sub_trace_id,
+            "status": status,
+            "summary": summary,
+        })
+
+    trace.context["collaborators"] = collaborators
+    await store.update_trace(trace_id, context=trace.context)
+
+
+async def _update_goal_start(
+    store, trace_id: str, goal_id: str, mode: str, sub_trace_ids: List[str]
+) -> None:
+    """标记 Goal 开始执行"""
+    if not goal_id:
+        return
+    await store.update_goal(
+        trace_id, goal_id,
+        type="agent_call",
+        agent_call_mode=mode,
+        status="in_progress",
+        sub_trace_ids=sub_trace_ids
+    )
+
+
+async def _update_goal_complete(
+    store, trace_id: str, goal_id: str,
+    status: str, summary: str, sub_trace_ids: List[str]
+) -> None:
+    """标记 Goal 完成"""
+    if not goal_id:
+        return
+    await store.update_goal(
+        trace_id, goal_id,
+        status=status,
+        summary=summary,
+        sub_trace_ids=sub_trace_ids
+    )
+
+
+def _aggregate_stats(results: List[Dict[str, Any]]) -> Dict[str, Any]:
+    """聚合多个结果的统计信息"""
+    total_messages = 0
+    total_tokens = 0
+    total_cost = 0.0
+
+    for result in results:
+        if isinstance(result, dict) and "stats" in result:
+            stats = result["stats"]
+            total_messages += stats.get("total_messages", 0)
+            total_tokens += stats.get("total_tokens", 0)
+            total_cost += stats.get("total_cost", 0.0)
+
+    return {
+        "total_messages": total_messages,
+        "total_tokens": total_tokens,
+        "total_cost": total_cost
+    }
+
+
+def _get_allowed_tools(single: bool, context: dict) -> Optional[List[str]]:
+    """获取允许工具列表。single=True: 全部(去掉 agent/evaluate); single=False: 只读"""
+    if not single:
+        return ["read_file", "grep_content", "glob_files", "goal"]
+    # single (delegate): 获取所有工具,排除 agent 和 evaluate
+    runner = context.get("runner")
+    if runner and hasattr(runner, "tools") and hasattr(runner.tools, "registry"):
+        all_tools = list(runner.tools.registry.keys())
+        return [t for t in all_tools if t not in ("agent", "evaluate")]
+    return None
+
+
+def _format_single_result(result: Dict[str, Any], sub_trace_id: str, continued: bool) -> Dict[str, Any]:
+    """格式化单任务(delegate)结果"""
+    lines = [DELEGATE_RESULT_HEADER]
+    summary = result.get("summary", "")
+    if summary:
+        lines.append(summary)
+        lines.append("")
+
+    # 添加保存的知识 ID
+    saved_knowledge_ids = result.get("saved_knowledge_ids", [])
+    if saved_knowledge_ids:
+        lines.append("---\n")
+        lines.append(DELEGATE_SAVED_KNOWLEDGE_HEADER.format(count=len(saved_knowledge_ids)))
+        for kid in saved_knowledge_ids:
+            lines.append(f"- {kid}")
+        lines.append("")
+
+    lines.append("---\n")
+    lines.append(DELEGATE_STATS_HEADER)
+    stats = result.get("stats", {})
+    if stats:
+        lines.append(f"- 消息数: {stats.get('total_messages', 0)}")
+        lines.append(f"- Tokens: {stats.get('total_tokens', 0)}")
+        lines.append(f"- 成本: ${stats.get('total_cost', 0.0):.4f}")
+    formatted_summary = "\n".join(lines)
+
+    return {
+        "mode": "delegate",
+        "sub_trace_id": sub_trace_id,
+        "continue_from": continued,
+        "saved_knowledge_ids": saved_knowledge_ids,  # 传递给父 agent
+        **result,
+        "summary": formatted_summary,
+    }
+
+
+def _format_multi_result(
+    tasks: List[str], results: List[Dict[str, Any]], sub_trace_ids: List[Dict]
+) -> Dict[str, Any]:
+    """格式化多任务(explore)聚合结果"""
+    lines = [EXPLORE_RESULT_HEADER]
+    successful = 0
+    failed = 0
+    total_tokens = 0
+    total_cost = 0.0
+
+    for i, (task_item, result) in enumerate(zip(tasks, results)):
+        branch_name = chr(ord('A') + i)
+        lines.append(EXPLORE_BRANCH_TEMPLATE.format(branch_name=branch_name, task=task_item))
+
+        if isinstance(result, dict):
+            status = result.get("status", "unknown")
+            if status == "completed":
+                lines.append(EXPLORE_STATUS_SUCCESS)
+                successful += 1
+            else:
+                lines.append(EXPLORE_STATUS_FAILED)
+                failed += 1
+
+            summary = result.get("summary", "")
+            if summary:
+                lines.append(f"**摘要**: {summary[:200]}...")
+
+            stats = result.get("stats", {})
+            if stats:
+                messages = stats.get("total_messages", 0)
+                tokens = stats.get("total_tokens", 0)
+                cost = stats.get("total_cost", 0.0)
+                lines.append(f"**统计**: {messages} messages, {tokens} tokens, ${cost:.4f}")
+                total_tokens += tokens
+                total_cost += cost
+        else:
+            lines.append(EXPLORE_STATUS_ERROR)
+            failed += 1
+
+        lines.append("")
+
+    lines.append("---\n")
+    lines.append(EXPLORE_SUMMARY_HEADER)
+    lines.append(f"- 总分支数: {len(tasks)}")
+    lines.append(f"- 成功: {successful}")
+    lines.append(f"- 失败: {failed}")
+    lines.append(f"- 总 tokens: {total_tokens}")
+    lines.append(f"- 总成本: ${total_cost:.4f}")
+
+    aggregated_summary = "\n".join(lines)
+    overall_status = "completed" if successful > 0 else "failed"
+
+    return {
+        "mode": "explore",
+        "status": overall_status,
+        "summary": aggregated_summary,
+        "sub_trace_ids": sub_trace_ids,
+        "tasks": tasks,
+        "stats": _aggregate_stats(results),
+    }
+
+
+async def _get_goal_description(store, trace_id: str, goal_id: str) -> str:
+    """从 GoalTree 获取目标描述"""
+    if not goal_id:
+        return ""
+    goal_tree = await store.get_goal_tree(trace_id)
+    if goal_tree:
+        target_goal = goal_tree.find(goal_id)
+        if target_goal:
+            return target_goal.description
+    return f"Goal {goal_id}"
+
+
+def _build_evaluate_prompt(goal_description: str, messages: Optional[Messages]) -> str:
+    """
+    构建评估 prompt。
+
+    Args:
+        goal_description: 代码从 GoalTree 注入的目标描述
+        messages: 模型提供的消息(执行结果+上下文)
+    """
+    # 从 messages 提取文本内容
+    result_text = ""
+    if messages:
+        parts = []
+        for msg in messages:
+            content = msg.get("content", "")
+            if isinstance(content, str):
+                parts.append(content)
+            elif isinstance(content, list):
+                # 多模态内容,提取文本部分
+                for item in content:
+                    if isinstance(item, dict) and item.get("type") == "text":
+                        parts.append(item.get("text", ""))
+        result_text = "\n".join(parts)
+
+    return build_evaluate_prompt(goal_description, result_text)
+
+
+def _make_event_printer(label: str):
+    """
+    创建子 Agent 执行过程打印函数。
+
+    当父 runner.debug=True 时,传给 run_result(on_event=...),
+    实时输出子 Agent 的工具调用和助手消息。
+    """
+    prefix = f"  [{label}]"
+
+    def on_event(item):
+        from agent.trace.models import Trace, Message
+        if isinstance(item, Message):
+            if item.role == "assistant":
+                content = item.content
+                if isinstance(content, dict):
+                    text = content.get("text", "")
+                    tool_calls = content.get("tool_calls")
+                    if text:
+                        preview = text[:120] + "..." if len(text) > 120 else text
+                        print(f"{prefix} {preview}")
+                    if tool_calls:
+                        for tc in tool_calls:
+                            name = tc.get("function", {}).get("name", "unknown")
+                            print(f"{prefix} 🛠️  {name}")
+            elif item.role == "tool":
+                content = item.content
+                if isinstance(content, dict):
+                    name = content.get("tool_name", "unknown")
+                    desc = item.description or ""
+                    desc_short = (desc[:60] + "...") if len(desc) > 60 else desc
+                    suffix = f": {desc_short}" if desc_short else ""
+                    print(f"{prefix} ✅ {name}{suffix}")
+        elif isinstance(item, Trace):
+            if item.status == "completed":
+                print(f"{prefix} ✓ 完成")
+            elif item.status == "failed":
+                err = (item.error_message or "")[:80]
+                print(f"{prefix} ✗ 失败: {err}")
+
+    return on_event
+
+
+# ===== 统一内部执行函数 =====
+
+async def _run_agents(
+    tasks: List[str],
+    per_agent_msgs: List[Messages],
+    continue_from: Optional[str],
+    store, trace_id: str, goal_id: str, runner, context: dict,
+    agent_type: Optional[str] = None,
+    skills: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+    """
+    统一 agent 执行逻辑。
+
+    single (len(tasks)==1): delegate 模式,全量工具(排除 agent/evaluate)
+    multi (len(tasks)>1): explore 模式,只读工具,并行执行
+    """
+    single = len(tasks) == 1
+    parent_trace = await store.get_trace(trace_id)
+
+    # continue_from: 复用已有 trace(仅 single)
+    sub_trace_id = None
+    continued = False
+    if single and continue_from:
+        existing = await store.get_trace(continue_from)
+        if not existing:
+            return {"status": "failed", "error": f"Continue-from trace not found: {continue_from}"}
+        sub_trace_id = continue_from
+        continued = True
+        goal_tree = await store.get_goal_tree(continue_from)
+        mission = goal_tree.mission if goal_tree else tasks[0]
+        sub_trace_ids = [{"trace_id": sub_trace_id, "mission": mission}]
+    else:
+        sub_trace_ids = []
+
+    # 创建 sub-traces 和执行协程
+    coros = []
+    all_sub_trace_ids = list(sub_trace_ids)  # copy for continue_from case
+
+    for i, (task_item, msgs) in enumerate(zip(tasks, per_agent_msgs)):
+        if single and continued:
+            # continue_from 已经设置了 sub_trace_id
+            pass
+        else:
+            resolved_agent_type = agent_type or ("delegate" if single else "explore")
+            suffix = "delegate" if single else f"explore-{i+1:03d}"
+            stid = generate_sub_trace_id(trace_id, suffix)
+
+            sub_trace = Trace(
+                trace_id=stid,
+                mode="agent",
+                task=task_item,
+                parent_trace_id=trace_id,
+                parent_goal_id=goal_id,
+                agent_type=resolved_agent_type,
+                uid=parent_trace.uid if parent_trace else None,
+                model=parent_trace.model if parent_trace else None,
+                status="running",
+                context={"created_by_tool": "agent"},
+                created_at=datetime.now(),
+            )
+            await store.create_trace(sub_trace)
+            await store.update_goal_tree(stid, GoalTree(mission=task_item))
+
+            all_sub_trace_ids.append({"trace_id": stid, "mission": task_item})
+
+            # 广播 sub_trace_started
+            await broadcast_sub_trace_started(
+                trace_id, stid, goal_id or "",
+                resolved_agent_type, task_item,
+            )
+
+            if single:
+                sub_trace_id = stid
+
+        # 注册为活跃协作者
+        cur_stid = sub_trace_id if single else all_sub_trace_ids[-1]["trace_id"]
+        collab_name = task_item[:30] if single and not continued else (
+            f"delegate-{cur_stid[:8]}" if single else f"explore-{i+1}"
+        )
+        await _update_collaborator(
+            store, trace_id,
+            name=collab_name, sub_trace_id=cur_stid,
+            status="running", summary=task_item[:80],
+        )
+
+        # 构建消息
+        agent_msgs = list(msgs) + [{"role": "user", "content": task_item}]
+        allowed_tools = _get_allowed_tools(single, context)
+
+        debug = getattr(runner, 'debug', False)
+        agent_label = (agent_type or ("delegate" if single else f"explore-{i+1}"))
+        on_event = _make_event_printer(agent_label) if debug else None
+
+        coro = runner.run_result(
+            messages=agent_msgs,
+            config=_make_run_config(
+                trace_id=cur_stid,
+                agent_type=agent_type or ("delegate" if single else "explore"),
+                model=parent_trace.model if parent_trace else "gpt-4o",
+                uid=parent_trace.uid if parent_trace else None,
+                tools=allowed_tools,
+                name=task_item[:50],
+                skills=skills,
+            ),
+            on_event=on_event,
+        )
+        coros.append((i, cur_stid, collab_name, coro))
+
+    # 更新主 Goal 为 in_progress
+    await _update_goal_start(
+        store, trace_id, goal_id,
+        "delegate" if single else "explore",
+        all_sub_trace_ids,
+    )
+
+    # 执行
+    if single:
+        # 单任务直接执行(带异常处理)
+        _, stid, collab_name, coro = coros[0]
+        try:
+            result = await coro
+
+            await broadcast_sub_trace_completed(
+                trace_id, stid,
+                result.get("status", "completed"),
+                result.get("summary", ""),
+                result.get("stats", {}),
+            )
+            await _update_collaborator(
+                store, trace_id,
+                name=collab_name, sub_trace_id=stid,
+                status=result.get("status", "completed"),
+                summary=result.get("summary", "")[:80],
+            )
+
+            formatted = _format_single_result(result, stid, continued)
+
+            await _update_goal_complete(
+                store, trace_id, goal_id,
+                result.get("status", "completed"),
+                formatted["summary"],
+                all_sub_trace_ids,
+            )
+            return formatted
+
+        except Exception as e:
+            error_msg = str(e)
+            await broadcast_sub_trace_completed(
+                trace_id, stid, "failed", error_msg, {},
+            )
+            await _update_collaborator(
+                store, trace_id,
+                name=collab_name, sub_trace_id=stid,
+                status="failed", summary=error_msg[:80],
+            )
+            await _update_goal_complete(
+                store, trace_id, goal_id,
+                "failed", f"委托任务失败: {error_msg}",
+                all_sub_trace_ids,
+            )
+            return {
+                "mode": "delegate",
+                "status": "failed",
+                "error": error_msg,
+                "sub_trace_id": stid,
+            }
+    else:
+        # 多任务并行执行
+        raw_results = await asyncio.gather(
+            *(coro for _, _, _, coro in coros),
+            return_exceptions=True,
+        )
+
+        processed_results = []
+        for idx, raw in enumerate(raw_results):
+            _, stid, collab_name, _ = coros[idx]
+            if isinstance(raw, Exception):
+                error_result = {
+                    "status": "failed",
+                    "summary": f"执行出错: {str(raw)}",
+                    "stats": {"total_messages": 0, "total_tokens": 0, "total_cost": 0.0},
+                }
+                processed_results.append(error_result)
+                await broadcast_sub_trace_completed(
+                    trace_id, stid, "failed", str(raw), {},
+                )
+                await _update_collaborator(
+                    store, trace_id,
+                    name=collab_name, sub_trace_id=stid,
+                    status="failed", summary=str(raw)[:80],
+                )
+            else:
+                processed_results.append(raw)
+                await broadcast_sub_trace_completed(
+                    trace_id, stid,
+                    raw.get("status", "completed"),
+                    raw.get("summary", ""),
+                    raw.get("stats", {}),
+                )
+                await _update_collaborator(
+                    store, trace_id,
+                    name=collab_name, sub_trace_id=stid,
+                    status=raw.get("status", "completed"),
+                    summary=raw.get("summary", "")[:80],
+                )
+
+        formatted = _format_multi_result(tasks, processed_results, all_sub_trace_ids)
+
+        await _update_goal_complete(
+            store, trace_id, goal_id,
+            formatted["status"],
+            formatted["summary"],
+            all_sub_trace_ids,
+        )
+        return formatted
+
+
+# ===== 工具定义 =====
+
+@tool(description="创建 Agent 执行任务", hidden_params=["context"])
+async def agent(
+    task: Union[str, List[str]],
+    messages: Optional[Union[Messages, List[Messages]]] = None,
+    continue_from: Optional[str] = None,
+    agent_type: Optional[str] = None,
+    skills: Optional[List[str]] = None,
+    context: Optional[dict] = None,
+) -> Dict[str, Any]:
+    """
+    创建 Agent 执行任务。
+
+    单任务 (task: str): delegate 模式,全量工具
+    多任务 (task: List[str]): explore 模式,只读工具,并行执行
+
+    Args:
+        task: 任务描述。字符串=单任务,列表=多任务并行
+        messages: 预置消息。1D 列表=所有 agent 共享;2D 列表=per-agent
+        continue_from: 继续已有 trace(仅单任务)
+        agent_type: 子 Agent 类型,决定 preset 和默认 skills(如 "deconstruct")
+        skills: 附加到 system prompt 的 skill 名称列表,覆盖 preset 默认值
+        context: 框架自动注入的上下文
+    """
+    if not context:
+        return {"status": "failed", "error": "context is required"}
+
+    store = context.get("store")
+    trace_id = context.get("trace_id")
+    goal_id = context.get("goal_id")
+    runner = context.get("runner")
+
+    missing = []
+    if not store:
+        missing.append("store")
+    if not trace_id:
+        missing.append("trace_id")
+    if not runner:
+        missing.append("runner")
+    if missing:
+        return {"status": "failed", "error": f"Missing required context: {', '.join(missing)}"}
+
+    # 归一化 task → list
+    single = isinstance(task, str)
+    tasks = [task] if single else task
+
+    if not tasks:
+        return {"status": "failed", "error": "task is required"}
+
+    # 归一化 messages → List[Messages](per-agent)
+    if messages is None:
+        per_agent_msgs: List[Messages] = [[] for _ in tasks]
+    elif messages and isinstance(messages[0], list):
+        per_agent_msgs = messages  # 2D: per-agent
+    else:
+        per_agent_msgs = [messages] * len(tasks)  # 1D: 共享
+
+    if continue_from and not single:
+        return {"status": "failed", "error": "continue_from requires single task"}
+
+    return await _run_agents(
+        tasks, per_agent_msgs, continue_from,
+        store, trace_id, goal_id, runner, context,
+        agent_type=agent_type,
+        skills=skills,
+    )
+
+
+@tool(description="评估目标执行结果是否满足要求", hidden_params=["context"])
+async def evaluate(
+    messages: Optional[Messages] = None,
+    target_goal_id: Optional[str] = None,
+    continue_from: Optional[str] = None,
+    context: Optional[dict] = None,
+) -> Dict[str, Any]:
+    """
+    评估目标执行结果是否满足要求。
+
+    代码自动从 GoalTree 注入目标描述。模型把执行结果和上下文放在 messages 中。
+
+    Args:
+        messages: 执行结果和上下文消息(OpenAI 格式)
+        target_goal_id: 要评估的目标 ID(默认当前 goal_id)
+        continue_from: 继续已有评估 trace
+        context: 框架自动注入的上下文
+    """
+    if not context:
+        return {"status": "failed", "error": "context is required"}
+
+    store = context.get("store")
+    trace_id = context.get("trace_id")
+    current_goal_id = context.get("goal_id")
+    runner = context.get("runner")
+
+    missing = []
+    if not store:
+        missing.append("store")
+    if not trace_id:
+        missing.append("trace_id")
+    if not runner:
+        missing.append("runner")
+    if missing:
+        return {"status": "failed", "error": f"Missing required context: {', '.join(missing)}"}
+
+    # target_goal_id 默认 context["goal_id"]
+    goal_id = target_goal_id or current_goal_id
+
+    # 从 GoalTree 获取目标描述
+    goal_desc = await _get_goal_description(store, trace_id, goal_id)
+
+    # 构建 evaluator prompt
+    eval_prompt = _build_evaluate_prompt(goal_desc, messages)
+
+    # 获取父 Trace 信息
+    parent_trace = await store.get_trace(trace_id)
+
+    # 处理 continue_from 或创建新 Sub-Trace
+    if continue_from:
+        existing_trace = await store.get_trace(continue_from)
+        if not existing_trace:
+            return {"status": "failed", "error": f"Continue-from trace not found: {continue_from}"}
+        sub_trace_id = continue_from
+        goal_tree = await store.get_goal_tree(continue_from)
+        mission = goal_tree.mission if goal_tree else eval_prompt
+        sub_trace_ids = [{"trace_id": sub_trace_id, "mission": mission}]
+    else:
+        sub_trace_id = generate_sub_trace_id(trace_id, "evaluate")
+        sub_trace = Trace(
+            trace_id=sub_trace_id,
+            mode="agent",
+            task=eval_prompt,
+            parent_trace_id=trace_id,
+            parent_goal_id=current_goal_id,
+            agent_type="evaluate",
+            uid=parent_trace.uid if parent_trace else None,
+            model=parent_trace.model if parent_trace else None,
+            status="running",
+            context={"created_by_tool": "evaluate"},
+            created_at=datetime.now(),
+        )
+        await store.create_trace(sub_trace)
+        await store.update_goal_tree(sub_trace_id, GoalTree(mission=eval_prompt))
+        sub_trace_ids = [{"trace_id": sub_trace_id, "mission": eval_prompt}]
+
+        # 广播 sub_trace_started
+        await broadcast_sub_trace_started(
+            trace_id, sub_trace_id, current_goal_id or "",
+            "evaluate", eval_prompt,
+        )
+
+    # 更新主 Goal 为 in_progress
+    await _update_goal_start(store, trace_id, current_goal_id, "evaluate", sub_trace_ids)
+
+    # 注册为活跃协作者
+    eval_name = f"评估: {(goal_id or 'unknown')[:20]}"
+    await _update_collaborator(
+        store, trace_id,
+        name=eval_name, sub_trace_id=sub_trace_id,
+        status="running", summary=f"评估 Goal {goal_id}",
+    )
+
+    # 执行评估
+    try:
+        # evaluate 使用只读工具 + goal
+        allowed_tools = ["read_file", "grep_content", "glob_files", "goal"]
+        result = await runner.run_result(
+            messages=[{"role": "user", "content": eval_prompt}],
+            config=_make_run_config(
+                trace_id=sub_trace_id,
+                agent_type="evaluate",
+                model=parent_trace.model if parent_trace else "gpt-4o",
+                uid=parent_trace.uid if parent_trace else None,
+                tools=allowed_tools,
+                name=f"评估: {goal_id}",
+            ),
+            on_event=_make_event_printer("evaluate") if getattr(runner, 'debug', False) else None,
+        )
+
+        await broadcast_sub_trace_completed(
+            trace_id, sub_trace_id,
+            result.get("status", "completed"),
+            result.get("summary", ""),
+            result.get("stats", {}),
+        )
+        await _update_collaborator(
+            store, trace_id,
+            name=eval_name, sub_trace_id=sub_trace_id,
+            status=result.get("status", "completed"),
+            summary=result.get("summary", "")[:80],
+        )
+
+        formatted_summary = result.get("summary", "")
+
+        await _update_goal_complete(
+            store, trace_id, current_goal_id,
+            result.get("status", "completed"),
+            formatted_summary,
+            sub_trace_ids,
+        )
+
+        return {
+            "mode": "evaluate",
+            "sub_trace_id": sub_trace_id,
+            "continue_from": bool(continue_from),
+            **result,
+            "summary": formatted_summary,
+        }
+
+    except Exception as e:
+        error_msg = str(e)
+        await broadcast_sub_trace_completed(
+            trace_id, sub_trace_id, "failed", error_msg, {},
+        )
+        await _update_collaborator(
+            store, trace_id,
+            name=eval_name, sub_trace_id=sub_trace_id,
+            status="failed", summary=error_msg[:80],
+        )
+        await _update_goal_complete(
+            store, trace_id, current_goal_id,
+            "failed", f"评估任务失败: {error_msg}",
+            sub_trace_ids,
+        )
+        return {
+            "mode": "evaluate",
+            "status": "failed",
+            "error": error_msg,
+            "sub_trace_id": sub_trace_id,
+        }

+ 126 - 0
agent/tools/models.py

@@ -0,0 +1,126 @@
+"""
+Tool Models - 工具系统核心数据模型
+
+定义:
+1. ToolResult: 工具执行结果(支持双层记忆管理)
+2. ToolContext: 工具执行上下文(依赖注入)
+"""
+
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional, Protocol
+
+
+@dataclass
+class ToolResult:
+	"""
+	工具执行结果
+
+	支持双层记忆管理(参考 Browser-Use 的 ActionResult):
+	- output: 主要输出,可能很长,可以配置只给 LLM 看一次
+	- long_term_memory: 简短摘要,永久保存在对话历史中
+
+	这种设计避免了大量临时内容占用 context。
+	"""
+
+	# 主要输出(临时内容)
+	title: str  # 简短标题,用于展示
+	output: str  # 主要输出内容
+
+	# 记忆管理
+	long_term_memory: Optional[str] = None  # 永久记忆(简短摘要)
+	include_output_only_once: bool = False  # output 是否只给 LLM 看一次
+
+	# 元数据
+	metadata: Dict[str, Any] = field(default_factory=dict)
+
+	# 状态标志
+	truncated: bool = False  # 输出是否被截断
+	error: Optional[str] = None  # 错误信息(如果执行失败)
+
+	# 附件支持(用于浏览器自动化等场景)
+	attachments: List[str] = field(default_factory=list)  # 文件路径列表
+	images: List[Dict[str, Any]] = field(default_factory=list)  # 图片列表
+
+	# Token追踪(用于工具内部LLM调用)
+	tool_usage: Optional[Dict[str, Any]] = None  # 格式:{"model": "...", "prompt_tokens": 100, "completion_tokens": 50, "cost": 0.0}
+
+	def to_llm_message(self, first_time: bool = True) -> str:
+		"""
+		转换为给 LLM 的消息
+
+		Args:
+			first_time: 是否第一次展示(影响 include_output_only_once 的行为)
+
+		Returns:
+			给 LLM 的消息字符串
+		"""
+		# 如果有错误,优先返回错误
+		if self.error:
+			return f"Error: {self.error}"
+
+		# 构建消息
+		parts = []
+
+		# 标题
+		if self.title:
+			parts.append(f"# {self.title}")
+
+		# 主要输出
+		if first_time or not self.include_output_only_once:
+			if self.output:
+				parts.append(self.output)
+				if self.truncated:
+					parts.append("(Output truncated)")
+
+		# 长期记忆(永远包含)
+		if self.long_term_memory:
+			parts.append(f"\nSummary: {self.long_term_memory}")
+
+		# 附件信息
+		if self.attachments:
+			parts.append(f"\nAttachments: {', '.join(self.attachments)}")
+
+		return "\n\n".join(parts)
+
+
+class ToolContext(Protocol):
+	"""
+	工具执行上下文(依赖注入)
+
+	工具函数可以声明需要哪些上下文字段,框架自动注入。
+
+	使用 Protocol 允许不同实现提供不同的上下文字段。
+	"""
+
+	# 基础字段(所有工具都可用)
+	trace_id: str
+	step_id: str
+	uid: Optional[str]
+
+	# 浏览器相关(Browser-Use 集成)
+	browser_session: Optional[Any]  # BrowserSession 实例
+	page_url: Optional[str]  # 当前页面 URL
+	file_system: Optional[Any]  # FileSystem 实例
+	sensitive_data: Optional[Dict[str, Any]]  # 敏感数据字典
+
+	# 其他可扩展字段
+	context: Optional[Dict[str, Any]]  # 额外上下文数据
+
+
+@dataclass
+class ToolContextImpl:
+	"""ToolContext 的默认实现"""
+
+	# 基础字段
+	trace_id: str
+	step_id: str
+	uid: Optional[str] = None
+
+	# 浏览器相关
+	browser_session: Optional[Any] = None
+	page_url: Optional[str] = None
+	file_system: Optional[Any] = None
+	sensitive_data: Optional[Dict[str, Any]] = None
+
+	# 额外上下文
+	context: Optional[Dict[str, Any]] = None

+ 503 - 0
agent/tools/registry.py

@@ -0,0 +1,503 @@
+"""
+Tool Registry - 工具注册表和装饰器
+
+职责:
+1. @tool 装饰器:自动注册工具并生成 Schema
+2. 管理所有工具的 Schema 和实现
+3. 路由工具调用到具体实现
+4. 支持域名过滤、敏感数据处理、工具统计
+
+从 Resonote/llm/tools/registry.py 抽取并扩展
+"""
+
+import json
+import inspect
+import logging
+import time
+from typing import Any, Callable, Dict, List, Optional
+
+from agent.tools.url_matcher import filter_by_url
+
+logger = logging.getLogger(__name__)
+
+
+class ToolStats:
+	"""工具使用统计"""
+
+	def __init__(self):
+		self.call_count: int = 0
+		self.success_count: int = 0
+		self.failure_count: int = 0
+		self.total_duration: float = 0.0
+		self.last_called: Optional[float] = None
+
+	@property
+	def average_duration(self) -> float:
+		"""平均执行时间(秒)"""
+		return self.total_duration / self.call_count if self.call_count > 0 else 0.0
+
+	@property
+	def success_rate(self) -> float:
+		"""成功率"""
+		return self.success_count / self.call_count if self.call_count > 0 else 0.0
+
+	def to_dict(self) -> Dict[str, Any]:
+		return {
+			"call_count": self.call_count,
+			"success_count": self.success_count,
+			"failure_count": self.failure_count,
+			"average_duration": self.average_duration,
+			"success_rate": self.success_rate,
+			"last_called": self.last_called
+		}
+
+
+class ToolRegistry:
+	"""工具注册表"""
+
+	def __init__(self):
+		self._tools: Dict[str, Dict[str, Any]] = {}
+		self._stats: Dict[str, ToolStats] = {}
+
+	def register(
+		self,
+		func: Callable,
+		schema: Optional[Dict] = None,
+		requires_confirmation: bool = False,
+		editable_params: Optional[List[str]] = None,
+		display: Optional[Dict[str, Dict[str, Any]]] = None,
+		url_patterns: Optional[List[str]] = None,
+		hidden_params: Optional[List[str]] = None,
+		inject_params: Optional[Dict[str, Any]] = None
+	):
+		"""
+		注册工具
+
+		Args:
+			func: 工具函数
+			schema: 工具 Schema(如果为 None,自动生成)
+			requires_confirmation: 是否需要用户确认
+			editable_params: 允许用户编辑的参数列表
+			display: i18n 展示信息 {"zh": {"name": "xx", "params": {...}}, "en": {...}}
+			url_patterns: URL 模式列表(如 ["*.google.com"],None = 无限制)
+			hidden_params: 隐藏参数列表(不生成 schema,LLM 看不到)
+			inject_params: 注入参数规则 {param_name: injector_func}
+		"""
+		func_name = func.__name__
+
+		# 如果没有提供 Schema,自动生成
+		if schema is None:
+			try:
+				from agent.tools.schema import SchemaGenerator
+				schema = SchemaGenerator.generate(func, hidden_params=hidden_params or [])
+			except Exception as e:
+				logger.error(f"Failed to generate schema for {func_name}: {e}")
+				raise
+
+		self._tools[func_name] = {
+			"func": func,
+			"schema": schema,
+			"url_patterns": url_patterns,
+			"hidden_params": hidden_params or [],
+			"inject_params": inject_params or {},
+			"ui_metadata": {
+				"requires_confirmation": requires_confirmation,
+				"editable_params": editable_params or [],
+				"display": display or {}
+			}
+		}
+
+		# 初始化统计
+		self._stats[func_name] = ToolStats()
+
+		logger.debug(
+			f"[ToolRegistry] Registered: {func_name} "
+			f"(requires_confirmation={requires_confirmation}, "
+			f"editable_params={editable_params or []}, "
+			f"url_patterns={url_patterns or 'none'})"
+		)
+
+	def is_registered(self, tool_name: str) -> bool:
+		"""检查工具是否已注册"""
+		return tool_name in self._tools
+
+	def get_schemas(self, tool_names: Optional[List[str]] = None) -> List[Dict]:
+		"""
+		获取工具 Schema
+
+		Args:
+			tool_names: 工具名称列表(None = 所有工具)
+
+		Returns:
+			OpenAI Tool Schema 列表
+		"""
+		if tool_names is None:
+			tool_names = list(self._tools.keys())
+
+		schemas = []
+		for name in tool_names:
+			if name in self._tools:
+				schemas.append(self._tools[name]["schema"])
+			else:
+				logger.warning(f"[ToolRegistry] Tool not found: {name}")
+
+		return schemas
+
+	def get_tool_names(self, current_url: Optional[str] = None) -> List[str]:
+		"""
+		获取工具名称列表(可选 URL 过滤)
+
+		Args:
+			current_url: 当前 URL(None = 返回所有工具)
+
+		Returns:
+			工具名称列表
+		"""
+		if current_url is None:
+			return list(self._tools.keys())
+
+		# 过滤工具
+		tool_items = [
+			{"name": name, "url_patterns": tool["url_patterns"]}
+			for name, tool in self._tools.items()
+		]
+		filtered = filter_by_url(tool_items, current_url, url_field="url_patterns")
+		return [item["name"] for item in filtered]
+
+	def get_schemas_for_url(self, current_url: Optional[str] = None) -> List[Dict]:
+		"""
+		根据当前 URL 获取匹配的工具 Schema
+
+		Args:
+			current_url: 当前 URL(None = 返回无 URL 限制的工具)
+
+		Returns:
+			过滤后的工具 Schema 列表
+		"""
+		tool_names = self.get_tool_names(current_url)
+		return self.get_schemas(tool_names)
+
+	async def execute(
+		self,
+		name: str,
+		arguments: Dict[str, Any],
+		uid: str = "",
+		context: Optional[Dict[str, Any]] = None,
+		sensitive_data: Optional[Dict[str, Any]] = None
+	) -> str:
+		"""
+		执行工具调用
+
+		Args:
+			name: 工具名称
+			arguments: 工具参数
+			uid: 用户ID(自动注入)
+			context: 额外上下文
+			sensitive_data: 敏感数据字典(用于替换 <secret> 占位符)
+
+		Returns:
+			JSON 字符串格式的结果
+		"""
+		if name not in self._tools:
+			error_msg = f"Unknown tool: {name}"
+			logger.error(f"[ToolRegistry] {error_msg}")
+			return json.dumps({"error": error_msg}, ensure_ascii=False)
+
+		start_time = time.time()
+		stats = self._stats[name]
+		stats.call_count += 1
+		stats.last_called = start_time
+
+		try:
+			func = self._tools[name]["func"]
+			tool_info = self._tools[name]
+
+			# 处理敏感数据占位符
+			if sensitive_data:
+				from agent.tools.sensitive import replace_sensitive_data
+				current_url = context.get("page_url") if context else None
+				arguments = replace_sensitive_data(arguments, sensitive_data, current_url)
+
+			# 准备参数:只注入函数需要的参数
+			kwargs = {**arguments}
+			sig = inspect.signature(func)
+
+			# 注入隐藏参数(hidden_params)
+			hidden_params = tool_info.get("hidden_params", [])
+			if "uid" in hidden_params and "uid" in sig.parameters:
+				kwargs["uid"] = uid
+			if "context" in hidden_params and "context" in sig.parameters:
+				kwargs["context"] = context
+
+			# 注入默认值(inject_params)
+			inject_params = tool_info.get("inject_params", {})
+			for param_name, injector in inject_params.items():
+				if param_name in sig.parameters:
+					# 如果 LLM 已提供值,不覆盖
+					if param_name not in kwargs or kwargs[param_name] is None:
+						if callable(injector):
+							# 检查 injector 的参数数量
+							injector_sig = inspect.signature(injector)
+							if len(injector_sig.parameters) == 1:
+								# lambda ctx: ...
+								kwargs[param_name] = injector(context)
+							elif len(injector_sig.parameters) == 2:
+								# lambda ctx, args: ...
+								kwargs[param_name] = injector(context, kwargs)
+							else:
+								kwargs[param_name] = injector()
+						else:
+							# 直接使用值
+							kwargs[param_name] = injector
+
+			# 执行函数
+			if inspect.iscoroutinefunction(func):
+				result = await func(**kwargs)
+			else:
+				result = func(**kwargs)
+
+			# 记录成功
+			stats.success_count += 1
+			duration = time.time() - start_time
+			stats.total_duration += duration
+
+			# 返回结果:ToolResult 转为可序列化格式
+			if isinstance(result, str):
+				return result
+
+			# 处理 ToolResult 对象
+			from agent.tools.models import ToolResult
+			if isinstance(result, ToolResult):
+				ret = {"text": result.to_llm_message()}
+
+				# 保留images
+				if result.images:
+					ret["images"] = result.images
+
+				# 保留tool_usage
+				if result.tool_usage:
+					ret["tool_usage"] = result.tool_usage
+
+				# 向后兼容:只有text时返回字符串
+				if len(ret) == 1:
+					return ret["text"]
+				return ret
+
+			return json.dumps(result, ensure_ascii=False, indent=2)
+
+		except Exception as e:
+			# 记录失败
+			stats.failure_count += 1
+			duration = time.time() - start_time
+			stats.total_duration += duration
+
+			error_msg = f"Error executing tool '{name}': {str(e)}"
+			logger.error(f"[ToolRegistry] {error_msg}")
+			import traceback
+			logger.error(traceback.format_exc())
+			return json.dumps({"error": error_msg}, ensure_ascii=False)
+
+	def get_stats(self, tool_name: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
+		"""
+		获取工具统计信息
+
+		Args:
+			tool_name: 工具名称(None = 所有工具)
+
+		Returns:
+			统计信息字典
+		"""
+		if tool_name:
+			if tool_name in self._stats:
+				return {tool_name: self._stats[tool_name].to_dict()}
+			return {}
+
+		return {name: stats.to_dict() for name, stats in self._stats.items()}
+
+	def get_top_tools(self, limit: int = 10, by: str = "call_count") -> List[str]:
+		"""
+		获取排名靠前的工具
+
+		Args:
+			limit: 返回数量
+			by: 排序依据(call_count, success_rate, average_duration)
+
+		Returns:
+			工具名称列表
+		"""
+		if by == "call_count":
+			sorted_tools = sorted(
+				self._stats.items(),
+				key=lambda x: x[1].call_count,
+				reverse=True
+			)
+		elif by == "success_rate":
+			sorted_tools = sorted(
+				self._stats.items(),
+				key=lambda x: x[1].success_rate,
+				reverse=True
+			)
+		elif by == "average_duration":
+			sorted_tools = sorted(
+				self._stats.items(),
+				key=lambda x: x[1].average_duration,
+				reverse=False  # 越快越好
+			)
+		else:
+			raise ValueError(f"Invalid sort by: {by}")
+
+		return [name for name, _ in sorted_tools[:limit]]
+
+	def check_confirmation_required(self, tool_calls: List[Dict]) -> bool:
+		"""检查是否有工具需要用户确认"""
+		for tc in tool_calls:
+			tool_name = tc.get("function", {}).get("name")
+			if tool_name and tool_name in self._tools:
+				if self._tools[tool_name]["ui_metadata"].get("requires_confirmation", False):
+					return True
+		return False
+
+	def get_confirmation_flags(self, tool_calls: List[Dict]) -> List[bool]:
+		"""返回每个工具是否需要确认"""
+		flags = []
+		for tc in tool_calls:
+			tool_name = tc.get("function", {}).get("name")
+			if tool_name and tool_name in self._tools:
+				flags.append(self._tools[tool_name]["ui_metadata"].get("requires_confirmation", False))
+			else:
+				flags.append(False)
+		return flags
+
+	def check_any_param_editable(self, tool_calls: List[Dict]) -> bool:
+		"""检查是否有任何工具允许参数编辑"""
+		for tc in tool_calls:
+			tool_name = tc.get("function", {}).get("name")
+			if tool_name and tool_name in self._tools:
+				editable_params = self._tools[tool_name]["ui_metadata"].get("editable_params", [])
+				if editable_params:
+					return True
+		return False
+
+	def get_editable_params_map(self, tool_calls: List[Dict]) -> Dict[str, List[str]]:
+		"""返回每个工具调用的可编辑参数列表"""
+		params_map = {}
+		for tc in tool_calls:
+			tool_call_id = tc.get("id")
+			tool_name = tc.get("function", {}).get("name")
+
+			if tool_name and tool_name in self._tools:
+				editable_params = self._tools[tool_name]["ui_metadata"].get("editable_params", [])
+				params_map[tool_call_id] = editable_params
+			else:
+				params_map[tool_call_id] = []
+
+		return params_map
+
+	def get_ui_metadata(
+		self,
+		locale: str = "zh",
+		tool_names: Optional[List[str]] = None
+	) -> Dict[str, Dict[str, Any]]:
+		"""
+		获取工具的UI元数据(用于前端展示)
+
+		Returns:
+			{
+				"tool_name": {
+					"display_name": "搜索笔记",
+					"param_display_names": {"query": "搜索关键词"},
+					"requires_confirmation": false,
+					"editable_params": ["query"]
+				}
+			}
+		"""
+		if tool_names is None:
+			tool_names = list(self._tools.keys())
+
+		metadata = {}
+		for name in tool_names:
+			if name not in self._tools:
+				continue
+
+			ui_meta = self._tools[name]["ui_metadata"]
+			display = ui_meta.get("display", {}).get(locale, {})
+
+			metadata[name] = {
+				"display_name": display.get("name", name),
+				"param_display_names": display.get("params", {}),
+				"requires_confirmation": ui_meta.get("requires_confirmation", False),
+				"editable_params": ui_meta.get("editable_params", [])
+			}
+
+		return metadata
+
+
+# 全局单例
+_global_registry = ToolRegistry()
+
+
+def tool(
+	description: Optional[str] = None,
+	param_descriptions: Optional[Dict[str, str]] = None,
+	requires_confirmation: bool = False,
+	editable_params: Optional[List[str]] = None,
+	display: Optional[Dict[str, Dict[str, Any]]] = None,
+	url_patterns: Optional[List[str]] = None,
+	hidden_params: Optional[List[str]] = None,
+	inject_params: Optional[Dict[str, Any]] = None
+):
+	"""
+	工具装饰器 - 自动注册工具并生成 Schema
+
+	Args:
+		description: 函数描述(可选,从 docstring 提取)
+		param_descriptions: 参数描述(可选,从 docstring 提取)
+		requires_confirmation: 是否需要用户确认(默认 False)
+		editable_params: 允许用户编辑的参数列表
+		display: i18n 展示信息
+		url_patterns: URL 模式列表(如 ["*.google.com"],None = 无限制)
+		hidden_params: 隐藏参数列表(不生成 schema,LLM 看不到)
+		inject_params: 注入参数规则 {param_name: injector_func}
+
+	Example:
+		@tool(
+			hidden_params=["context", "uid"],
+			inject_params={
+				"owner": lambda ctx: ctx.config.knowledge.get_owner(),
+			},
+			editable_params=["query"],
+			url_patterns=["*.google.com"],
+			display={
+				"zh": {"name": "搜索笔记", "params": {"query": "搜索关键词"}},
+				"en": {"name": "Search Notes", "params": {"query": "Query"}}
+			}
+		)
+		async def search_blocks(
+			query: str,
+			limit: int = 10,
+			owner: Optional[str] = None,
+			context: Optional[ToolContext] = None,
+			uid: str = ""
+		) -> str:
+			'''搜索用户的笔记块'''
+			...
+	"""
+	def decorator(func: Callable) -> Callable:
+		# 注册到全局 registry
+		_global_registry.register(
+			func,
+			requires_confirmation=requires_confirmation,
+			editable_params=editable_params,
+			display=display,
+			url_patterns=url_patterns,
+			hidden_params=hidden_params,
+			inject_params=inject_params
+		)
+		return func
+
+	return decorator
+
+
+def get_tool_registry() -> ToolRegistry:
+	"""获取全局工具注册表"""
+	return _global_registry

+ 199 - 0
agent/tools/schema.py

@@ -0,0 +1,199 @@
+"""
+Schema Generator - 从函数签名自动生成 OpenAI Tool Schema
+
+职责:
+1. 解析函数签名(参数、类型注解、默认值)
+2. 解析 docstring(Google 风格)
+3. 生成 OpenAI Tool Calling 格式的 JSON Schema
+
+从 Resonote/llm/tools/schema.py 抽取
+"""
+
+import inspect
+import logging
+from typing import Any, Dict, List, Literal, Optional, Union, get_args, get_origin
+
+logger = logging.getLogger(__name__)
+
+# 尝试导入 docstring_parser,如果不可用则提供降级方案
+try:
+    from docstring_parser import parse as parse_docstring
+    HAS_DOCSTRING_PARSER = True
+except ImportError:
+    HAS_DOCSTRING_PARSER = False
+    logger.warning("docstring_parser not installed, using fallback docstring parsing")
+
+
+def _simple_parse_docstring(docstring: str) -> tuple[str, Dict[str, str]]:
+    """简单的 docstring 解析(降级方案)"""
+    if not docstring:
+        return "", {}
+
+    lines = docstring.strip().split("\n")
+    description = lines[0] if lines else ""
+    param_descriptions = {}
+
+    # 简单解析 Args: 部分
+    in_args = False
+    for line in lines[1:]:
+        line = line.strip()
+        if line.lower().startswith("args:"):
+            in_args = True
+            continue
+        if line.lower().startswith(("returns:", "raises:", "example:")):
+            in_args = False
+            continue
+        if in_args and ":" in line:
+            parts = line.split(":", 1)
+            param_name = parts[0].strip()
+            param_desc = parts[1].strip() if len(parts) > 1 else ""
+            param_descriptions[param_name] = param_desc
+
+    return description, param_descriptions
+
+
+class SchemaGenerator:
+    """从函数生成 OpenAI Tool Schema"""
+
+    # Python 类型到 JSON Schema 类型的映射
+    TYPE_MAP = {
+        str: "string",
+        int: "integer",
+        float: "number",
+        bool: "boolean",
+        list: "array",
+        dict: "object",
+        List: "array",
+        Dict: "object",
+    }
+
+    @classmethod
+    def generate(cls, func: callable, hidden_params: Optional[List[str]] = None) -> Dict[str, Any]:
+        """
+        从函数生成 OpenAI Tool Schema
+
+        Args:
+            func: 要生成 Schema 的函数
+            hidden_params: 隐藏参数列表(不生成 schema)
+
+        Returns:
+            OpenAI Tool Schema(JSON 格式)
+        """
+        hidden_params = hidden_params or []
+
+        # 解析函数签名
+        sig = inspect.signature(func)
+        func_name = func.__name__
+
+        # 解析 docstring
+        if HAS_DOCSTRING_PARSER:
+            doc = parse_docstring(func.__doc__ or "")
+            func_description = doc.short_description or doc.long_description or f"Call {func_name}"
+            param_descriptions = {p.arg_name: p.description for p in doc.params if p.description}
+        else:
+            func_description, param_descriptions = _simple_parse_docstring(func.__doc__ or "")
+            if not func_description:
+                func_description = f"Call {func_name}"
+
+        # 生成参数 Schema
+        properties = {}
+        required = []
+
+        for param_name, param in sig.parameters.items():
+            # 跳过特殊参数
+            if param_name in ["self", "cls", "kwargs"]:
+                continue
+
+            # 跳过隐藏参数
+            if param_name in hidden_params:
+                continue
+
+            # 获取类型注解
+            param_type = param.annotation if param.annotation != inspect.Parameter.empty else str
+
+            # 生成参数 Schema
+            param_schema = cls._type_to_schema(param_type)
+
+            # 添加描述
+            if param_name in param_descriptions:
+                param_schema["description"] = param_descriptions[param_name]
+
+            # 添加默认值
+            if param.default != inspect.Parameter.empty:
+                param_schema["default"] = param.default
+            else:
+                required.append(param_name)
+
+            properties[param_name] = param_schema
+
+        # 构建完整的 Schema
+        schema = {
+            "type": "function",
+            "function": {
+                "name": func_name,
+                "description": func_description,
+                "parameters": {
+                    "type": "object",
+                    "properties": properties,
+                    "required": required
+                }
+            }
+        }
+
+        return schema
+
+    @classmethod
+    def _type_to_schema(cls, python_type: Any) -> Dict[str, Any]:
+        """将 Python 类型转换为 JSON Schema"""
+        if python_type is Any:
+            return {}
+
+        origin = get_origin(python_type)
+        args = get_args(python_type)
+
+        # 处理 Literal[...]
+        if origin is Literal:
+            values = list(args)
+            if all(isinstance(v, str) for v in values):
+                return {"type": "string", "enum": values}
+            elif all(isinstance(v, int) for v in values):
+                return {"type": "integer", "enum": values}
+            return {"enum": values}
+
+        # 处理 Union[T, ...] 和 Optional[T]
+        if origin is Union:
+            if len(args) == 2 and type(None) in args:
+                # Optional[T] = Union[T, None]
+                inner = args[0] if args[1] is type(None) else args[1]
+                return cls._type_to_schema(inner)
+            non_none = [a for a in args if a is not type(None)]
+            return {"oneOf": [cls._type_to_schema(a) for a in non_none]}
+
+        # 处理 List[T]
+        if origin is list or origin is List:
+            if args:
+                item_type = args[0]
+                return {
+                    "type": "array",
+                    "items": cls._type_to_schema(item_type)
+                }
+            return {"type": "array"}
+
+        # 处理 Dict[K, V]
+        if origin is dict or origin is Dict:
+            return {"type": "object"}
+
+        # 处理基础类型
+        if python_type in cls.TYPE_MAP:
+            return {"type": cls.TYPE_MAP[python_type]}
+
+        # 检查是否是 Protocol(如 ToolContext)
+        # Protocol 类型用于依赖注入,不应出现在 schema 中
+        type_name = getattr(python_type, "__name__", str(python_type))
+        if "Protocol" in str(type(python_type)) or type_name in ("ToolContext",):
+            logger.debug(f"Skipping Protocol type {python_type} (used for dependency injection)")
+            return {}
+
+        # 默认为 string
+        logger.debug(f"Unknown type {python_type}, defaulting to string")
+        return {"type": "string"}

+ 247 - 0
agent/tools/sensitive.py

@@ -0,0 +1,247 @@
+"""
+Sensitive Data Handling - 敏感数据占位符替换
+
+支持:
+1. <secret>key</secret> 占位符格式
+2. 域名匹配(不同域名使用不同密钥)
+3. TOTP 2FA(key_bu_2fa_code 自动生成验证码)
+4. 递归处理嵌套结构
+
+参考 Browser-Use 的实现。
+"""
+
+import re
+import logging
+from typing import Any, Dict, Optional
+
+logger = logging.getLogger(__name__)
+
+# 尝试导入 pyotp(TOTP 支持)
+try:
+	import pyotp
+	HAS_PYOTP = True
+except ImportError:
+	HAS_PYOTP = False
+	logger.warning("pyotp not installed, TOTP 2FA support disabled")
+
+
+def match_domain(url: str, domain_pattern: str) -> bool:
+	"""
+	检查 URL 是否匹配域名模式
+
+	Args:
+		url: 完整 URL
+		domain_pattern: 域名模式(支持通配符)
+
+	Returns:
+		是否匹配
+	"""
+	from agent.tools.url_matcher import match_url_with_pattern
+	return match_url_with_pattern(url, domain_pattern)
+
+
+def get_applicable_secrets(
+	sensitive_data: Dict[str, Any],
+	current_url: Optional[str]
+) -> Dict[str, Any]:
+	"""
+	获取当前 URL 适用的敏感数据
+
+	Args:
+		sensitive_data: 敏感数据字典,格式:
+			- 旧格式:{key: value}(适用于所有域名)
+			- 新格式:{domain_pattern: {key: value}}(域名特定)
+		current_url: 当前 URL
+
+	Returns:
+		适用的敏感数据字典
+	"""
+	applicable = {}
+
+	for domain_or_key, content in sensitive_data.items():
+		if isinstance(content, dict):
+			# 新格式:{domain_pattern: {key: value}}
+			if current_url:
+				if match_domain(current_url, domain_or_key):
+					applicable.update(content)
+		else:
+			# 旧格式:{key: value}(适用于所有域名)
+			applicable[domain_or_key] = content
+
+	# 过滤空值
+	return {k: v for k, v in applicable.items() if v}
+
+
+def replace_secret_in_string(
+	value: str,
+	applicable_secrets: Dict[str, Any],
+	replaced_placeholders: set,
+	missing_placeholders: set
+) -> str:
+	"""
+	替换字符串中的 <secret>key</secret> 占位符
+
+	Args:
+		value: 原始字符串
+		applicable_secrets: 适用的敏感数据
+		replaced_placeholders: 已替换的占位符集合(输出参数)
+		missing_placeholders: 缺失的占位符集合(输出参数)
+
+	Returns:
+		替换后的字符串
+	"""
+	secret_pattern = re.compile(r'<secret>(.*?)</secret>')
+	matches = secret_pattern.findall(value)
+
+	for placeholder in matches:
+		if placeholder in applicable_secrets:
+			secret_value = applicable_secrets[placeholder]
+
+			# 检查是否是 TOTP 2FA
+			if placeholder.endswith('_bu_2fa_code'):
+				if HAS_PYOTP:
+					try:
+						totp = pyotp.TOTP(secret_value, digits=6)
+						replacement = totp.now()
+						logger.info(f"Generated TOTP code for {placeholder}")
+					except Exception as e:
+						logger.error(f"Failed to generate TOTP for {placeholder}: {e}")
+						replacement = secret_value
+				else:
+					logger.warning(f"TOTP requested for {placeholder} but pyotp not installed")
+					replacement = secret_value
+			else:
+				replacement = secret_value
+
+			# 替换占位符
+			value = value.replace(f'<secret>{placeholder}</secret>', replacement)
+			replaced_placeholders.add(placeholder)
+		else:
+			# 缺失的占位符
+			missing_placeholders.add(placeholder)
+
+	return value
+
+
+def replace_secrets_recursively(
+	value: Any,
+	applicable_secrets: Dict[str, Any],
+	replaced_placeholders: set,
+	missing_placeholders: set
+) -> Any:
+	"""
+	递归替换嵌套结构中的敏感数据占位符
+
+	Args:
+		value: 原始值(可能是字符串、字典、列表等)
+		applicable_secrets: 适用的敏感数据
+		replaced_placeholders: 已替换的占位符集合
+		missing_placeholders: 缺失的占位符集合
+
+	Returns:
+		替换后的值
+	"""
+	if isinstance(value, str):
+		return replace_secret_in_string(
+			value,
+			applicable_secrets,
+			replaced_placeholders,
+			missing_placeholders
+		)
+	elif isinstance(value, dict):
+		return {
+			k: replace_secrets_recursively(
+				v,
+				applicable_secrets,
+				replaced_placeholders,
+				missing_placeholders
+			)
+			for k, v in value.items()
+		}
+	elif isinstance(value, list):
+		return [
+			replace_secrets_recursively(
+				item,
+				applicable_secrets,
+				replaced_placeholders,
+				missing_placeholders
+			)
+			for item in value
+		]
+	else:
+		return value
+
+
+def replace_sensitive_data(
+	arguments: Dict[str, Any],
+	sensitive_data: Dict[str, Any],
+	current_url: Optional[str] = None
+) -> Dict[str, Any]:
+	"""
+	替换工具参数中的敏感数据占位符
+
+	Args:
+		arguments: 工具参数字典
+		sensitive_data: 敏感数据字典
+		current_url: 当前 URL(用于域名匹配)
+
+	Returns:
+		替换后的参数字典
+
+	Example:
+		sensitive_data = {
+			"*.github.com": {
+				"github_token": "ghp_xxxxx",
+				"github_password": "secret123",
+				"github_2fa_bu_2fa_code": "JBSWY3DPEHPK3PXP"
+			}
+		}
+
+		arguments = {
+			"username": "user",
+			"password": "<secret>github_password</secret>",
+			"totp": "<secret>github_2fa_bu_2fa_code</secret>"
+		}
+
+		# 执行替换
+		replaced = replace_sensitive_data(arguments, sensitive_data, "https://github.com")
+
+		# 结果:
+		# {
+		#     "username": "user",
+		#     "password": "secret123",
+		#     "totp": "123456"  # 自动生成的 TOTP 代码
+		# }
+	"""
+	# 获取适用的密钥
+	applicable_secrets = get_applicable_secrets(sensitive_data, current_url)
+
+	if not applicable_secrets:
+		logger.debug("No applicable secrets found for current URL")
+		return arguments
+
+	# 跟踪替换和缺失的占位符
+	replaced_placeholders = set()
+	missing_placeholders = set()
+
+	# 递归替换
+	replaced_arguments = replace_secrets_recursively(
+		arguments,
+		applicable_secrets,
+		replaced_placeholders,
+		missing_placeholders
+	)
+
+	# 记录日志
+	if replaced_placeholders:
+		logger.info(
+			f"Replaced sensitive placeholders: {', '.join(sorted(replaced_placeholders))}"
+			f"{' on ' + current_url if current_url else ''}"
+		)
+
+	if missing_placeholders:
+		logger.warning(
+			f"Missing sensitive data keys: {', '.join(sorted(missing_placeholders))}"
+		)
+
+	return replaced_arguments

+ 142 - 0
agent/tools/url_matcher.py

@@ -0,0 +1,142 @@
+"""
+URL Pattern Matching - 域名模式匹配工具
+
+用于工具的域名过滤功能,支持 glob 模式:
+- *.example.com
+- www.example.*
+- https://*.example.com/path/*
+"""
+
+import re
+from typing import List, Optional
+from urllib.parse import urlparse
+
+
+def normalize_pattern(pattern: str) -> str:
+	"""
+	规范化 URL 模式
+
+	Args:
+		pattern: URL 模式(可能包含协议、通配符等)
+
+	Returns:
+		规范化的模式
+	"""
+	# 如果没有协议,添加通配符协议
+	if not pattern.startswith(("http://", "https://", "*://")):
+		pattern = f"*://{pattern}"
+
+	return pattern
+
+
+def pattern_to_regex(pattern: str) -> re.Pattern:
+	"""
+	将 glob 模式转换为正则表达式
+
+	支持的通配符:
+	- * : 匹配任意字符(不包括 /)
+	- ** : 匹配任意字符(包括 /)
+
+	Args:
+		pattern: glob 模式
+
+	Returns:
+		编译后的正则表达式
+	"""
+	# 转义正则表达式特殊字符
+	regex = re.escape(pattern)
+
+	# 替换通配符
+	regex = regex.replace(r"\*\*", ".__DOUBLE_STAR__")
+	regex = regex.replace(r"\*", r"[^/]*")
+	regex = regex.replace(".__DOUBLE_STAR__", ".*")
+
+	# 添加开始和结束锚点
+	regex = f"^{regex}$"
+
+	return re.compile(regex, re.IGNORECASE)
+
+
+def match_url_with_pattern(url: str, pattern: str) -> bool:
+	"""
+	检查 URL 是否匹配模式
+
+	Args:
+		url: 要检查的 URL
+		pattern: URL 模式(支持通配符)
+
+	Returns:
+		是否匹配
+
+	Examples:
+		>>> match_url_with_pattern("https://google.com", "*.google.com")
+		False
+		>>> match_url_with_pattern("https://www.google.com", "*.google.com")
+		True
+		>>> match_url_with_pattern("https://www.google.co.uk", "www.google.*")
+		True
+		>>> match_url_with_pattern("https://github.com/user/repo", "https://github.com/**")
+		True
+	"""
+	# 规范化模式
+	pattern = normalize_pattern(pattern)
+
+	# 解析 URL
+	parsed_url = urlparse(url)
+
+	# 构建完整 URL 字符串用于匹配
+	url_str = f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}"
+	if parsed_url.query:
+		url_str += f"?{parsed_url.query}"
+
+	# 转换为正则并匹配
+	regex = pattern_to_regex(pattern)
+	return bool(regex.match(url_str))
+
+
+def match_url_with_patterns(url: str, patterns: List[str]) -> bool:
+	"""
+	检查 URL 是否匹配任一模式
+
+	Args:
+		url: 要检查的 URL
+		patterns: URL 模式列表
+
+	Returns:
+		是否匹配任一模式
+	"""
+	return any(match_url_with_pattern(url, pattern) for pattern in patterns)
+
+
+def filter_by_url(
+	items: List[dict],
+	current_url: Optional[str],
+	url_field: str = "url_patterns"
+) -> List[dict]:
+	"""
+	根据 URL 过滤项目列表
+
+	Args:
+		items: 项目列表(每个包含 url_patterns 字段)
+		current_url: 当前 URL(None = 只返回无 URL 限制的项)
+		url_field: URL 模式字段名
+
+	Returns:
+		过滤后的项目列表
+	"""
+	if current_url is None:
+		# 没有 URL 上下文,只返回无 URL 限制的项
+		return [item for item in items if not item.get(url_field)]
+
+	# 有 URL 上下文,返回匹配的项
+	filtered = []
+	for item in items:
+		patterns = item.get(url_field)
+		if patterns is None:
+			# 无 URL 限制,总是包含
+			filtered.append(item)
+		elif match_url_with_patterns(current_url, patterns):
+			# 匹配 URL,包含
+			filtered.append(item)
+
+	return filtered

+ 34 - 0
agent/trace/__init__.py

@@ -0,0 +1,34 @@
+"""
+Trace module - 执行追踪与计划管理
+
+包含:
+- Trace, Goal, Message 数据模型
+- TraceStore 存储接口和实现
+- goal 工具(计划管理)
+- Context 压缩
+- REST/WebSocket API
+"""
+
+from .models import Trace, Message
+from .goal_models import Goal, GoalTree, GoalStatus, GoalType, GoalStats
+from .protocols import TraceStore
+from .store import FileSystemTraceStore
+from .trace_id import generate_trace_id, generate_sub_trace_id, parse_parent_trace_id
+
+__all__ = [
+    # Models
+    "Trace",
+    "Message",
+    "Goal",
+    "GoalTree",
+    "GoalStatus",
+    "GoalType",
+    "GoalStats",
+    # Store
+    "TraceStore",
+    "FileSystemTraceStore",
+    # Utils
+    "generate_trace_id",
+    "generate_sub_trace_id",
+    "parse_parent_trace_id",
+]

+ 173 - 0
agent/trace/api.py

@@ -0,0 +1,173 @@
+"""
+Trace RESTful API
+
+提供 Trace、GoalTree、Message 的查询接口
+"""
+
+from typing import List, Optional, Dict, Any
+from fastapi import APIRouter, HTTPException, Query
+from pydantic import BaseModel
+
+from .protocols import TraceStore
+
+
+router = APIRouter(prefix="/api/traces", tags=["traces"])
+
+
+# ===== Response 模型 =====
+
+
+class TraceListResponse(BaseModel):
+    """Trace 列表响应"""
+    traces: List[Dict[str, Any]]
+
+
+class TraceDetailResponse(BaseModel):
+    """Trace 详情响应(包含 GoalTree 和 Sub-Traces 元数据)"""
+    trace: Dict[str, Any]
+    goal_tree: Optional[Dict[str, Any]] = None
+    sub_traces: Dict[str, Dict[str, Any]] = {}
+
+
+class MessagesResponse(BaseModel):
+    """Messages 响应"""
+    messages: List[Dict[str, Any]]
+
+
+# ===== 全局 TraceStore(由 api_server.py 注入)=====
+
+
+_trace_store: Optional[TraceStore] = None
+
+
+def set_trace_store(store: TraceStore):
+    """设置 TraceStore 实例"""
+    global _trace_store
+    _trace_store = store
+
+
+def get_trace_store() -> TraceStore:
+    """获取 TraceStore 实例"""
+    if _trace_store is None:
+        raise RuntimeError("TraceStore not initialized")
+    return _trace_store
+
+
+# ===== 路由 =====
+
+
+@router.get("", response_model=TraceListResponse)
+async def list_traces(
+    mode: Optional[str] = None,
+    agent_type: Optional[str] = None,
+    uid: Optional[str] = None,
+    status: Optional[str] = None,
+    limit: int = Query(20, le=100)
+):
+    """
+    列出 Traces
+
+    Args:
+        mode: 模式过滤(call/agent)
+        agent_type: Agent 类型过滤
+        uid: 用户 ID 过滤
+        status: 状态过滤(running/completed/failed)
+        limit: 最大返回数量
+    """
+    store = get_trace_store()
+    traces = await store.list_traces(
+        mode=mode,
+        agent_type=agent_type,
+        uid=uid,
+        status=status,
+        limit=limit
+    )
+    return TraceListResponse(
+        traces=[t.to_dict() for t in traces]
+    )
+
+
+@router.get("/{trace_id}", response_model=TraceDetailResponse)
+async def get_trace(trace_id: str):
+    """
+    获取 Trace 详情
+
+    返回 Trace 元数据、GoalTree、Sub-Traces 元数据(不含 Sub-Trace 内 GoalTree)
+
+    Args:
+        trace_id: Trace ID
+    """
+    store = get_trace_store()
+
+    # 获取 Trace
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        raise HTTPException(status_code=404, detail="Trace not found")
+
+    # 获取 GoalTree
+    goal_tree = await store.get_goal_tree(trace_id)
+
+    # 获取所有 Sub-Traces(通过 parent_trace_id 查询)
+    sub_traces = {}
+    all_traces = await store.list_traces(limit=1000)  # 获取所有 traces
+    for t in all_traces:
+        if t.parent_trace_id == trace_id:
+            sub_traces[t.trace_id] = t.to_dict()
+
+    return TraceDetailResponse(
+        trace=trace.to_dict(),
+        goal_tree=goal_tree.to_dict() if goal_tree else None,
+        sub_traces=sub_traces
+    )
+
+
+@router.get("/{trace_id}/messages", response_model=MessagesResponse)
+async def get_messages(
+    trace_id: str,
+    mode: str = Query("main_path", description="查询模式:main_path(当前主路径消息)或 all(全部消息含所有分支)"),
+    head: Optional[int] = Query(None, description="主路径的 head sequence(仅 mode=main_path 有效,默认用 trace.head_sequence)"),
+    goal_id: Optional[str] = Query(None, description="过滤指定 Goal 的消息。使用 '_init' 查询初始阶段(goal_id=None)的消息"),
+):
+    """
+    获取 Messages
+
+    Args:
+        trace_id: Trace ID
+        mode: 查询模式
+              - "main_path"(默认): 从 head 沿 parent_sequence 链回溯的主路径消息
+              - "all": 返回所有消息(包含所有分支)
+        head: 可选,指定主路径的 head sequence(仅 mode=main_path 有效)
+        goal_id: 可选,过滤指定 Goal 的消息
+                - 不指定: 不按 goal 过滤
+                - "_init" 或 "null": 返回初始阶段(goal_id=None)的消息
+                - 其他值: 返回指定 Goal 的消息
+    """
+    store = get_trace_store()
+
+    # 验证 Trace 存在
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        raise HTTPException(status_code=404, detail="Trace not found")
+
+    # 获取 Messages
+    if goal_id and goal_id not in ("_init", "null"):
+        # 按 Goal 过滤(独立查询)
+        messages = await store.get_messages_by_goal(trace_id, goal_id)
+    elif mode == "main_path":
+        # 主路径模式
+        head_seq = head if head is not None else trace.head_sequence
+        if head_seq > 0:
+            messages = await store.get_main_path_messages(trace_id, head_seq)
+        else:
+            messages = []
+    else:
+        # all 模式:返回所有消息
+        messages = await store.get_trace_messages(trace_id)
+
+    # goal_id 过滤(_init 表示 goal_id=None 的消息)
+    if goal_id in ("_init", "null"):
+        messages = [m for m in messages if m.goal_id is None]
+
+    return MessagesResponse(
+        messages=[m.to_dict() for m in messages]
+    )

+ 323 - 0
agent/trace/compaction.py

@@ -0,0 +1,323 @@
+"""
+Context 压缩 — 两级压缩策略
+
+Level 1: GoalTree 过滤(确定性,零成本)
+  - 跳过 completed/abandoned goals 的消息(信息已在 GoalTree summary 中)
+  - 始终保留:system prompt、第一条 user message、当前 focus goal 的消息
+
+Level 2: LLM 总结(仅在 Level 1 后仍超限时触发)
+  - 在消息列表末尾追加压缩 prompt → 主模型回复 → summary 存为新消息
+  - summary 的 parent_sequence 跳过被压缩的范围
+
+压缩不修改存储:原始消息永远保留在 messages/,通过 parent_sequence 树结构实现跳过。
+"""
+
+import logging
+from dataclasses import dataclass
+from typing import List, Dict, Any, Optional, Set
+
+from .goal_models import GoalTree
+from .models import Message
+from agent.core.prompts import (
+    COMPRESSION_EVAL_PROMPT_TEMPLATE,
+    REFLECT_PROMPT,
+    build_compression_eval_prompt,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# ===== 模型 Context Window(tokens)=====
+
+MODEL_CONTEXT_WINDOWS: Dict[str, int] = {
+    # Anthropic Claude
+    "claude-sonnet-4": 200_000,
+    "claude-opus-4": 200_000,
+    "claude-3-5-sonnet": 200_000,
+    "claude-3-5-haiku": 200_000,
+    "claude-3-opus": 200_000,
+    "claude-3-sonnet": 200_000,
+    "claude-3-haiku": 200_000,
+    # OpenAI
+    "gpt-4o": 128_000,
+    "gpt-4o-mini": 128_000,
+    "gpt-4-turbo": 128_000,
+    "gpt-4": 8_192,
+    "o1": 200_000,
+    "o3-mini": 200_000,
+    # Google Gemini
+    "gemini-2.5-pro": 1_000_000,
+    "gemini-2.5-flash": 1_000_000,
+    "gemini-2.0-flash": 1_000_000,
+    "gemini-1.5-pro": 2_000_000,
+    "gemini-1.5-flash": 1_000_000,
+    # DeepSeek
+    "deepseek-chat": 64_000,
+    "deepseek-r1": 64_000,
+}
+
+DEFAULT_CONTEXT_WINDOW = 200_000
+
+
+def get_context_window(model: str) -> int:
+    """
+    根据模型名称获取 context window 大小。
+
+    支持带 provider 前缀的模型名(如 "anthropic/claude-sonnet-4.5")和
+    带版本后缀的名称(如 "claude-3-5-sonnet-20241022")。
+    """
+    # 去掉 provider 前缀
+    name = model.split("/")[-1].lower()
+
+    # 精确匹配
+    if name in MODEL_CONTEXT_WINDOWS:
+        return MODEL_CONTEXT_WINDOWS[name]
+
+    # 前缀匹配(处理版本后缀)
+    for key, window in MODEL_CONTEXT_WINDOWS.items():
+        if name.startswith(key):
+            return window
+
+    return DEFAULT_CONTEXT_WINDOW
+
+
+# ===== 配置 =====
+
+@dataclass
+class CompressionConfig:
+    """压缩配置"""
+    max_tokens: int = 0                # 最大 token 数(0 = 自动:context_window * 0.5)
+    threshold_ratio: float = 0.5       # 触发压缩的阈值 = context_window 的比例
+    keep_recent_messages: int = 10     # Level 1 中始终保留最近 N 条消息
+    max_messages: int = 50             # 最大消息数(超过此数量触发压缩,0 = 禁用)
+
+    def get_max_tokens(self, model: str) -> int:
+        """获取实际的 max_tokens(如果为 0 则自动计算)"""
+        if self.max_tokens > 0:
+            return self.max_tokens
+        window = get_context_window(model)
+        return int(window * self.threshold_ratio)
+
+
+# ===== Level 1: GoalTree 过滤 =====
+
+def filter_by_goal_status(
+    messages: List[Message],
+    goal_tree: Optional[GoalTree],
+) -> List[Message]:
+    """
+    Level 1 过滤:跳过 completed/abandoned goals 的消息
+
+    始终保留:
+    - goal_id 为 None 的消息(system prompt、初始 user message)
+    - 当前 focus goal 及其祖先链上的消息
+    - in_progress 和 pending goals 的消息
+
+    跳过:
+    - completed 且不在焦点路径上的 goals 的消息
+    - abandoned goals 的消息
+
+    Args:
+        messages: 主路径上的有序消息列表
+        goal_tree: GoalTree 实例
+
+    Returns:
+        过滤后的消息列表
+    """
+    if not goal_tree or not goal_tree.goals:
+        return messages
+
+    # 构建焦点路径(当前焦点 + 父链 + 直接子节点)
+    focus_path = _get_focus_path(goal_tree)
+
+    # 构建需要跳过的 goal IDs
+    skip_goal_ids: Set[str] = set()
+    for goal in goal_tree.goals:
+        if goal.id in focus_path:
+            continue  # 焦点路径上的 goal 始终保留
+        if goal.status in ("completed", "abandoned"):
+            skip_goal_ids.add(goal.id)
+
+    # 过滤消息
+    result = []
+    for msg in messages:
+        if msg.goal_id is None:
+            result.append(msg)  # 无 goal 的消息始终保留
+        elif msg.goal_id not in skip_goal_ids:
+            result.append(msg)  # 不在跳过列表中的消息保留
+
+    return result
+
+
+def _get_focus_path(goal_tree: GoalTree) -> Set[str]:
+    """
+    获取焦点路径上需要保留消息的 goal IDs
+
+    保留:焦点自身 + 父链 + 未完成的直接子节点
+    不保留:已完成/已放弃的直接子节点(信息已在 goal.summary 中)
+    """
+    focus_ids: Set[str] = set()
+
+    if not goal_tree.current_id:
+        return focus_ids
+
+    # 焦点自身
+    focus_ids.add(goal_tree.current_id)
+
+    # 父链
+    goal = goal_tree.find(goal_tree.current_id)
+    while goal and goal.parent_id:
+        focus_ids.add(goal.parent_id)
+        goal = goal_tree.find(goal.parent_id)
+
+    # 直接子节点:仅保留未完成的(completed/abandoned 的信息已在 summary 中)
+    children = goal_tree.get_children(goal_tree.current_id)
+    for child in children:
+        if child.status not in ("completed", "abandoned"):
+            focus_ids.add(child.id)
+
+    return focus_ids
+
+
+# ===== Token 估算 =====
+
+def estimate_tokens(messages: List[Dict[str, Any]]) -> int:
+    """
+    估算消息列表的 token 数量
+
+    对 CJK 字符和 ASCII 字符使用不同的估算系数:
+    - ASCII/Latin 字符:~4 字符 ≈ 1 token
+    - CJK 字符(中日韩):~1 字符 ≈ 1.5 tokens(BPE tokenizer 特性)
+    """
+    total_tokens = 0
+    for msg in messages:
+        content = msg.get("content", "")
+        if isinstance(content, str):
+            total_tokens += _estimate_text_tokens(content)
+        elif isinstance(content, list):
+            for part in content:
+                if isinstance(part, dict):
+                    if part.get("type") == "text":
+                        total_tokens += _estimate_text_tokens(part.get("text", ""))
+                    elif part.get("type") in ("image_url", "image"):
+                        total_tokens += _estimate_image_tokens(part)
+        # tool_calls
+        tool_calls = msg.get("tool_calls")
+        if tool_calls and isinstance(tool_calls, list):
+            for tc in tool_calls:
+                if isinstance(tc, dict):
+                    func = tc.get("function", {})
+                    total_tokens += len(func.get("name", "")) // 4
+                    args = func.get("arguments", "")
+                    if isinstance(args, str):
+                        total_tokens += _estimate_text_tokens(args)
+
+    return total_tokens
+
+
+def _estimate_text_tokens(text: str) -> int:
+    """
+    估算文本的 token 数,区分 CJK 和 ASCII 字符。
+
+    CJK 字符在 BPE tokenizer 中通常占 1.5-2 tokens,
+    ASCII 字符约 4 个对应 1 token。
+    """
+    if not text:
+        return 0
+    cjk_chars = 0
+    other_chars = 0
+    for ch in text:
+        if _is_cjk(ch):
+            cjk_chars += 1
+        else:
+            other_chars += 1
+    # CJK: 1 char ≈ 1.5 tokens; ASCII: 4 chars ≈ 1 token
+    return int(cjk_chars * 1.5) + other_chars // 4
+
+
+def _estimate_image_tokens(block: Dict[str, Any]) -> int:
+    """
+    估算图片块的 token 消耗。
+
+    Anthropic 计算方式:tokens = (width * height) / 750
+    优先从 _image_meta 读取真实尺寸,其次从 base64 数据量粗估,最小 1600 tokens。
+    """
+    MIN_IMAGE_TOKENS = 1600
+
+    # 优先使用 _image_meta 中的真实尺寸
+    meta = block.get("_image_meta")
+    if meta and meta.get("width") and meta.get("height"):
+        tokens = (meta["width"] * meta["height"]) // 750
+        return max(MIN_IMAGE_TOKENS, tokens)
+
+    # 回退:从 base64 数据长度粗估
+    b64_data = ""
+    if block.get("type") == "image":
+        source = block.get("source", {})
+        if source.get("type") == "base64":
+            b64_data = source.get("data", "")
+    elif block.get("type") == "image_url":
+        url_obj = block.get("image_url", {})
+        url = url_obj.get("url", "") if isinstance(url_obj, dict) else str(url_obj)
+        if url.startswith("data:"):
+            _, _, b64_data = url.partition(",")
+
+    if b64_data:
+        # base64 编码后大小约为原始字节的 4/3
+        raw_bytes = len(b64_data) * 3 // 4
+        # 粗估:假设 JPEG 压缩率 ~10:1,像素数 ≈ raw_bytes * 10 / 3 (RGB)
+        estimated_pixels = raw_bytes * 10 // 3
+        estimated_tokens = estimated_pixels // 750
+        return max(MIN_IMAGE_TOKENS, estimated_tokens)
+
+    return MIN_IMAGE_TOKENS
+
+
+def _is_cjk(ch: str) -> bool:
+    """判断字符是否为 CJK(中日韩)字符"""
+    cp = ord(ch)
+    return (
+        0x2E80 <= cp <= 0x9FFF       # CJK 基本区 + 部首 + 笔画 + 兼容
+        or 0xF900 <= cp <= 0xFAFF    # CJK 兼容表意文字
+        or 0xFE30 <= cp <= 0xFE4F    # CJK 兼容形式
+        or 0x20000 <= cp <= 0x2FA1F  # CJK 扩展 B-F + 兼容补充
+        or 0x3000 <= cp <= 0x303F   # CJK 标点符号
+        or 0xFF00 <= cp <= 0xFFEF   # 全角字符
+    )
+
+
+def estimate_tokens_from_messages(messages: List[Message]) -> int:
+    """从 Message 对象列表估算 token 数"""
+    return estimate_tokens([msg.to_llm_dict() for msg in messages])
+
+
+def needs_level2_compression(
+    token_count: int,
+    config: CompressionConfig,
+    model: str = "",
+) -> bool:
+    """判断是否需要触发 Level 2 压缩"""
+    limit = config.get_max_tokens(model) if model else config.max_tokens
+    return token_count > limit
+
+
+# ===== Level 2: 压缩 Prompt =====
+# 注意:这些 prompt 已迁移到 agent.core.prompts
+# COMPRESSION_EVAL_PROMPT 和 REFLECT_PROMPT 现在从 prompts.py 导入
+
+
+def build_compression_prompt(goal_tree: Optional[GoalTree]) -> str:
+    """构建 Level 2 压缩 prompt"""
+    goal_prompt = ""
+    if goal_tree:
+        goal_prompt = goal_tree.to_prompt(include_summary=True)
+
+    return build_compression_eval_prompt(
+        goal_tree_prompt=goal_prompt,
+    )
+
+
+def build_reflect_prompt() -> str:
+    """构建反思 prompt"""
+    return REFLECT_PROMPT
+

+ 144 - 0
agent/trace/examples_api.py

@@ -0,0 +1,144 @@
+"""
+Examples API - 提供 examples 项目列表和 prompt 读取接口
+"""
+
+import os
+from typing import List, Optional
+from pathlib import Path
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+
+router = APIRouter(prefix="/api/examples", tags=["examples"])
+
+
+class ExampleProject(BaseModel):
+    """Example 项目信息"""
+    name: str
+    path: str
+    has_prompt: bool
+
+
+class ExampleListResponse(BaseModel):
+    """Example 列表响应"""
+    projects: List[ExampleProject]
+
+
+class PromptResponse(BaseModel):
+    """Prompt 响应"""
+    system_prompt: str
+    user_prompt: str
+    model: Optional[str] = None
+    temperature: Optional[float] = None
+
+
+# 配置 examples 目录路径
+EXAMPLES_DIR = Path("examples")
+
+
+@router.get("", response_model=ExampleListResponse)
+async def list_examples():
+    """
+    列出所有 example 项目
+
+    扫描 examples 目录,返回所有子目录及其 prompt 文件状态
+    """
+    if not EXAMPLES_DIR.exists():
+        return ExampleListResponse(projects=[])
+
+    projects = []
+    for item in EXAMPLES_DIR.iterdir():
+        if item.is_dir():
+            # 检查是否有 prompt 文件
+            prompt_file = item / "production.prompt"
+            has_prompt = prompt_file.exists()
+
+            projects.append(ExampleProject(
+                name=item.name,
+                path=str(item),
+                has_prompt=has_prompt
+            ))
+
+    # 按名称排序
+    projects.sort(key=lambda x: x.name)
+
+    return ExampleListResponse(projects=projects)
+
+
+@router.get("/{project_name}/prompt", response_model=PromptResponse)
+async def get_example_prompt(project_name: str):
+    """
+    获取指定 example 项目的 prompt
+
+    读取 production.prompt 文件,解析 frontmatter 和内容
+    """
+    project_path = EXAMPLES_DIR / project_name
+    if not project_path.exists() or not project_path.is_dir():
+        raise HTTPException(status_code=404, detail=f"Project not found: {project_name}")
+
+    prompt_file = project_path / "production.prompt"
+    if not prompt_file.exists():
+        raise HTTPException(status_code=404, detail=f"Prompt file not found for project: {project_name}")
+
+    try:
+        content = prompt_file.read_text(encoding="utf-8")
+
+        # 解析 frontmatter 和内容
+        system_prompt = ""
+        user_prompt = ""
+        model = None
+        temperature = None
+
+        # 检查是否有 frontmatter
+        if content.startswith("---"):
+            parts = content.split("---", 2)
+            if len(parts) >= 3:
+                frontmatter = parts[1].strip()
+                body = parts[2].strip()
+
+                # 解析 frontmatter
+                for line in frontmatter.split("\n"):
+                    if ":" in line:
+                        key, value = line.split(":", 1)
+                        key = key.strip()
+                        value = value.strip()
+                        if key == "model":
+                            model = value
+                        elif key == "temperature":
+                            try:
+                                temperature = float(value)
+                            except ValueError:
+                                pass
+            else:
+                body = content
+        else:
+            body = content
+
+        # 解析 $system$ 和 $user$ 部分
+        if "$system$" in body:
+            parts = body.split("$system$", 1)
+            if len(parts) > 1:
+                rest = parts[1]
+                if "$user$" in rest:
+                    system_part, user_part = rest.split("$user$", 1)
+                    system_prompt = system_part.strip()
+                    user_prompt = user_part.strip()
+                else:
+                    system_prompt = rest.strip()
+        elif "$user$" in body:
+            parts = body.split("$user$", 1)
+            if len(parts) > 1:
+                user_prompt = parts[1].strip()
+        else:
+            # 没有标记,全部作为 user_prompt
+            user_prompt = body.strip()
+
+        return PromptResponse(
+            system_prompt=system_prompt,
+            user_prompt=user_prompt,
+            model=model,
+            temperature=temperature
+        )
+
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Failed to read prompt file: {str(e)}")

+ 543 - 0
agent/trace/goal_models.py

@@ -0,0 +1,543 @@
+"""
+Goal 数据模型
+
+Goal: 执行计划中的目标节点
+GoalTree: 目标树,管理整个执行计划
+GoalStats: 目标统计信息
+"""
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Dict, Any, List, Optional, Literal
+import json
+
+
+# Goal 状态
+GoalStatus = Literal["pending", "in_progress", "completed", "abandoned"]
+
+# Goal 类型
+GoalType = Literal["normal", "agent_call"]
+
+
+@dataclass
+class GoalStats:
+    """目标统计信息"""
+    message_count: int = 0               # 消息数量
+    total_tokens: int = 0                # Token 总数
+    total_cost: float = 0.0              # 总成本
+    preview: Optional[str] = None        # 工具调用摘要,如 "read_file → edit_file → bash"
+
+    def to_dict(self) -> Dict[str, Any]:
+        return {
+            "message_count": self.message_count,
+            "total_tokens": self.total_tokens,
+            "total_cost": self.total_cost,
+            "preview": self.preview,
+        }
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "GoalStats":
+        return cls(
+            message_count=data.get("message_count", 0),
+            total_tokens=data.get("total_tokens", 0),
+            total_cost=data.get("total_cost", 0.0),
+            preview=data.get("preview"),
+        )
+
+
+@dataclass
+class Goal:
+    """
+    执行目标
+
+    使用扁平列表 + parent_id 构建层级结构。
+    agent_call 类型用于标记启动了 Sub-Trace 的 Goal。
+    """
+    id: str                                  # 内部唯一 ID,纯自增("1", "2", "3"...)
+    description: str                         # 目标描述
+    reason: str = ""                         # 创建理由(为什么做)
+    parent_id: Optional[str] = None          # 父 Goal ID(层级关系)
+    type: GoalType = "normal"                # Goal 类型
+    status: GoalStatus = "pending"           # 状态
+    summary: Optional[str] = None            # 完成/放弃时的总结
+
+    # agent_call 特有
+    sub_trace_ids: Optional[List[Dict[str, str]]] = None      # 启动的 Sub-Trace 信息 [{"trace_id": "...", "mission": "..."}]
+    agent_call_mode: Optional[str] = None          # "explore" | "delegate" | "sequential"
+    sub_trace_metadata: Optional[Dict[str, Dict[str, Any]]] = None  # Sub-Trace 元数据
+
+    # 统计(后端维护,用于可视化边的数据)
+    self_stats: GoalStats = field(default_factory=GoalStats)          # 自身统计(仅直接关联的 messages)
+    cumulative_stats: GoalStats = field(default_factory=GoalStats)    # 累计统计(自身 + 所有后代)
+
+    # 相关知识(自动检索注入)
+    knowledge: Optional[List[Dict[str, Any]]] = None                  # 相关知识列表
+
+    created_at: datetime = field(default_factory=datetime.now)
+
+    def to_dict(self) -> Dict[str, Any]:
+        """转换为字典"""
+        return {
+            "id": self.id,
+            "description": self.description,
+            "reason": self.reason,
+            "parent_id": self.parent_id,
+            "type": self.type,
+            "status": self.status,
+            "summary": self.summary,
+            "sub_trace_ids": self.sub_trace_ids,
+            "agent_call_mode": self.agent_call_mode,
+            "sub_trace_metadata": self.sub_trace_metadata,
+            "self_stats": self.self_stats.to_dict(),
+            "cumulative_stats": self.cumulative_stats.to_dict(),
+            "knowledge": self.knowledge,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+        }
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "Goal":
+        """从字典创建"""
+        created_at = data.get("created_at")
+        if isinstance(created_at, str):
+            created_at = datetime.fromisoformat(created_at)
+
+        self_stats = data.get("self_stats", {})
+        if isinstance(self_stats, dict):
+            self_stats = GoalStats.from_dict(self_stats)
+
+        cumulative_stats = data.get("cumulative_stats", {})
+        if isinstance(cumulative_stats, dict):
+            cumulative_stats = GoalStats.from_dict(cumulative_stats)
+
+        return cls(
+            id=data["id"],
+            description=data["description"],
+            reason=data.get("reason", ""),
+            parent_id=data.get("parent_id"),
+            type=data.get("type", "normal"),
+            status=data.get("status", "pending"),
+            summary=data.get("summary"),
+            sub_trace_ids=data.get("sub_trace_ids"),
+            agent_call_mode=data.get("agent_call_mode"),
+            sub_trace_metadata=data.get("sub_trace_metadata"),
+            self_stats=self_stats,
+            cumulative_stats=cumulative_stats,
+            knowledge=data.get("knowledge"),
+            created_at=created_at or datetime.now(),
+        )
+
+
+@dataclass
+class GoalTree:
+    """
+    目标树 - 管理整个执行计划
+
+    使用扁平列表 + parent_id 构建层级结构
+    """
+    mission: str                             # 总任务描述
+    goals: List[Goal] = field(default_factory=list)  # 扁平列表(通过 parent_id 构建层级)
+    current_id: Optional[str] = None         # 当前焦点 goal ID
+    _next_id: int = 1                        # 内部 ID 计数器(私有字段)
+    created_at: datetime = field(default_factory=datetime.now)
+
+    def find(self, goal_id: str) -> Optional[Goal]:
+        """按 ID 查找 Goal"""
+        for goal in self.goals:
+            if goal.id == goal_id:
+                return goal
+        return None
+
+    def find_by_display_id(self, display_id: str) -> Optional[Goal]:
+        """按显示 ID 查找 Goal(如 "1", "2.1", "2.2")"""
+        for goal in self.goals:
+            if self._generate_display_id(goal) == display_id:
+                return goal
+        return None
+
+    def find_parent(self, goal_id: str) -> Optional[Goal]:
+        """查找指定 Goal 的父节点"""
+        goal = self.find(goal_id)
+        if not goal or not goal.parent_id:
+            return None
+        return self.find(goal.parent_id)
+
+    def get_children(self, parent_id: Optional[str]) -> List[Goal]:
+        """获取指定父节点的所有子节点"""
+        return [g for g in self.goals if g.parent_id == parent_id]
+
+    def get_current(self) -> Optional[Goal]:
+        """获取当前焦点 Goal"""
+        if self.current_id:
+            return self.find(self.current_id)
+        return None
+
+    def _generate_id(self) -> str:
+        """生成新的 Goal ID(纯自增)"""
+        new_id = str(self._next_id)
+        self._next_id += 1
+        return new_id
+
+    def _generate_display_id(self, goal: Goal) -> str:
+        """生成显示序号(1, 2, 2.1, 2.2...)"""
+        if not goal.parent_id:
+            # 顶层目标:找到在同级中的序号
+            siblings = [g for g in self.goals if g.parent_id is None and g.status != "abandoned"]
+            try:
+                index = [g.id for g in siblings].index(goal.id) + 1
+                return str(index)
+            except ValueError:
+                return "?"
+        else:
+            # 子目标:父序号 + "." + 在同级中的序号
+            parent = self.find(goal.parent_id)
+            if not parent:
+                return "?"
+            parent_display = self._generate_display_id(parent)
+            siblings = [g for g in self.goals if g.parent_id == goal.parent_id and g.status != "abandoned"]
+            try:
+                index = [g.id for g in siblings].index(goal.id) + 1
+                return f"{parent_display}.{index}"
+            except ValueError:
+                return f"{parent_display}.?"
+
+    def add_goals(
+        self,
+        descriptions: List[str],
+        reasons: Optional[List[str]] = None,
+        parent_id: Optional[str] = None
+    ) -> List[Goal]:
+        """
+        添加目标
+
+        如果 parent_id 为 None,添加到顶层
+        如果 parent_id 有值,添加为该 goal 的子目标
+        """
+        if parent_id:
+            parent = self.find(parent_id)
+            if not parent:
+                raise ValueError(f"Parent goal not found: {parent_id}")
+
+        # 创建新目标
+        new_goals = []
+        for i, desc in enumerate(descriptions):
+            goal_id = self._generate_id()
+            reason = reasons[i] if reasons and i < len(reasons) else ""
+            goal = Goal(
+                id=goal_id,
+                description=desc.strip(),
+                reason=reason,
+                parent_id=parent_id
+            )
+            self.goals.append(goal)
+            new_goals.append(goal)
+
+        return new_goals
+
+    def add_goals_after(
+        self,
+        target_id: str,
+        descriptions: List[str],
+        reasons: Optional[List[str]] = None
+    ) -> List[Goal]:
+        """
+        在指定 Goal 后面添加兄弟节点
+
+        新创建的 goals 与 target 有相同的 parent_id,
+        并插入到 goals 列表中 target 的后面。
+        """
+        target = self.find(target_id)
+        if not target:
+            raise ValueError(f"Target goal not found: {target_id}")
+
+        # 创建新 goals(parent_id 与 target 相同)
+        new_goals = []
+        for i, desc in enumerate(descriptions):
+            goal_id = self._generate_id()
+            reason = reasons[i] if reasons and i < len(reasons) else ""
+            goal = Goal(
+                id=goal_id,
+                description=desc.strip(),
+                reason=reason,
+                parent_id=target.parent_id  # 同层级
+            )
+            new_goals.append(goal)
+
+        # 插入到 target 后面(调整 goals 列表顺序)
+        target_index = self.goals.index(target)
+        for i, goal in enumerate(new_goals):
+            self.goals.insert(target_index + 1 + i, goal)
+
+        return new_goals
+
+    def focus(self, goal_id: str) -> Goal:
+        """切换焦点到指定 Goal,并将其状态设为 in_progress"""
+        goal = self.find(goal_id)
+        if not goal:
+            raise ValueError(f"Goal not found: {goal_id}")
+
+        # 更新状态
+        if goal.status == "pending":
+            goal.status = "in_progress"
+
+        self.current_id = goal_id
+        return goal
+
+    def complete(self, goal_id: str, summary: str, clear_focus: bool = True) -> Goal:
+        """
+        完成指定 Goal
+
+        Args:
+            goal_id: 要完成的目标 ID
+            summary: 完成总结
+            clear_focus: 如果完成的是当前焦点,是否清除焦点(默认 True)
+        """
+        goal = self.find(goal_id)
+        if not goal:
+            raise ValueError(f"Goal not found: {goal_id}")
+
+        goal.status = "completed"
+        goal.summary = summary
+
+        # 如果完成的是当前焦点,根据参数决定是否清除焦点
+        if clear_focus and self.current_id == goal_id:
+            self.current_id = None
+
+        # 检查是否所有兄弟都完成了,如果是则自动完成父节点
+        if goal.parent_id:
+            siblings = self.get_children(goal.parent_id)
+            all_completed = all(g.status == "completed" for g in siblings)
+            if all_completed:
+                parent = self.find(goal.parent_id)
+                if parent and parent.status != "completed":
+                    # 自动级联完成父节点
+                    parent.status = "completed"
+                    if not parent.summary:
+                        parent.summary = "所有子目标已完成"
+
+        return goal
+
+    def abandon(self, goal_id: str, reason: str) -> Goal:
+        """放弃指定 Goal"""
+        goal = self.find(goal_id)
+        if not goal:
+            raise ValueError(f"Goal not found: {goal_id}")
+
+        goal.status = "abandoned"
+        goal.summary = reason
+
+        # 如果放弃的是当前焦点,清除焦点
+        if self.current_id == goal_id:
+            self.current_id = None
+
+        return goal
+
+    def to_prompt(self, include_abandoned: bool = False, include_summary: bool = False) -> str:
+        """
+        格式化为 Prompt 注入文本
+
+        Args:
+            include_abandoned: 是否包含已废弃的目标
+            include_summary: 是否显示 completed/abandoned goals 的 summary 详情
+                False(默认)= 精简视图,用于日常周期性注入
+                True = 完整视图(含 summary),用于压缩时提供上下文
+
+        展示策略:
+        - 过滤掉 abandoned 目标(除非明确要求)
+        - 完整展示所有顶层目标
+        - 完整展示当前 focus 目标的父链及其所有子孙
+        - 其他分支的子目标折叠显示(只显示数量和状态)
+        - include_summary=True 时不折叠,全部展开并显示 summary
+        """
+        lines = []
+        lines.append(f"**Mission**: {self.mission}")
+
+        if self.current_id:
+            current = self.find(self.current_id)
+            if current:
+                display_id = self._generate_display_id(current)
+                lines.append(f"**Current**: {display_id} {current.description}")
+
+        lines.append("")
+        lines.append("**Progress**:")
+
+        # 获取当前焦点的祖先链(从根到当前节点的路径)
+        current_path = set()
+        if self.current_id:
+            goal = self.find(self.current_id)
+            while goal:
+                current_path.add(goal.id)
+                if goal.parent_id:
+                    goal = self.find(goal.parent_id)
+                else:
+                    break
+
+        def format_goal(goal: Goal, indent: int = 0) -> List[str]:
+            # 跳过废弃的目标(除非明确要求包含)
+            if goal.status == "abandoned" and not include_abandoned:
+                return []
+
+            prefix = "    " * indent
+
+            # 状态图标
+            if goal.status == "completed":
+                icon = "[✓]"
+            elif goal.status == "in_progress":
+                icon = "[→]"
+            elif goal.status == "abandoned":
+                icon = "[✗]"
+            else:
+                icon = "[ ]"
+
+            # 生成显示序号
+            display_id = self._generate_display_id(goal)
+
+            # 当前焦点标记
+            current_mark = " ← current" if goal.id == self.current_id else ""
+
+            result = [f"{prefix}{icon} {display_id}. {goal.description}{current_mark}"]
+
+            # 显示 summary:include_summary=True 时全部显示,否则只在焦点路径上显示
+            if goal.summary and (include_summary or goal.id in current_path):
+                result.append(f"{prefix}    → {goal.summary}")
+
+            # 显示相关知识:仅在当前焦点 goal 显示
+            if goal.id == self.current_id and goal.knowledge:
+                result.append(f"{prefix}    📚 相关知识 ({len(goal.knowledge)} 条):")
+                for idx, k in enumerate(goal.knowledge[:3], 1):
+                    k_id = k.get('id', 'N/A')
+                    # 将多行内容压缩为单行摘要
+                    k_content = k.get('content', '').replace('\n', ' ').strip()[:80]
+                    result.append(f"{prefix}       {idx}. [{k_id}] {k_content}...")
+
+            # 递归处理子目标
+            children = self.get_children(goal.id)
+
+            # include_summary 模式下不折叠,全部展开
+            if include_summary:
+                for child in children:
+                    result.extend(format_goal(child, indent + 1))
+                return result
+
+            # 判断是否需要折叠
+            # 如果当前 goal 或其子孙在焦点路径上,完整展示
+            should_expand = goal.id in current_path or any(
+                child.id in current_path for child in self._get_all_descendants(goal.id)
+            )
+
+            if should_expand or not children:
+                # 完整展示子目标
+                for child in children:
+                    result.extend(format_goal(child, indent + 1))
+            else:
+                # 折叠显示:只显示子目标的统计
+                non_abandoned = [c for c in children if c.status != "abandoned"]
+                if non_abandoned:
+                    completed = sum(1 for c in non_abandoned if c.status == "completed")
+                    in_progress = sum(1 for c in non_abandoned if c.status == "in_progress")
+                    pending = sum(1 for c in non_abandoned if c.status == "pending")
+
+                    status_parts = []
+                    if completed > 0:
+                        status_parts.append(f"{completed} completed")
+                    if in_progress > 0:
+                        status_parts.append(f"{in_progress} in progress")
+                    if pending > 0:
+                        status_parts.append(f"{pending} pending")
+
+                    status_str = ", ".join(status_parts)
+                    result.append(f"{prefix}    ({len(non_abandoned)} subtasks: {status_str})")
+
+            return result
+
+        # 处理所有顶层目标
+        top_goals = self.get_children(None)
+        for goal in top_goals:
+            lines.extend(format_goal(goal))
+
+        return "\n".join(lines)
+
+    def _get_all_descendants(self, goal_id: str) -> List[Goal]:
+        """获取指定 Goal 的所有子孙节点"""
+        descendants = []
+        children = self.get_children(goal_id)
+        for child in children:
+            descendants.append(child)
+            descendants.extend(self._get_all_descendants(child.id))
+        return descendants
+
+    def to_dict(self) -> Dict[str, Any]:
+        """转换为字典"""
+        return {
+            "mission": self.mission,
+            "goals": [g.to_dict() for g in self.goals],
+            "current_id": self.current_id,
+            "_next_id": self._next_id,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+        }
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "GoalTree":
+        """从字典创建"""
+        goals = [Goal.from_dict(g) for g in data.get("goals", [])]
+        created_at = data.get("created_at")
+        if isinstance(created_at, str):
+            created_at = datetime.fromisoformat(created_at)
+
+        return cls(
+            mission=data["mission"],
+            goals=goals,
+            current_id=data.get("current_id"),
+            _next_id=data.get("_next_id", 1),
+            created_at=created_at or datetime.now(),
+        )
+
+    def rebuild_for_rewind(self, cutoff_time: datetime) -> "GoalTree":
+        """
+        为 Rewind 重建干净的 GoalTree
+
+        以截断点消息的 created_at 为界:
+        - 保留 created_at <= cutoff_time 的所有 goals(无论状态)
+        - 丢弃 cutoff_time 之后创建的 goals
+        - 将被保留的 in_progress goals 重置为 pending
+        - 清空 current_id,让 Agent 重新选择焦点
+
+        Args:
+            cutoff_time: 截断点消息的创建时间
+
+        Returns:
+            新的干净 GoalTree
+        """
+        surviving_goals = []
+        for goal in self.goals:
+            if goal.created_at <= cutoff_time:
+                surviving_goals.append(goal)
+
+        # 清理 parent_id 引用:如果 parent 不在存活列表中,设为 None
+        surviving_ids = {g.id for g in surviving_goals}
+        for goal in surviving_goals:
+            if goal.parent_id and goal.parent_id not in surviving_ids:
+                goal.parent_id = None
+            # 将 in_progress 重置为 pending(回溯后需要重新执行)
+            if goal.status == "in_progress":
+                goal.status = "pending"
+
+        new_tree = GoalTree(
+            mission=self.mission,
+            goals=surviving_goals,
+            current_id=None,
+            _next_id=self._next_id,
+            created_at=self.created_at,
+        )
+        return new_tree
+
+    def save(self, path: str) -> None:
+        """保存到 JSON 文件"""
+        with open(path, "w", encoding="utf-8") as f:
+            json.dump(self.to_dict(), f, ensure_ascii=False, indent=2)
+
+    @classmethod
+    def load(cls, path: str) -> "GoalTree":
+        """从 JSON 文件加载"""
+        with open(path, "r", encoding="utf-8") as f:
+            data = json.load(f)
+        return cls.from_dict(data)

+ 333 - 0
agent/trace/goal_tool.py

@@ -0,0 +1,333 @@
+"""
+Goal 工具 - 计划管理
+
+提供 goal 工具供 LLM 管理执行计划。
+"""
+
+from typing import Optional, List, TYPE_CHECKING
+
+from agent.tools import tool
+
+if TYPE_CHECKING:
+    from .goal_models import GoalTree
+    from .protocols import TraceStore
+
+
+# ===== LLM 可调用的 goal 工具 =====
+
+@tool(description="管理执行计划,添加/完成/放弃目标,切换焦点", hidden_params=["context"])
+async def goal(
+    add: Optional[str] = None,
+    reason: Optional[str] = None,
+    after: Optional[str] = None,
+    under: Optional[str] = None,
+    done: Optional[str] = None,
+    abandon: Optional[str] = None,
+    focus: Optional[str] = None,
+    context: Optional[dict] = None
+) -> str:
+    """
+    管理执行计划,添加/完成/放弃目标,切换焦点。
+
+    Args:
+        add: 添加目标(逗号分隔多个)
+        reason: 创建理由(逗号分隔多个,与 add 一一对应)
+        after: 在指定目标后面添加(同层级)
+        under: 为指定目标添加子目标
+        done: 完成当前目标,值为 summary
+        abandon: 放弃当前目标,值为原因
+        focus: 切换焦点到指定 ID
+        context: 工具执行上下文(包含 store、trace_id、goal_tree)
+
+    Returns:
+        str: 更新后的计划状态文本
+    """
+    # GoalTree 从 context 获取,每个 agent 实例独立,不再依赖全局变量
+    tree = context.get("goal_tree") if context else None
+    if tree is None:
+        return "错误:GoalTree 未初始化"
+
+    # 从 context 获取 store 和 trace_id
+    store = context.get("store") if context else None
+    trace_id = context.get("trace_id") if context else None
+
+    return await goal_tool(
+        tree=tree,
+        store=store,
+        trace_id=trace_id,
+        add=add,
+        reason=reason,
+        after=after,
+        under=under,
+        done=done,
+        abandon=abandon,
+        focus=focus
+    )
+
+
+# ===== 核心逻辑函数 =====
+
+
+async def goal_tool(
+    tree: "GoalTree",
+    store: Optional["TraceStore"] = None,
+    trace_id: Optional[str] = None,
+    add: Optional[str] = None,
+    reason: Optional[str] = None,
+    after: Optional[str] = None,
+    under: Optional[str] = None,
+    done: Optional[str] = None,
+    abandon: Optional[str] = None,
+    focus: Optional[str] = None,
+) -> str:
+    """
+    管理执行计划。
+
+    Args:
+        tree: GoalTree 实例
+        store: TraceStore 实例(用于推送事件)
+        trace_id: 当前 Trace ID
+        add: 添加目标(逗号分隔多个)
+        reason: 创建理由(逗号分隔多个,与 add 一一对应)
+        after: 在指定目标后面添加(同层级)
+        under: 为指定目标添加子目标
+        done: 完成当前目标,值为 summary
+        abandon: 放弃当前目标,值为原因
+        focus: 切换焦点到指定 ID
+
+    Returns:
+        更新后的计划状态文本
+    """
+    changes = []
+
+    # 1. 处理 done(完成当前目标)
+    if done is not None:
+        if not tree.current_id:
+            return f"错误:没有当前目标可以完成。当前焦点为空,请先使用 focus 参数切换到要完成的目标。\n\n当前计划:\n{tree.to_prompt()}"
+
+        # 完成当前目标
+        # 如果同时指定了 focus,则不清空焦点(后面会切换到新目标)
+        # 如果只有 done,则清空焦点
+        clear_focus = (focus is None)
+        goal = tree.complete(tree.current_id, done, clear_focus=clear_focus)
+        display_id = tree._generate_display_id(goal)
+        changes.append(f"已完成: {display_id}. {goal.description}")
+
+        # 推送事件
+        if store and trace_id:
+            await store.update_goal(trace_id, goal.id, status="completed", summary=done)
+
+        # 检查是否有级联完成的父目标(complete方法已经处理,这里只需要记录)
+        if goal.parent_id:
+            parent = tree.find(goal.parent_id)
+            if parent and parent.status == "completed":
+                parent_display_id = tree._generate_display_id(parent)
+                changes.append(f"自动完成: {parent_display_id}. {parent.description}(所有子目标已完成)")
+
+    # 2. 处理 focus(切换焦点到新目标)
+    if focus is not None:
+        goal = tree.find_by_display_id(focus)
+
+        if not goal:
+            return f"错误:找不到目标 {focus}\n\n当前计划:\n{tree.to_prompt()}"
+
+        tree.focus(goal.id)
+        display_id = tree._generate_display_id(goal)
+        changes.append(f"切换焦点: {display_id}. {goal.description}")
+
+        # 自动注入知识
+        try:
+            from agent.tools.builtin.knowledge import knowledge_search
+
+            knowledge_result = await knowledge_search(
+                query=goal.description,
+                top_k=3,
+                min_score=3,
+                context=None
+            )
+
+            # 将知识保存到 goal 对象
+            if knowledge_result.metadata and knowledge_result.metadata.get("items"):
+                goal.knowledge = knowledge_result.metadata["items"]
+                knowledge_count = len(goal.knowledge)
+                changes.append(f"📚 已注入 {knowledge_count} 条相关知识")
+
+                # 持久化到 store
+                if store and trace_id:
+                    await store.update_goal_tree(trace_id, tree)
+            else:
+                goal.knowledge = []
+
+        except Exception as e:
+            # 知识注入失败不影响 focus 操作
+            import logging
+            logging.getLogger(__name__).warning(f"知识注入失败: {e}")
+            goal.knowledge = []
+
+    # 3. 处理 abandon(放弃当前目标)
+    if abandon is not None:
+        if not tree.current_id:
+            return f"错误:没有当前目标可以放弃。当前焦点为空。\n\n当前计划:\n{tree.to_prompt()}"
+        goal = tree.abandon(tree.current_id, abandon)
+        display_id = tree._generate_display_id(goal)
+        changes.append(f"已放弃: {display_id}. {goal.description}")
+
+        # 推送事件
+        if store and trace_id:
+            await store.update_goal(trace_id, goal.id, status="abandoned", summary=abandon)
+
+    # 4. 处理 add
+    if add is not None:
+        # 检查 after 和 under 互斥
+        if after is not None and under is not None:
+            return "错误:after 和 under 参数不能同时指定"
+
+        descriptions = [d.strip() for d in add.split(",") if d.strip()]
+        if descriptions:
+            # 解析 reasons(与 descriptions 一一对应)
+            reasons = None
+            if reason:
+                reasons = [r.strip() for r in reason.split(",")]
+                # 如果 reasons 数量少于 descriptions,补空字符串
+                while len(reasons) < len(descriptions):
+                    reasons.append("")
+
+            # 确定添加位置
+            if after is not None:
+                # 在指定 goal 后面添加(同层级)
+                target_goal = tree.find_by_display_id(after)
+
+                if not target_goal:
+                    return f"错误:找不到目标 {after}\n\n当前计划:\n{tree.to_prompt()}"
+
+                new_goals = tree.add_goals_after(target_goal.id, descriptions, reasons=reasons)
+                changes.append(f"在 {tree._generate_display_id(target_goal)} 后面添加 {len(new_goals)} 个同级目标")
+
+            elif under is not None:
+                # 为指定 goal 添加子目标
+                parent_goal = tree.find_by_display_id(under)
+
+                if not parent_goal:
+                    return f"错误:找不到目标 {under}\n\n当前计划:\n{tree.to_prompt()}"
+
+                new_goals = tree.add_goals(descriptions, reasons=reasons, parent_id=parent_goal.id)
+                changes.append(f"在 {tree._generate_display_id(parent_goal)} 下添加 {len(new_goals)} 个子目标")
+
+            else:
+                # 默认行为:添加到当前焦点下(如果有焦点),否则添加到顶层
+                parent_id = tree.current_id
+                new_goals = tree.add_goals(descriptions, reasons=reasons, parent_id=parent_id)
+
+                if parent_id:
+                    parent_display_id = tree._generate_display_id(tree.find(parent_id))
+                    changes.append(f"在 {parent_display_id} 下添加 {len(new_goals)} 个子目标")
+                else:
+                    changes.append(f"添加 {len(new_goals)} 个顶层目标")
+
+            # 推送事件
+            if store and trace_id:
+                for goal in new_goals:
+                    await store.add_goal(trace_id, goal)
+
+            # 如果没有焦点且添加了目标,自动 focus 到第一个新目标
+            if not tree.current_id and new_goals:
+                tree.focus(new_goals[0].id)
+                display_id = tree._generate_display_id(new_goals[0])
+                changes.append(f"自动切换焦点: {display_id}")
+
+    # 将完整内存树状态(含 current_id)同步到存储,
+    # 因为 store.add_goal / update_goal 各自从磁盘加载,不包含 focus 等内存变更
+    if store and trace_id and changes:
+        await store.update_goal_tree(trace_id, tree)
+
+    # 返回当前状态
+    result = []
+    if changes:
+        result.append("## 更新")
+        result.extend(f"- {c}" for c in changes)
+        result.append("")
+
+    result.append("## Current Plan")
+    result.append(tree.to_prompt())
+
+    return "\n".join(result)
+
+
+def create_goal_tool_schema() -> dict:
+    """创建 goal 工具的 JSON Schema"""
+    return {
+        "name": "goal",
+        "description": """管理执行计划。目标工具是灵活的支持系统,帮助你组织和追踪工作进度。
+
+使用策略(按需选择):
+- 全局规划:先规划所有目标,再逐个执行
+- 渐进规划:走一步看一步,每次只创建下一个目标
+- 动态调整:行动中随时 abandon 不可行的目标,创建新目标
+
+参数:
+- add: 添加目标(逗号分隔多个)
+- reason: 创建理由(逗号分隔,与 add 一一对应)
+- after: 在指定目标后面添加同级目标。使用目标 ID。
+- under: 为指定目标添加子目标。使用目标 ID。如已有子目标,追加到最后。
+- done: 完成当前目标,值为 summary(记录关键结论)
+- abandon: 放弃当前目标,值为原因
+- focus: 切换焦点到指定目标。使用目标 ID。
+
+位置控制(优先使用 after):
+- 不指定 after/under: 添加到当前 focus 下作为子目标(无 focus 时添加到顶层)
+- after="X": 在目标 X 后面添加兄弟节点(同层级)
+- under="X": 为目标 X 添加子目标
+- after 和 under 不能同时指定
+
+执行顺序:
+- done → focus → abandon → add
+- 如果同时指定 done 和 focus,会先完成当前目标,再切换焦点到新目标
+
+示例:
+- goal(add="分析代码, 实现功能, 测试") - 添加顶层目标
+- goal(add="设计接口, 实现代码", under="2") - 为目标2添加子目标
+- goal(add="编写文档", after="3") - 在目标3后面添加同级任务
+- goal(add="集成测试", after="2.2") - 在目标2.2后面添加同级任务
+- goal(done="发现用户模型在 models/user.py") - 完成当前目标
+- goal(done="已完成调研", focus="2") - 完成当前目标,切换到目标2
+- goal(abandon="方案A需要Redis,环境没有") - 放弃当前目标
+
+注意:
+- 目标 ID 的格式为 "1", "2", "2.1", "2.2" 等,在计划视图中可以看到
+- reason 应该与 add 的目标数量一致,如果数量不一致,缺少的 reason 将为空
+""",
+        "parameters": {
+            "type": "object",
+            "properties": {
+                "add": {
+                    "type": "string",
+                    "description": "添加目标(逗号分隔多个)"
+                },
+                "reason": {
+                    "type": "string",
+                    "description": "创建理由(逗号分隔多个,与 add 一一对应)。说明为什么要做这些目标。"
+                },
+                "after": {
+                    "type": "string",
+                    "description": "在指定目标后面添加(同层级)。使用目标的 ID,如 \"2\" 或 \"2.1\"。"
+                },
+                "under": {
+                    "type": "string",
+                    "description": "为指定目标添加子目标。使用目标的 ID,如 \"2\" 或 \"2.1\"。"
+                },
+                "done": {
+                    "type": "string",
+                    "description": "完成当前目标,值为 summary"
+                },
+                "abandon": {
+                    "type": "string",
+                    "description": "放弃当前目标,值为原因"
+                },
+                "focus": {
+                    "type": "string",
+                    "description": "切换焦点到指定目标。使用目标的 ID,如 \"2\" 或 \"2.1\"。"
+                }
+            },
+            "required": []
+        }
+    }

+ 102 - 0
agent/trace/logs_websocket.py

@@ -0,0 +1,102 @@
+"""
+Logs WebSocket - 实时推送后端日志到前端
+"""
+
+import asyncio
+import logging
+from typing import Set
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+from datetime import datetime
+
+
+router = APIRouter(prefix="/api/logs", tags=["logs"])
+
+
+# 存储所有连接的WebSocket客户端
+_clients: Set[WebSocket] = set()
+
+
+class WebSocketLogHandler(logging.Handler):
+    """自定义日志处理器,将日志推送到WebSocket客户端"""
+
+    def emit(self, record: logging.LogRecord):
+        """发送日志记录到所有连接的客户端"""
+        try:
+            log_entry = self.format(record)
+            # 构造日志消息
+            message = {
+                "timestamp": datetime.now().isoformat(),
+                "level": record.levelname,
+                "name": record.name,
+                "message": log_entry,
+            }
+            # 异步发送到所有客户端
+            asyncio.create_task(broadcast_log(message))
+        except Exception:
+            self.handleError(record)
+
+
+async def broadcast_log(message: dict):
+    """广播日志消息到所有连接的客户端"""
+    disconnected = set()
+    for client in _clients:
+        try:
+            await client.send_json(message)
+        except Exception:
+            disconnected.add(client)
+
+    # 移除断开连接的客户端
+    for client in disconnected:
+        _clients.discard(client)
+
+
+@router.websocket("/watch")
+async def logs_websocket(websocket: WebSocket):
+    """
+    日志WebSocket端点
+
+    客户端连接后,实时接收后端日志
+    """
+    await websocket.accept()
+    _clients.add(websocket)
+
+    try:
+        # 发送欢迎消息
+        await websocket.send_json({
+            "timestamp": datetime.now().isoformat(),
+            "level": "INFO",
+            "name": "logs_websocket",
+            "message": "Connected to logs stream",
+        })
+
+        # 保持连接,等待客户端断开
+        while True:
+            # 接收客户端消息(用于保持连接)
+            await websocket.receive_text()
+    except WebSocketDisconnect:
+        pass
+    finally:
+        _clients.discard(websocket)
+
+
+def setup_websocket_logging(level=logging.INFO):
+    """
+    设置WebSocket日志处理器
+
+    将根日志器的日志推送到WebSocket客户端
+    """
+    handler = WebSocketLogHandler()
+    handler.setLevel(level)
+
+    # 设置日志格式
+    formatter = logging.Formatter(
+        "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+        datefmt="%Y-%m-%d %H:%M:%S"
+    )
+    handler.setFormatter(formatter)
+
+    # 添加到根日志器
+    root_logger = logging.getLogger()
+    root_logger.addHandler(handler)
+
+    return handler

+ 531 - 0
agent/trace/models.py

@@ -0,0 +1,531 @@
+"""
+Trace 和 Message 数据模型
+
+Trace: 一次完整的 LLM 交互(单次调用或 Agent 任务)
+Message: Trace 中的 LLM 消息,对应 LLM API 格式
+"""
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Dict, Any, List, Optional, Literal, Union
+import uuid
+
+# ===== 消息线格式类型别名 =====
+# 轻量 wire-format 类型,用于工具参数和 runner/LLM API 接口。
+# 内部存储使用下方的 Message dataclass。
+
+ChatMessage = Dict[str, Any]                          # 单条 OpenAI 格式消息
+Messages = List[ChatMessage]                          # 消息列表
+MessageContent = Union[str, List[Dict[str, str]]]     # content 字段(文本或多模态)
+
+
+# 导入 TokenUsage(延迟导入避免循环依赖)
+def _get_token_usage_class():
+    from ..llm.usage import TokenUsage
+    return TokenUsage
+
+
+@dataclass
+class Trace:
+    """
+    执行轨迹 - 一次完整的 LLM 交互
+
+    单次调用: mode="call"
+    Agent 模式: mode="agent"
+
+    主 Trace 和 Sub-Trace 使用相同的数据结构。
+    Sub-Trace 通过 parent_trace_id 和 parent_goal_id 关联父 Trace。
+    """
+    trace_id: str
+    mode: Literal["call", "agent"]
+
+    # Prompt 标识(可选)
+    prompt_name: Optional[str] = None
+
+    # Agent 模式特有
+    task: Optional[str] = None
+    agent_type: Optional[str] = None
+
+    # 父子关系(Sub-Trace 特有)
+    parent_trace_id: Optional[str] = None     # 父 Trace ID
+    parent_goal_id: Optional[str] = None      # 哪个 Goal 启动的
+
+    # 状态
+    status: Literal["running", "completed", "failed", "stopped"] = "running"
+
+    # 统计
+    total_messages: int = 0      # 消息总数(改名自 total_steps)
+    total_tokens: int = 0        # 总 tokens(向后兼容,= prompt + completion)
+    total_prompt_tokens: int = 0      # 总输入 tokens
+    total_completion_tokens: int = 0  # 总输出 tokens
+    total_reasoning_tokens: int = 0   # 总推理 tokens(o1/o3, DeepSeek R1, Gemini thinking)
+    total_cache_creation_tokens: int = 0  # 总缓存创建 tokens(Claude)
+    total_cache_read_tokens: int = 0      # 总缓存读取 tokens(Claude)
+    total_cost: float = 0.0
+    total_duration_ms: int = 0   # 总耗时(毫秒)
+
+    # 进度追踪(head)
+    last_sequence: int = 0      # 最新 message 的 sequence
+    head_sequence: int = 0      # 当前主路径的头节点 sequence(用于 build_llm_messages)
+    last_event_id: int = 0      # 最新事件 ID(用于 WS 续传)
+
+    # 配置
+    uid: Optional[str] = None
+    model: Optional[str] = None              # 默认模型
+    tools: Optional[List[Dict]] = None       # 工具定义(整个 trace 共享)
+    llm_params: Dict[str, Any] = field(default_factory=dict)  # LLM 参数(temperature 等)
+    context: Dict[str, Any] = field(default_factory=dict)     # 其他元数据
+
+    # 当前焦点 goal
+    current_goal_id: Optional[str] = None
+
+    # 结果
+    result_summary: Optional[str] = None     # 执行结果摘要
+    error_message: Optional[str] = None      # 错误信息
+
+    # 时间
+    created_at: datetime = field(default_factory=datetime.now)
+    completed_at: Optional[datetime] = None
+
+    @classmethod
+    def create(
+        cls,
+        mode: Literal["call", "agent"],
+        **kwargs
+    ) -> "Trace":
+        """创建新的 Trace"""
+        return cls(
+            trace_id=str(uuid.uuid4()),
+            mode=mode,
+            **kwargs
+        )
+
+    def to_dict(self) -> Dict[str, Any]:
+        """转换为字典"""
+        return {
+            "trace_id": self.trace_id,
+            "mode": self.mode,
+            "prompt_name": self.prompt_name,
+            "task": self.task,
+            "agent_type": self.agent_type,
+            "parent_trace_id": self.parent_trace_id,
+            "parent_goal_id": self.parent_goal_id,
+            "status": self.status,
+            "total_messages": self.total_messages,
+            "total_tokens": self.total_tokens,
+            "total_prompt_tokens": self.total_prompt_tokens,
+            "total_completion_tokens": self.total_completion_tokens,
+            "total_reasoning_tokens": self.total_reasoning_tokens,
+            "total_cache_creation_tokens": self.total_cache_creation_tokens,
+            "total_cache_read_tokens": self.total_cache_read_tokens,
+            "total_cost": self.total_cost,
+            "total_duration_ms": self.total_duration_ms,
+            "last_sequence": self.last_sequence,
+            "head_sequence": self.head_sequence,
+            "last_event_id": self.last_event_id,
+            "uid": self.uid,
+            "model": self.model,
+            "tools": self.tools,
+            "llm_params": self.llm_params,
+            "context": self.context,
+            "current_goal_id": self.current_goal_id,
+            "result_summary": self.result_summary,
+            "error_message": self.error_message,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+            "completed_at": self.completed_at.isoformat() if self.completed_at else None,
+        }
+
+
+@dataclass
+class Message:
+    """
+    执行消息 - Trace 中的 LLM 消息
+
+    对应 LLM API 消息格式(system/user/assistant/tool),通过 goal_id 关联 Goal。
+
+    description 字段自动生成规则:
+    - system: 取 content 前 200 字符
+    - user: 取 content 前 200 字符
+    - assistant: 优先取 content,若无 content 则生成 "tool call: XX, XX"
+    - tool: 使用 tool name
+    """
+    message_id: str
+    trace_id: str
+    role: Literal["system", "user", "assistant", "tool"]   # 和 LLM API 一致
+    sequence: int                        # 全局顺序
+    parent_sequence: Optional[int] = None  # 父消息的 sequence(构成消息树)
+    status: Literal["active", "abandoned"] = "active"  # [已弃用] 由 parent_sequence 树结构替代
+    goal_id: Optional[str] = None        # 关联的 Goal 内部 ID(None = 还没有创建 Goal)
+    description: str = ""                # 消息描述(系统自动生成)
+    tool_call_id: Optional[str] = None   # tool 消息关联对应的 tool_call
+    content: Any = None                  # 消息内容(和 LLM API 格式一致)
+
+    # 元数据
+    prompt_tokens: Optional[int] = None  # 输入 tokens
+    completion_tokens: Optional[int] = None  # 输出 tokens
+    reasoning_tokens: Optional[int] = None   # 推理 tokens(o1/o3, DeepSeek R1, Gemini thinking)
+    cache_creation_tokens: Optional[int] = None  # 缓存创建 tokens(Claude)
+    cache_read_tokens: Optional[int] = None      # 缓存读取 tokens(Claude)
+    cost: Optional[float] = None
+    duration_ms: Optional[int] = None
+    created_at: datetime = field(default_factory=datetime.now)
+    abandoned_at: Optional[datetime] = None  # [已弃用] 由 parent_sequence 树结构替代
+
+    # LLM 响应信息(仅 role="assistant" 时使用)
+    finish_reason: Optional[str] = None  # stop, length, tool_calls, content_filter 等
+
+    @property
+    def tokens(self) -> int:
+        """动态计算总 tokens(向后兼容,input + output)"""
+        return (self.prompt_tokens or 0) + (self.completion_tokens or 0)
+
+    @property
+    def all_tokens(self) -> int:
+        """所有 tokens(包括 reasoning)"""
+        return self.tokens + (self.reasoning_tokens or 0)
+
+    def get_usage(self):
+        """获取 TokenUsage 对象"""
+        TokenUsage = _get_token_usage_class()
+        return TokenUsage(
+            input_tokens=self.prompt_tokens or 0,
+            output_tokens=self.completion_tokens or 0,
+            reasoning_tokens=self.reasoning_tokens or 0,
+            cache_creation_tokens=self.cache_creation_tokens or 0,
+            cache_read_tokens=self.cache_read_tokens or 0,
+        )
+
+    def to_llm_dict(self) -> Dict[str, Any]:
+        """转换为 OpenAI SDK 格式的消息字典(用于 LLM 调用)"""
+        msg: Dict[str, Any] = {"role": self.role}
+
+        if self.role == "tool":
+            # tool message: tool_call_id + name + content
+            if self.tool_call_id:
+                msg["tool_call_id"] = self.tool_call_id
+                msg["name"] = self.description or "unknown"
+            if isinstance(self.content, dict):
+                result = self.content.get("result", self.content)
+                # result 可能是 list(含图片的多模态内容)或字符串
+                msg["content"] = result if isinstance(result, list) else str(result)
+            else:
+                msg["content"] = str(self.content) if self.content is not None else ""
+
+        elif self.role == "assistant":
+            # assistant message: content(text) + tool_calls
+            if isinstance(self.content, dict):
+                msg["content"] = self.content.get("text", "") or ""
+                if self.content.get("tool_calls"):
+                    msg["tool_calls"] = self.content["tool_calls"]
+            elif isinstance(self.content, str):
+                msg["content"] = self.content
+            else:
+                msg["content"] = ""
+
+        else:
+            # system / user message: content 直接传
+            msg["content"] = self.content
+
+        return msg
+
+    @classmethod
+    def from_llm_dict(
+        cls,
+        d: Dict[str, Any],
+        trace_id: str,
+        sequence: int,
+        goal_id: Optional[str] = None,
+        parent_sequence: Optional[int] = None,
+    ) -> "Message":
+        """从 OpenAI SDK 格式创建 Message"""
+        role = d["role"]
+
+        if role == "assistant":
+            content = {"text": d.get("content", ""), "tool_calls": d.get("tool_calls")}
+        elif role == "tool":
+            content = {"tool_name": d.get("name", "unknown"), "result": d.get("content", "")}
+        else:
+            content = d.get("content", "")
+
+        return cls.create(
+            trace_id=trace_id,
+            role=role,
+            sequence=sequence,
+            goal_id=goal_id,
+            parent_sequence=parent_sequence,
+            content=content,
+            tool_call_id=d.get("tool_call_id"),
+        )
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "Message":
+        """从字典创建 Message(处理向后兼容)"""
+        # 过滤掉已删除的字段
+        filtered_data = {k: v for k, v in data.items() if k not in ["tokens", "available_tools"]}
+
+        # 解析 datetime
+        if filtered_data.get("created_at") and isinstance(filtered_data["created_at"], str):
+            filtered_data["created_at"] = datetime.fromisoformat(filtered_data["created_at"])
+        if filtered_data.get("abandoned_at") and isinstance(filtered_data["abandoned_at"], str):
+            filtered_data["abandoned_at"] = datetime.fromisoformat(filtered_data["abandoned_at"])
+
+        # 向后兼容:旧消息没有 status 字段,默认 active
+        if "status" not in filtered_data:
+            filtered_data["status"] = "active"
+
+        # 向后兼容:旧消息没有 parent_sequence 字段
+        if "parent_sequence" not in filtered_data:
+            filtered_data["parent_sequence"] = None
+
+        return cls(**filtered_data)
+
+    @classmethod
+    def create(
+        cls,
+        trace_id: str,
+        role: Literal["system", "user", "assistant", "tool"],
+        sequence: int,
+        goal_id: Optional[str] = None,
+        content: Any = None,
+        tool_call_id: Optional[str] = None,
+        parent_sequence: Optional[int] = None,
+        prompt_tokens: Optional[int] = None,
+        completion_tokens: Optional[int] = None,
+        reasoning_tokens: Optional[int] = None,
+        cache_creation_tokens: Optional[int] = None,
+        cache_read_tokens: Optional[int] = None,
+        cost: Optional[float] = None,
+        duration_ms: Optional[int] = None,
+        finish_reason: Optional[str] = None,
+    ) -> "Message":
+        """创建新的 Message,自动生成 description"""
+        description = cls._generate_description(role, content)
+
+        return cls(
+            message_id=f"{trace_id}-{sequence:04d}",
+            trace_id=trace_id,
+            role=role,
+            sequence=sequence,
+            parent_sequence=parent_sequence,
+            goal_id=goal_id,
+            content=content,
+            description=description,
+            tool_call_id=tool_call_id,
+            prompt_tokens=prompt_tokens,
+            completion_tokens=completion_tokens,
+            reasoning_tokens=reasoning_tokens,
+            cache_creation_tokens=cache_creation_tokens,
+            cache_read_tokens=cache_read_tokens,
+            cost=cost,
+            duration_ms=duration_ms,
+            finish_reason=finish_reason,
+        )
+
+    @staticmethod
+    def _generate_description(role: str, content: Any) -> str:
+        """
+        自动生成 description
+
+        - system: 取 content 前 200 字符
+        - user: 取 content 前 200 字符
+        - assistant: 优先取 content,若无 content 则生成 "tool call: XX, XX"
+        - tool: 使用 tool name
+        """
+        if role == "system":
+            # system 消息:直接截取文本
+            if isinstance(content, str):
+                return content[:200] + "..." if len(content) > 200 else content
+            return "system prompt"
+
+        elif role == "user":
+            # user 消息:直接截取文本
+            if isinstance(content, str):
+                return content[:200] + "..." if len(content) > 200 else content
+            return "user message"
+
+        elif role == "assistant":
+            # assistant 消息:content 是字典,可能包含 text 和 tool_calls
+            if isinstance(content, dict):
+                # 优先返回文本内容
+                if content.get("text"):
+                    text = content["text"]
+                    # 截断过长的文本
+                    return text[:200] + "..." if len(text) > 200 else text
+
+                # 如果没有文本,检查 tool_calls
+                if content.get("tool_calls"):
+                    tool_calls = content["tool_calls"]
+                    if isinstance(tool_calls, list):
+                        tool_descriptions = []
+                        for tc in tool_calls:
+                            if isinstance(tc, dict) and tc.get("function", {}).get("name"):
+                                tool_name = tc["function"]["name"]
+                                # 提取参数并截断到 100 字符
+                                tool_args = tc["function"].get("arguments", "{}")
+                                if isinstance(tool_args, str):
+                                    args_str = tool_args
+                                else:
+                                    import json
+                                    args_str = json.dumps(tool_args, ensure_ascii=False)
+                                args_display = args_str[:100] + "..." if len(args_str) > 100 else args_str
+                                tool_descriptions.append(f"{tool_name}({args_display})")
+                        if tool_descriptions:
+                            return "tool call: " + ", ".join(tool_descriptions)
+
+            # 如果 content 是字符串
+            if isinstance(content, str):
+                return content[:200] + "..." if len(content) > 200 else content
+
+            return "assistant message"
+
+        elif role == "tool":
+            # tool 消息:从 content 中提取 tool name
+            if isinstance(content, dict):
+                if content.get("tool_name"):
+                    return content["tool_name"]
+
+            # 如果是字符串,尝试解析
+            if isinstance(content, str):
+                return content[:100] + "..." if len(content) > 100 else content
+
+            return "tool result"
+
+        return ""
+
+    def to_dict(self) -> Dict[str, Any]:
+        """转换为字典"""
+        result = {
+            "message_id": self.message_id,
+            "trace_id": self.trace_id,
+            "role": self.role,
+            "sequence": self.sequence,
+            "parent_sequence": self.parent_sequence,
+            "status": self.status,
+            "goal_id": self.goal_id,
+            "tool_call_id": self.tool_call_id,
+            "content": self.content,
+            "description": self.description,
+            "tokens": self.tokens,  # 使用 @property 动态计算
+            "prompt_tokens": self.prompt_tokens,
+            "completion_tokens": self.completion_tokens,
+            "cost": self.cost,
+            "duration_ms": self.duration_ms,
+            "finish_reason": self.finish_reason,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+        }
+        # 只添加非空的可选字段
+        if self.abandoned_at:
+            result["abandoned_at"] = self.abandoned_at.isoformat()
+        if self.reasoning_tokens is not None:
+            result["reasoning_tokens"] = self.reasoning_tokens
+        if self.cache_creation_tokens is not None:
+            result["cache_creation_tokens"] = self.cache_creation_tokens
+        if self.cache_read_tokens is not None:
+            result["cache_read_tokens"] = self.cache_read_tokens
+        return result
+
+
+# ===== 已弃用:Step 模型(保留用于向后兼容)=====
+
+# Step 类型
+StepType = Literal[
+    "goal", "thought", "evaluation", "response",
+    "action", "result", "memory_read", "memory_write",
+]
+
+# Step 状态
+StepStatus = Literal[
+    "planned", "in_progress", "awaiting_approval",
+    "completed", "failed", "skipped",
+]
+
+
+@dataclass
+class Step:
+    """
+    [已弃用] 执行步骤 - 使用 Message 模型替代
+
+    保留用于向后兼容
+    """
+    step_id: str
+    trace_id: str
+    step_type: StepType
+    status: StepStatus
+    sequence: int
+    parent_id: Optional[str] = None
+    description: str = ""
+    data: Dict[str, Any] = field(default_factory=dict)
+    summary: Optional[str] = None
+    has_children: bool = False
+    children_count: int = 0
+    duration_ms: Optional[int] = None
+    tokens: Optional[int] = None
+    cost: Optional[float] = None
+    created_at: datetime = field(default_factory=datetime.now)
+
+    @classmethod
+    def create(
+        cls,
+        trace_id: str,
+        step_type: StepType,
+        sequence: int,
+        status: StepStatus = "completed",
+        description: str = "",
+        data: Dict[str, Any] = None,
+        parent_id: Optional[str] = None,
+        summary: Optional[str] = None,
+        duration_ms: Optional[int] = None,
+        tokens: Optional[int] = None,
+        cost: Optional[float] = None,
+    ) -> "Step":
+        """创建新的 Step"""
+        return cls(
+            step_id=str(uuid.uuid4()),
+            trace_id=trace_id,
+            step_type=step_type,
+            status=status,
+            sequence=sequence,
+            parent_id=parent_id,
+            description=description,
+            data=data or {},
+            summary=summary,
+            duration_ms=duration_ms,
+            tokens=tokens,
+            cost=cost,
+        )
+
+    def to_dict(self, view: str = "full") -> Dict[str, Any]:
+        """
+        转换为字典
+
+        Args:
+            view: "compact" - 不返回大字段
+                  "full" - 返回完整数据
+        """
+        result = {
+            "step_id": self.step_id,
+            "trace_id": self.trace_id,
+            "step_type": self.step_type,
+            "status": self.status,
+            "sequence": self.sequence,
+            "parent_id": self.parent_id,
+            "description": self.description,
+            "summary": self.summary,
+            "has_children": self.has_children,
+            "children_count": self.children_count,
+            "duration_ms": self.duration_ms,
+            "tokens": self.tokens,
+            "cost": self.cost,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+        }
+
+        # 处理 data 字段
+        if view == "compact":
+            data_copy = self.data.copy()
+            for key in ["output", "content", "full_output", "full_content"]:
+                data_copy.pop(key, None)
+            result["data"] = data_copy
+        else:
+            result["data"] = self.data
+
+        return result

+ 232 - 0
agent/trace/protocols.py

@@ -0,0 +1,232 @@
+"""
+Trace Storage Protocol - Trace 存储接口定义
+
+使用 Protocol 定义接口,允许不同的存储实现(内存、PostgreSQL、Neo4j 等)
+"""
+
+from typing import Protocol, List, Optional, Dict, Any, runtime_checkable
+
+from .models import Trace, Message
+from .goal_models import GoalTree, Goal
+
+
+@runtime_checkable
+class TraceStore(Protocol):
+    """Trace + Message + GoalTree 存储接口"""
+
+    # ===== Trace 操作 =====
+
+    async def create_trace(self, trace: Trace) -> str:
+        """
+        创建新的 Trace
+
+        Args:
+            trace: Trace 对象
+
+        Returns:
+            trace_id
+        """
+        ...
+
+    async def get_trace(self, trace_id: str) -> Optional[Trace]:
+        """获取 Trace"""
+        ...
+
+    async def update_trace(self, trace_id: str, **updates) -> None:
+        """
+        更新 Trace
+
+        Args:
+            trace_id: Trace ID
+            **updates: 要更新的字段
+        """
+        ...
+
+    async def list_traces(
+        self,
+        mode: Optional[str] = None,
+        agent_type: Optional[str] = None,
+        uid: Optional[str] = None,
+        status: Optional[str] = None,
+        limit: int = 50
+    ) -> List[Trace]:
+        """列出 Traces"""
+        ...
+
+    # ===== GoalTree 操作 =====
+
+    async def get_goal_tree(self, trace_id: str) -> Optional[GoalTree]:
+        """
+        获取 GoalTree
+
+        Args:
+            trace_id: Trace ID
+
+        Returns:
+            GoalTree 对象,如果不存在返回 None
+        """
+        ...
+
+    async def update_goal_tree(self, trace_id: str, tree: GoalTree) -> None:
+        """
+        更新完整 GoalTree
+
+        Args:
+            trace_id: Trace ID
+            tree: GoalTree 对象
+        """
+        ...
+
+    async def add_goal(self, trace_id: str, goal: Goal) -> None:
+        """
+        添加 Goal 到 GoalTree
+
+        Args:
+            trace_id: Trace ID
+            goal: Goal 对象
+        """
+        ...
+
+    async def update_goal(self, trace_id: str, goal_id: str, **updates) -> None:
+        """
+        更新 Goal 字段
+
+        Args:
+            trace_id: Trace ID
+            goal_id: Goal ID
+            **updates: 要更新的字段(如 status, summary, self_stats, cumulative_stats)
+        """
+        ...
+
+    # ===== Message 操作 =====
+
+    async def add_message(self, message: Message) -> str:
+        """
+        添加 Message
+
+        自动更新关联 Goal 的 stats(self_stats 和祖先的 cumulative_stats)
+
+        Args:
+            message: Message 对象
+
+        Returns:
+            message_id
+        """
+        ...
+
+    async def get_message(self, message_id: str) -> Optional[Message]:
+        """获取 Message"""
+        ...
+
+    async def get_trace_messages(
+        self,
+        trace_id: str,
+    ) -> List[Message]:
+        """
+        获取 Trace 的所有 Messages(按 sequence 排序)
+
+        返回该 Trace 下所有消息(包含所有分支)。
+        如需获取特定主路径的消息,使用 get_main_path_messages()。
+
+        Args:
+            trace_id: Trace ID
+
+        Returns:
+            Message 列表
+        """
+        ...
+
+    async def get_main_path_messages(
+        self,
+        trace_id: str,
+        head_sequence: int
+    ) -> List[Message]:
+        """
+        获取主路径上的消息(从 head_sequence 沿 parent_sequence 链回溯到 root)
+
+        Args:
+            trace_id: Trace ID
+            head_sequence: 主路径头节点的 sequence
+
+        Returns:
+            按 sequence 正序排列的主路径 Message 列表
+        """
+        ...
+
+    async def get_messages_by_goal(
+        self,
+        trace_id: str,
+        goal_id: str
+    ) -> List[Message]:
+        """
+        获取指定 Goal 关联的所有 Messages
+
+        Args:
+            trace_id: Trace ID
+            goal_id: Goal ID
+
+        Returns:
+            Message 列表
+        """
+        ...
+
+    async def update_message(self, message_id: str, **updates) -> None:
+        """
+        更新 Message 字段(用于状态变更、错误记录等)
+
+        Args:
+            message_id: Message ID
+            **updates: 要更新的字段
+        """
+        ...
+
+    async def abandon_messages_after(self, trace_id: str, cutoff_sequence: int) -> List[str]:
+        """
+        将 cutoff_sequence 之后的所有 active 消息标记为 abandoned(回溯专用)
+
+        Args:
+            trace_id: Trace ID
+            cutoff_sequence: 截断点(该 sequence 及之前的消息保留)
+
+        Returns:
+            被标记为 abandoned 的 message_id 列表
+        """
+        ...
+
+    # ===== 事件流操作(用于 WebSocket 断线续传)=====
+
+    async def get_events(
+        self,
+        trace_id: str,
+        since_event_id: int = 0
+    ) -> List[Dict[str, Any]]:
+        """
+        获取事件流(用于 WS 断线续传)
+
+        Args:
+            trace_id: Trace ID
+            since_event_id: 从哪个事件 ID 开始(0 表示全部)
+
+        Returns:
+            事件列表(按 event_id 排序)
+        """
+        ...
+
+    async def append_event(
+        self,
+        trace_id: str,
+        event_type: str,
+        payload: Dict[str, Any]
+    ) -> int:
+        """
+        追加事件,返回 event_id
+
+        Args:
+            trace_id: Trace ID
+            event_type: 事件类型
+            payload: 事件数据
+
+        Returns:
+            event_id: 新事件的 ID
+        """
+        ...

+ 490 - 0
agent/trace/run_api.py

@@ -0,0 +1,490 @@
+"""
+Trace 控制 API — 新建 / 运行 / 停止 / 反思
+
+提供 POST 端点触发 Agent 执行和控制。需要通过 set_runner() 注入 AgentRunner 实例。
+执行在后台异步进行,客户端通过 WebSocket (/api/traces/{trace_id}/watch) 监听实时更新。
+
+端点:
+  POST /api/traces              — 新建 Trace 并执行
+  POST /api/traces/{id}/run     — 运行(统一续跑 + 回溯)
+  POST /api/traces/{id}/stop    — 停止运行中的 Trace
+  POST /api/traces/{id}/reflect — 反思,在 trace 末尾追加反思 prompt 运行,结果追加到 experiences 文件
+  GET  /api/traces/running      — 列出正在运行的 Trace
+  GET  /api/experiences         — 读取经验文件内容
+"""
+
+import asyncio
+import logging
+import re
+import uuid
+import os
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, Field
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/traces", tags=["run"])
+
+# 经验 API 使用独立 prefix
+experiences_router = APIRouter(prefix="/api", tags=["experiences"])
+
+
+# ===== 全局 Runner(由 api_server.py 注入)=====
+
+_runner = None
+
+
+def set_runner(runner):
+    """注入 AgentRunner 实例"""
+    global _runner
+    _runner = runner
+
+
+def _get_runner():
+    if _runner is None:
+        raise HTTPException(
+            status_code=503,
+            detail="AgentRunner not configured. Server is in read-only mode.",
+        )
+    return _runner
+
+
+# ===== Request / Response 模型 =====
+
+
+class CreateRequest(BaseModel):
+    """新建执行"""
+    messages: List[Dict[str, Any]] = Field(
+        ...,
+        description="OpenAI SDK 格式的输入消息。可包含 system + user 消息;若无 system 消息则从 skills 自动构建",
+    )
+    model: str = Field("gpt-4o", description="模型名称")
+    temperature: float = Field(0.3)
+    max_iterations: int = Field(200)
+    tools: Optional[List[str]] = Field(None, description="工具白名单(None = 全部)")
+    name: Optional[str] = Field(None, description="任务名称(None = 自动生成)")
+    uid: Optional[str] = Field(None)
+
+
+class TraceRunRequest(BaseModel):
+    """运行(统一续跑 + 回溯)"""
+    messages: List[Dict[str, Any]] = Field(
+        default_factory=list,
+        description="追加的新消息(可为空,用于重新生成场景)",
+    )
+    after_message_id: Optional[str] = Field(
+        None,
+        description="从哪条消息后续跑。None = 从末尾续跑,message_id = 从该消息后运行(自动判断续跑/回溯)",
+    )
+
+
+class ReflectRequest(BaseModel):
+    """反思请求"""
+    focus: Optional[str] = Field(None, description="反思重点(可选)")
+
+
+class RunResponse(BaseModel):
+    """操作响应(立即返回,后台执行)"""
+    trace_id: str
+    status: str = "started"
+    message: str = ""
+
+
+class StopResponse(BaseModel):
+    """停止响应"""
+    trace_id: str
+    status: str  # "stopping" | "not_running"
+
+
+class ReflectResponse(BaseModel):
+    """反思响应"""
+    trace_id: str
+    reflection: str
+
+
+# ===== 后台执行 =====
+
+_running_tasks: Dict[str, asyncio.Task] = {}
+
+
+async def _run_in_background(trace_id: str, messages: List[Dict], config):
+    """后台执行 agent,消费 run() 的所有 yield"""
+    runner = _get_runner()
+    try:
+        async for _item in runner.run(messages=messages, config=config):
+            pass  # WebSocket 广播由 runner 内部的 store 事件驱动
+    except Exception as e:
+        logger.error(f"Background run failed for {trace_id}: {e}")
+    finally:
+        _running_tasks.pop(trace_id, None)
+
+
+async def _run_with_trace_signal(
+    messages: List[Dict], config, trace_id_future: asyncio.Future,
+):
+    """后台执行 agent,通过 Future 将 trace_id 传回给等待的 endpoint"""
+    from agent.trace.models import Trace
+
+    runner = _get_runner()
+    trace_id: Optional[str] = None
+    try:
+        async for item in runner.run(messages=messages, config=config):
+            if isinstance(item, Trace) and not trace_id_future.done():
+                trace_id = item.trace_id
+                trace_id_future.set_result(trace_id)
+    except Exception as e:
+        if not trace_id_future.done():
+            trace_id_future.set_exception(e)
+        logger.error(f"Background run failed: {e}")
+    finally:
+        if trace_id:
+            _running_tasks.pop(trace_id, None)
+
+
+# ===== 路由 =====
+
+
+@router.post("", response_model=RunResponse)
+async def create_and_run(req: CreateRequest):
+    """
+    新建 Trace 并开始执行
+
+    立即返回 trace_id,后台异步执行。
+    通过 WebSocket /api/traces/{trace_id}/watch 监听实时更新。
+    """
+    from agent.core.runner import RunConfig
+
+    _get_runner()  # 验证 Runner 已配置
+
+    config = RunConfig(
+        model=req.model,
+        temperature=req.temperature,
+        max_iterations=req.max_iterations,
+        tools=req.tools,
+        name=req.name,
+        uid=req.uid,
+    )
+
+    # 启动后台执行,通过 Future 等待 trace_id(Phase 1 完成后即返回)
+    trace_id_future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
+    task = asyncio.create_task(
+        _run_with_trace_signal(req.messages, config, trace_id_future)
+    )
+
+    trace_id = await trace_id_future
+    _running_tasks[trace_id] = task
+
+    return RunResponse(
+        trace_id=trace_id,
+        status="started",
+        message=f"Execution started. Watch via WebSocket: /api/traces/{trace_id}/watch",
+    )
+
+
+async def _cleanup_incomplete_tool_calls(store, trace_id: str, after_sequence: int) -> int:
+    """
+    找到安全的插入点,保证不会把新消息插在一个不完整的工具调用序列中间。
+
+    场景:
+    1. after_sequence 刚好是一条带 tool_calls 的 assistant 消息,
+       但其部分/全部 tool response 还没生成 → 回退到该 assistant 之前。
+    2. after_sequence 是某条 tool response,但同一批 tool_calls 中
+       还有其他 response 未生成 → 回退到该 assistant 之前。
+
+    核心逻辑:从 after_sequence 往前找,定位到包含它的那条 assistant 消息,
+    检查该 assistant 的所有 tool_calls 是否都有对应的 tool response。
+    如果不完整,就把截断点回退到该 assistant 消息之前(即其 parent_sequence)。
+
+    Args:
+        store: TraceStore
+        trace_id: Trace ID
+        after_sequence: 用户指定的插入位置
+
+    Returns:
+        调整后的安全截断点(<= after_sequence)
+    """
+    all_messages = await store.get_trace_messages(trace_id)
+    if not all_messages:
+        return after_sequence
+
+    by_seq = {msg.sequence: msg for msg in all_messages}
+
+    target = by_seq.get(after_sequence)
+    if target is None:
+        return after_sequence
+
+    # 找到"所属的 assistant 消息":
+    # - 如果 target 本身是 assistant → 就是它
+    # - 如果 target 是 tool → 沿 parent_sequence 往上找 assistant
+    assistant_msg = None
+    if target.role == "assistant":
+        assistant_msg = target
+    elif target.role == "tool":
+        cur = target
+        while cur and cur.role == "tool":
+            parent_seq = cur.parent_sequence
+            cur = by_seq.get(parent_seq) if parent_seq is not None else None
+        if cur and cur.role == "assistant":
+            assistant_msg = cur
+
+    if assistant_msg is None:
+        return after_sequence
+
+    # 该 assistant 是否带 tool_calls?
+    content = assistant_msg.content
+    if not isinstance(content, dict) or not content.get("tool_calls"):
+        return after_sequence
+
+    # 收集所有 tool_call_ids
+    expected_ids = set()
+    for tc in content["tool_calls"]:
+        if isinstance(tc, dict) and tc.get("id"):
+            expected_ids.add(tc["id"])
+
+    if not expected_ids:
+        return after_sequence
+
+    # 查找已有的 tool responses
+    found_ids = set()
+    for msg in all_messages:
+        if msg.role == "tool" and msg.tool_call_id in expected_ids:
+            found_ids.add(msg.tool_call_id)
+
+    missing = expected_ids - found_ids
+    if not missing:
+        # 全部 tool response 都在,这是一个完整的序列
+        return after_sequence
+
+    # 不完整 → 回退到 assistant 之前
+    safe = assistant_msg.parent_sequence
+    if safe is None:
+        # assistant 已经是第一条消息,没有更早的位置
+        safe = assistant_msg.sequence - 1
+
+    logger.info(
+        "检测到不完整的工具调用 (assistant seq=%d, 缺少 %d/%d tool responses),"
+        "自动回退插入点:%d -> %d",
+        assistant_msg.sequence, len(missing), len(expected_ids),
+        after_sequence, safe,
+    )
+    return safe
+
+
+def _parse_sequence_from_message_id(message_id: str) -> int:
+    """从 message_id 末尾解析 sequence 整数(格式:{trace_id}-{sequence:04d})"""
+    try:
+        return int(message_id.rsplit("-", 1)[-1])
+    except (ValueError, IndexError):
+        raise HTTPException(
+            status_code=422,
+            detail=f"Invalid after_message_id format: {message_id!r}",
+        )
+
+
+@router.post("/{trace_id}/run", response_model=RunResponse)
+async def run_trace(trace_id: str, req: TraceRunRequest):
+    """
+    运行已有 Trace(统一续跑 + 回溯)
+
+    - after_message_id 为 null(或省略):从末尾续跑
+    - after_message_id 为 message_id 字符串:从该消息后运行(Runner 自动判断续跑/回溯)
+    - messages 为空 + after_message_id 有值:重新生成(从该位置重跑,不插入新消息)
+
+    **自动清理不完整工具调用**:
+    如果人工插入 message 的位置打断了一个工具调用过程(assistant 消息有 tool_calls
+    但缺少对应的 tool responses),框架会自动检测并调整插入位置,确保不会产生不一致的状态。
+    """
+    from agent.core.runner import RunConfig
+
+    runner = _get_runner()
+
+    # 将 message_id 转换为内部使用的 sequence 整数
+    after_sequence: Optional[int] = None
+    if req.after_message_id is not None:
+        after_sequence = _parse_sequence_from_message_id(req.after_message_id)
+
+    # 验证 trace 存在
+    if runner.trace_store:
+        trace = await runner.trace_store.get_trace(trace_id)
+        if not trace:
+            raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
+
+        # 自动检查并清理不完整的工具调用
+        if after_sequence is not None and req.messages:
+            adjusted_seq = await _cleanup_incomplete_tool_calls(
+                runner.trace_store, trace_id, after_sequence
+            )
+            if adjusted_seq != after_sequence:
+                logger.info(
+                    f"已自动调整插入位置:{after_sequence} -> {adjusted_seq}"
+                )
+                after_sequence = adjusted_seq
+
+    # 检查是否已在运行
+    if trace_id in _running_tasks and not _running_tasks[trace_id].done():
+        raise HTTPException(status_code=409, detail="Trace is already running")
+
+    config = RunConfig(trace_id=trace_id, after_sequence=after_sequence)
+    task = asyncio.create_task(_run_in_background(trace_id, req.messages, config))
+    _running_tasks[trace_id] = task
+
+    mode = "rewind" if after_sequence is not None else "continue"
+    return RunResponse(
+        trace_id=trace_id,
+        status="started",
+        message=f"Run ({mode}) started. Watch via WebSocket: /api/traces/{trace_id}/watch",
+    )
+
+
+@router.post("/{trace_id}/stop", response_model=StopResponse)
+async def stop_trace(trace_id: str):
+    """
+    停止运行中的 Trace
+
+    设置取消信号,agent loop 在下一个 LLM 调用前检查并退出。
+    Trace 状态置为 "stopped"。
+    """
+    runner = _get_runner()
+
+    # 通过 runner 的 stop 方法设置取消信号
+    stopped = await runner.stop(trace_id)
+
+    if not stopped:
+        # 检查是否在 _running_tasks 但 runner 不知道(可能已完成)
+        if trace_id in _running_tasks:
+            task = _running_tasks[trace_id]
+            if not task.done():
+                task.cancel()
+                _running_tasks.pop(trace_id, None)
+                return StopResponse(trace_id=trace_id, status="stopping")
+        return StopResponse(trace_id=trace_id, status="not_running")
+
+    return StopResponse(trace_id=trace_id, status="stopping")
+
+
+@router.post("/{trace_id}/reflect", response_model=ReflectResponse)
+async def reflect_trace(trace_id: str, req: ReflectRequest):
+    """
+    触发反思
+
+    在 trace 末尾追加一条包含反思 prompt 的 user message,单轮无工具 LLM 调用获取反思结果,
+    将结果追加到 experiences 文件(默认 ./.cache/experiences.md)。
+
+    反思消息作为侧枝(side branch):运行前保存 head_sequence,运行后恢复(try/finally 保证)。
+    使用 max_iterations=1, tools=[] 确保反思不会产生副作用。
+    """
+    from agent.core.runner import RunConfig
+    from agent.trace.compaction import build_reflect_prompt
+
+    runner = _get_runner()
+
+    if not runner.trace_store:
+        raise HTTPException(status_code=503, detail="TraceStore not configured")
+
+    # 验证 trace 存在
+    trace = await runner.trace_store.get_trace(trace_id)
+    if not trace:
+        raise HTTPException(status_code=404, detail=f"Trace not found: {trace_id}")
+
+    # 检查是否仍在运行
+    if trace_id in _running_tasks and not _running_tasks[trace_id].done():
+        raise HTTPException(status_code=409, detail="Cannot reflect on a running trace. Stop it first.")
+
+    # 保存当前 head_sequence(反思完成后恢复,使反思消息成为侧枝)
+    saved_head_sequence = trace.head_sequence
+
+    # 构建反思 prompt
+    prompt = build_reflect_prompt()
+    if req.focus:
+        prompt += f"\n\n请特别关注:{req.focus}"
+
+    # 以续跑方式运行:单轮无工具 LLM 调用
+    config = RunConfig(trace_id=trace_id, max_iterations=1, tools=[])
+    reflection_raw_text = ""
+    try:
+        result = await runner.run_result(
+            messages=[{"role": "user", "content": prompt}],
+            config=config,
+        )
+        reflection_raw_text = result.get("summary", "")
+    finally:
+        # 恢复 head_sequence(反思消息成为侧枝,不影响主路径)
+        await runner.trace_store.update_trace(trace_id, head_sequence=saved_head_sequence)
+
+    # --- 开始结构化解析与处理 ---
+    structured_entries = []
+    # 正则解析:匹配 - [intent:..., state:...] 经验内容
+    pattern = r"- \[(intent:.*?, state:.*?)\] (.*)"
+    matches = re.findall(pattern, reflection_raw_text)
+
+    for tags_str, content in matches:
+        # 生成唯一短 ID
+        ex_id = f"ex_{datetime.now().strftime('%m%d%H%M')}_{uuid.uuid4().hex[:4]}"
+        
+        # 提取标签详情
+        intent_match = re.search(r"intent:(.*?),", tags_str)
+        state_match = re.search(r"state:(.*)", tags_str)
+        
+        intents = [i.strip() for i in intent_match.group(1).split(",")] if intent_match else []
+        states = [s.strip() for s in state_match.group(1).split(",")] if state_match else []
+
+        # 构造符合文档规范的结构化条目
+        entry = f"""---
+id: {ex_id}
+trace_id: {trace_id}
+tags: {{intent: {intents}, state: {states}}}
+metrics: {{helpful: 1, harmful: 0}}
+created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+---
+- {content}
+- 引用策略 ID: [{ex_id}]
+"""
+        structured_entries.append(entry)
+
+    # 追加到经验文件
+    if structured_entries:
+        experiences_path = getattr(runner, "experiences_path", "./.cache/experiences.md")
+        os.makedirs(os.path.dirname(experiences_path), exist_ok=True)
+        
+        with open(experiences_path, "a", encoding="utf-8") as f:
+            f.write("\n\n" + "\n\n".join(structured_entries))
+        
+        logger.info(f"Successfully extracted {len(structured_entries)} structured experiences.")
+
+    return ReflectResponse(
+        trace_id=trace_id,
+        reflection=reflection_raw_text,
+    )
+
+
+@router.get("/running", tags=["run"])
+async def list_running():
+    """列出正在运行的 Trace"""
+    running = []
+    for tid, task in list(_running_tasks.items()):
+        if task.done():
+            _running_tasks.pop(tid, None)
+        else:
+            running.append(tid)
+    return {"running": running}
+
+
+# ===== 经验 API =====
+
+
+@experiences_router.get("/experiences")
+async def list_experiences():
+    """读取经验文件内容"""
+    runner = _get_runner()
+    experiences_path = getattr(runner, "experiences_path", "./.cache/experiences.md")
+
+    if not experiences_path or not os.path.exists(experiences_path):
+        return {"content": "", "path": experiences_path}
+
+    with open(experiences_path, "r", encoding="utf-8") as f:
+        content = f.read()
+
+    return {"content": content, "path": experiences_path}

+ 748 - 0
agent/trace/store.py

@@ -0,0 +1,748 @@
+"""
+FileSystem Trace Store - 文件系统存储实现
+
+用于跨进程数据共享,数据持久化到 .trace/ 目录
+
+目录结构:
+.trace/{trace_id}/
+├── meta.json           # Trace 元数据
+├── goal.json           # GoalTree(扁平 JSON,通过 parent_id 构建层级)
+├── messages/           # Messages(每条独立文件)
+│   ├── {message_id}.json
+│   └── ...
+└── events.jsonl        # 事件流(WebSocket 续传)
+
+Sub-Trace 是完全独立的 Trace,有自己的目录:
+.trace/{parent_id}@{mode}-{timestamp}-{seq}/
+├── meta.json           # parent_trace_id 指向父 Trace
+├── goal.json
+├── messages/
+└── events.jsonl
+"""
+
+import json
+import os
+import logging
+from pathlib import Path
+from typing import Dict, List, Optional, Any
+from datetime import datetime
+
+from .models import Trace, Message
+from .goal_models import GoalTree, Goal, GoalStats
+
+logger = logging.getLogger(__name__)
+
+
+class FileSystemTraceStore:
+    """文件系统 Trace 存储"""
+
+    def __init__(self, base_path: str = ".trace"):
+        self.base_path = Path(base_path)
+        self.base_path.mkdir(exist_ok=True)
+
+    def _get_trace_dir(self, trace_id: str) -> Path:
+        """获取 trace 目录"""
+        return self.base_path / trace_id
+
+    def _get_meta_file(self, trace_id: str) -> Path:
+        """获取 meta.json 文件路径"""
+        return self._get_trace_dir(trace_id) / "meta.json"
+
+    def _get_goal_file(self, trace_id: str) -> Path:
+        """获取 goal.json 文件路径"""
+        return self._get_trace_dir(trace_id) / "goal.json"
+
+    def _get_messages_dir(self, trace_id: str) -> Path:
+        """获取 messages 目录"""
+        return self._get_trace_dir(trace_id) / "messages"
+
+    def _get_message_file(self, trace_id: str, message_id: str) -> Path:
+        """获取 message 文件路径"""
+        return self._get_messages_dir(trace_id) / f"{message_id}.json"
+
+    def _get_events_file(self, trace_id: str) -> Path:
+        """获取 events.jsonl 文件路径"""
+        return self._get_trace_dir(trace_id) / "events.jsonl"
+
+    def _get_model_usage_file(self, trace_id: str) -> Path:
+        """获取 model_usage.json 文件路径"""
+        return self._get_trace_dir(trace_id) / "model_usage.json"
+
+    # ===== Trace 操作 =====
+
+    async def create_trace(self, trace: Trace) -> str:
+        """创建新的 Trace"""
+        trace_dir = self._get_trace_dir(trace.trace_id)
+        trace_dir.mkdir(exist_ok=True)
+
+        # 创建 messages 目录
+        messages_dir = self._get_messages_dir(trace.trace_id)
+        messages_dir.mkdir(exist_ok=True)
+
+        # 写入 meta.json
+        meta_file = self._get_meta_file(trace.trace_id)
+        meta_file.write_text(json.dumps(trace.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
+
+        # 创建空的 events.jsonl
+        events_file = self._get_events_file(trace.trace_id)
+        events_file.touch()
+
+        return trace.trace_id
+
+    async def get_trace(self, trace_id: str) -> Optional[Trace]:
+        """获取 Trace"""
+        meta_file = self._get_meta_file(trace_id)
+        if not meta_file.exists():
+            return None
+
+        data = json.loads(meta_file.read_text(encoding="utf-8"))
+
+        # 解析 datetime 字段
+        if data.get("created_at"):
+            data["created_at"] = datetime.fromisoformat(data["created_at"])
+        if data.get("completed_at"):
+            data["completed_at"] = datetime.fromisoformat(data["completed_at"])
+
+        return Trace(**data)
+
+    async def update_trace(self, trace_id: str, **updates) -> None:
+        """更新 Trace"""
+        trace = await self.get_trace(trace_id)
+        if not trace:
+            return
+
+        # 更新字段
+        for key, value in updates.items():
+            if hasattr(trace, key):
+                setattr(trace, key, value)
+
+        # 写回文件
+        meta_file = self._get_meta_file(trace_id)
+        meta_file.write_text(json.dumps(trace.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
+
+    async def list_traces(
+        self,
+        mode: Optional[str] = None,
+        agent_type: Optional[str] = None,
+        uid: Optional[str] = None,
+        status: Optional[str] = None,
+        limit: int = 50
+    ) -> List[Trace]:
+        """列出 Traces"""
+        traces = []
+
+        if not self.base_path.exists():
+            return []
+
+        for trace_dir in self.base_path.iterdir():
+            if not trace_dir.is_dir():
+                continue
+
+            meta_file = trace_dir / "meta.json"
+            if not meta_file.exists():
+                continue
+
+            try:
+                data = json.loads(meta_file.read_text(encoding="utf-8"))
+
+                # 过滤
+                if mode and data.get("mode") != mode:
+                    continue
+                if agent_type and data.get("agent_type") != agent_type:
+                    continue
+                if uid and data.get("uid") != uid:
+                    continue
+                if status and data.get("status") != status:
+                    continue
+
+                # 解析 datetime
+                if data.get("created_at"):
+                    data["created_at"] = datetime.fromisoformat(data["created_at"])
+                if data.get("completed_at"):
+                    data["completed_at"] = datetime.fromisoformat(data["completed_at"])
+
+                traces.append(Trace(**data))
+            except Exception:
+                continue
+
+        # 排序(最新的在前)
+        traces.sort(key=lambda t: t.created_at, reverse=True)
+
+        return traces[:limit]
+
+    # ===== GoalTree 操作 =====
+
+    async def get_goal_tree(self, trace_id: str) -> Optional[GoalTree]:
+        """获取 GoalTree"""
+        goal_file = self._get_goal_file(trace_id)
+        if not goal_file.exists():
+            return None
+
+        try:
+            data = json.loads(goal_file.read_text(encoding="utf-8"))
+            return GoalTree.from_dict(data)
+        except Exception:
+            return None
+
+    async def update_goal_tree(self, trace_id: str, tree: GoalTree) -> None:
+        """更新完整 GoalTree"""
+        goal_file = self._get_goal_file(trace_id)
+        goal_file.write_text(json.dumps(tree.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
+
+    async def add_goal(self, trace_id: str, goal: Goal) -> None:
+        """添加 Goal 到 GoalTree"""
+        tree = await self.get_goal_tree(trace_id)
+        if not tree:
+            return
+
+        tree.goals.append(goal)
+        await self.update_goal_tree(trace_id, tree)
+
+        # 推送 goal_added 事件
+        event_data = {
+            "goal": goal.to_dict(),
+            "parent_id": goal.parent_id
+        }
+        await self.append_event(trace_id, "goal_added", event_data)
+
+        # 打印详细的 goal 信息
+        desc_preview = goal.description[:80] + "..." if len(goal.description) > 80 else goal.description
+        print(f"[Goal Added] ID={goal.id}, Parent={goal.parent_id or 'root'}")
+        print(f"  📝 {desc_preview}")
+        if goal.reason:
+            reason_preview = goal.reason[:60] + "..." if len(goal.reason) > 60 else goal.reason
+            print(f"  💡 {reason_preview}")
+
+    async def update_goal(self, trace_id: str, goal_id: str, **updates) -> None:
+        """更新 Goal 字段"""
+        tree = await self.get_goal_tree(trace_id)
+        if not tree:
+            return
+
+        goal = tree.find(goal_id)
+        if not goal:
+            return
+
+        # 更新字段
+        for key, value in updates.items():
+            if hasattr(goal, key):
+                # 特殊处理 stats 字段(可能是 dict)
+                if key in ["self_stats", "cumulative_stats"] and isinstance(value, dict):
+                    value = GoalStats.from_dict(value)
+                setattr(goal, key, value)
+
+        await self.update_goal_tree(trace_id, tree)
+
+        # 推送 goal_updated 事件
+        # 如果状态变为 completed,检查是否需要级联完成父 Goal
+        affected_goals = [{"goal_id": goal_id, "updates": updates}]
+
+        if updates.get("status") == "completed":
+            # 检查级联完成:如果所有兄弟 Goal 都完成,父 Goal 也完成
+            cascade_completed = await self._check_cascade_completion(trace_id, goal)
+            affected_goals.extend(cascade_completed)
+
+        await self.append_event(trace_id, "goal_updated", {
+            "goal_id": goal_id,
+            "updates": updates,
+            "affected_goals": affected_goals
+        })
+        print(f"[DEBUG] Pushed goal_updated event: goal_id={goal_id}, updates={updates}, affected={len(affected_goals)}")
+
+    async def _check_cascade_completion(
+        self,
+        trace_id: str,
+        completed_goal: Goal
+    ) -> List[Dict[str, Any]]:
+        """
+        检查级联完成:如果一个 Goal 的所有子 Goal 都完成,则自动完成父 Goal
+
+        Args:
+            trace_id: Trace ID
+            completed_goal: 刚完成的 Goal
+
+        Returns:
+            受影响的父 Goals 列表(自动完成的)
+        """
+        if not completed_goal.parent_id:
+            return []
+
+        tree = await self.get_goal_tree(trace_id)
+        if not tree:
+            return []
+
+        affected = []
+        parent = tree.find(completed_goal.parent_id)
+
+        if not parent:
+            return []
+
+        # 获取父 Goal 的所有子 Goal
+        children = tree.get_children(parent.id)
+
+        # 检查是否所有子 Goal 都已完成(排除 abandoned)
+        all_completed = all(
+            child.status in ["completed", "abandoned"]
+            for child in children
+        )
+
+        if all_completed and parent.status != "completed":
+            # 自动完成父 Goal
+            parent.status = "completed"
+            if not parent.summary:
+                # 生成自动摘要
+                completed_count = sum(1 for c in children if c.status == "completed")
+                parent.summary = f"所有子目标已完成 ({completed_count}/{len(children)})"
+
+            await self.update_goal_tree(trace_id, tree)
+
+            affected.append({
+                "goal_id": parent.id,
+                "status": "completed",
+                "summary": parent.summary,
+                "cumulative_stats": parent.cumulative_stats.to_dict()
+            })
+
+            # 递归检查祖父 Goal
+            grandparent_affected = await self._check_cascade_completion(trace_id, parent)
+            affected.extend(grandparent_affected)
+
+        return affected
+
+    # ===== Message 操作 =====
+
+    async def add_message(self, message: Message) -> str:
+        """
+        添加 Message
+
+        自动更新关联 Goal 的 stats(self_stats 和祖先的 cumulative_stats)
+        """
+        trace_id = message.trace_id
+
+        # 1. 写入 message 文件
+        messages_dir = self._get_messages_dir(trace_id)
+        message_file = messages_dir / f"{message.message_id}.json"
+        message_file.write_text(json.dumps(message.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
+
+        # 2. 更新 trace 统计
+        trace = await self.get_trace(trace_id)
+        if trace:
+            trace.total_messages += 1
+            trace.last_sequence = max(trace.last_sequence, message.sequence)
+
+            # 累计 tokens(完整版)
+            if message.prompt_tokens:
+                trace.total_prompt_tokens += message.prompt_tokens
+            if message.completion_tokens:
+                trace.total_completion_tokens += message.completion_tokens
+            if message.reasoning_tokens:
+                trace.total_reasoning_tokens += message.reasoning_tokens
+            if message.cache_creation_tokens:
+                trace.total_cache_creation_tokens += message.cache_creation_tokens
+            if message.cache_read_tokens:
+                trace.total_cache_read_tokens += message.cache_read_tokens
+
+            # 向后兼容:也更新 total_tokens
+            if message.tokens:
+                trace.total_tokens += message.tokens
+            elif message.prompt_tokens or message.completion_tokens:
+                trace.total_tokens += (message.prompt_tokens or 0) + (message.completion_tokens or 0)
+
+            if message.cost:
+                trace.total_cost += message.cost
+            if message.duration_ms:
+                trace.total_duration_ms += message.duration_ms
+
+            # 更新 Trace
+            await self.update_trace(
+                trace_id,
+                total_messages=trace.total_messages,
+                last_sequence=trace.last_sequence,
+                total_tokens=trace.total_tokens,
+                total_prompt_tokens=trace.total_prompt_tokens,
+                total_completion_tokens=trace.total_completion_tokens,
+                total_reasoning_tokens=trace.total_reasoning_tokens,
+                total_cache_creation_tokens=trace.total_cache_creation_tokens,
+                total_cache_read_tokens=trace.total_cache_read_tokens,
+                total_cost=trace.total_cost,
+                total_duration_ms=trace.total_duration_ms
+            )
+
+        # 3. 更新 Goal stats
+        await self._update_goal_stats(trace_id, message)
+
+        # 4. 追加 message_added 事件
+        affected_goals = await self._get_affected_goals(trace_id, message)
+        event_id = await self.append_event(trace_id, "message_added", {
+            "message": message.to_dict(),
+            "affected_goals": affected_goals
+        })
+        if event_id:
+            try:
+                from . import websocket as trace_ws
+
+                await trace_ws.broadcast_message_added(
+                    trace_id=trace_id,
+                    event_id=event_id,
+                    message_dict=message.to_dict(),
+                    affected_goals=affected_goals,
+                )
+            except Exception:
+                logger.exception("Failed to broadcast message_added (trace_id=%s, event_id=%s)", trace_id, event_id)
+
+        return message.message_id
+
+    async def _update_goal_stats(self, trace_id: str, message: Message) -> None:
+        """更新 Goal 的 self_stats 和祖先的 cumulative_stats"""
+        tree = await self.get_goal_tree(trace_id)
+
+        if not tree:
+            return
+
+        # 找到关联的 Goal
+        goal = tree.find(message.goal_id)
+        if not goal:
+            return
+
+        # 更新自身 self_stats
+        goal.self_stats.message_count += 1
+        if message.tokens:
+            goal.self_stats.total_tokens += message.tokens
+        if message.cost:
+            goal.self_stats.total_cost += message.cost
+        # TODO: 更新 preview(工具调用摘要)
+
+        # 更新自身 cumulative_stats
+        goal.cumulative_stats.message_count += 1
+        if message.tokens:
+            goal.cumulative_stats.total_tokens += message.tokens
+        if message.cost:
+            goal.cumulative_stats.total_cost += message.cost
+
+        # 沿祖先链向上更新 cumulative_stats
+        current_goal = goal
+        while current_goal.parent_id:
+            parent = tree.find(current_goal.parent_id)
+            if not parent:
+                break
+
+            parent.cumulative_stats.message_count += 1
+            if message.tokens:
+                parent.cumulative_stats.total_tokens += message.tokens
+            if message.cost:
+                parent.cumulative_stats.total_cost += message.cost
+
+            current_goal = parent
+
+        # 保存更新后的 tree
+        await self.update_goal_tree(trace_id, tree)
+
+    async def _get_affected_goals(self, trace_id: str, message: Message) -> List[Dict[str, Any]]:
+        """获取受影响的 Goals(自身 + 所有祖先)"""
+        tree = await self.get_goal_tree(trace_id)
+
+        if not tree:
+            return []
+
+        goal = tree.find(message.goal_id)
+        if not goal:
+            return []
+
+        affected = []
+
+        # 添加自身(包含 self_stats 和 cumulative_stats)
+        affected.append({
+            "goal_id": goal.id,
+            "self_stats": goal.self_stats.to_dict(),
+            "cumulative_stats": goal.cumulative_stats.to_dict()
+        })
+
+        # 添加所有祖先(仅 cumulative_stats)
+        current_goal = goal
+        while current_goal.parent_id:
+            parent = tree.find(current_goal.parent_id)
+            if not parent:
+                break
+
+            affected.append({
+                "goal_id": parent.id,
+                "cumulative_stats": parent.cumulative_stats.to_dict()
+            })
+
+            current_goal = parent
+
+        return affected
+
+        return affected
+
+    async def get_message(self, message_id: str) -> Optional[Message]:
+        """获取 Message(扫描所有 trace)"""
+        for trace_dir in self.base_path.iterdir():
+            if not trace_dir.is_dir():
+                continue
+
+            # 检查 messages 目录
+            message_file = trace_dir / "messages" / f"{message_id}.json"
+            if message_file.exists():
+                try:
+                    data = json.loads(message_file.read_text(encoding="utf-8"))
+                    return Message.from_dict(data)
+                except Exception:
+                    pass
+
+        return None
+
+    async def get_trace_messages(
+        self,
+        trace_id: str,
+    ) -> List[Message]:
+        """获取 Trace 的所有 Messages(包含所有分支,按 sequence 排序)"""
+        messages_dir = self._get_messages_dir(trace_id)
+
+        if not messages_dir.exists():
+            return []
+
+        messages = []
+        for message_file in messages_dir.glob("*.json"):
+            try:
+                data = json.loads(message_file.read_text(encoding="utf-8"))
+                msg = Message.from_dict(data)
+                messages.append(msg)
+            except Exception:
+                continue
+
+        # 按 sequence 排序
+        messages.sort(key=lambda m: m.sequence)
+        return messages
+
+    async def get_main_path_messages(
+        self,
+        trace_id: str,
+        head_sequence: int
+    ) -> List[Message]:
+        """
+        获取主路径上的消息(从 head_sequence 沿 parent_sequence 链回溯到 root)
+
+        Returns:
+            按 sequence 正序排列的主路径 Message 列表
+        """
+        # 加载所有消息,建立 sequence -> Message 索引
+        all_messages = await self.get_trace_messages(trace_id)
+        messages_by_seq = {m.sequence: m for m in all_messages}
+
+        # 从 head 沿 parent chain 回溯
+        path = []
+        seq = head_sequence
+        while seq is not None:
+            msg = messages_by_seq.get(seq)
+            if not msg:
+                break
+            path.append(msg)
+            seq = msg.parent_sequence
+
+        # 反转为正序(root → head)
+        path.reverse()
+        return path
+
+    async def get_messages_by_goal(
+        self,
+        trace_id: str,
+        goal_id: str
+    ) -> List[Message]:
+        """获取指定 Goal 关联的所有 Messages"""
+        all_messages = await self.get_trace_messages(trace_id)
+        return [m for m in all_messages if m.goal_id == goal_id]
+
+    async def update_message(self, message_id: str, **updates) -> None:
+        """更新 Message 字段"""
+        message = await self.get_message(message_id)
+        if not message:
+            return
+
+        # 更新字段
+        for key, value in updates.items():
+            if hasattr(message, key):
+                setattr(message, key, value)
+
+        # 确定文件路径
+        messages_dir = self._get_messages_dir(message.trace_id)
+        message_file = messages_dir / f"{message_id}.json"
+        message_file.write_text(json.dumps(message.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
+
+    async def abandon_messages_after(self, trace_id: str, cutoff_sequence: int) -> List[str]:
+        """
+        将 sequence > cutoff_sequence 的 active messages 标记为 abandoned。
+        返回被 abandon 的 message_id 列表。
+        """
+        all_messages = await self.get_trace_messages(trace_id)
+        abandoned_ids = []
+        now = datetime.now()
+
+        for msg in all_messages:
+            if msg.sequence > cutoff_sequence and msg.status == "active":
+                msg.status = "abandoned"
+                msg.abandoned_at = now
+                # 直接写回文件
+                message_file = self._get_messages_dir(trace_id) / f"{msg.message_id}.json"
+                message_file.write_text(
+                    json.dumps(msg.to_dict(), indent=2, ensure_ascii=False),
+                    encoding="utf-8"
+                )
+                abandoned_ids.append(msg.message_id)
+
+        return abandoned_ids
+
+    # ===== 模型使用追踪 =====
+
+    async def record_model_usage(
+        self,
+        trace_id: str,
+        sequence: int,
+        role: str,
+        model: str,
+        prompt_tokens: int,
+        completion_tokens: int,
+        cache_read_tokens: int = 0,
+        tool_name: Optional[str] = None,
+    ) -> None:
+        """
+        记录模型使用情况到 model_usage.json
+
+        Args:
+            trace_id: Trace ID
+            sequence: 消息序号
+            role: 角色(assistant/tool)
+            model: 模型名称
+            prompt_tokens: 输入tokens
+            completion_tokens: 输出tokens
+            cache_read_tokens: 缓存读取tokens
+            tool_name: 工具名称(role=tool时)
+        """
+        usage_file = self._get_model_usage_file(trace_id)
+
+        # 读取现有数据
+        if usage_file.exists():
+            data = json.loads(usage_file.read_text(encoding="utf-8"))
+        else:
+            data = {
+                "summary": {
+                    "total_models": 0,
+                    "total_tokens": 0,
+                    "total_cache_read_tokens": 0,
+                    "agent_tokens": 0,
+                    "tool_tokens": 0,
+                },
+                "models": [],
+                "timeline": [],
+            }
+
+        # 更新summary
+        total_tokens = prompt_tokens + completion_tokens
+        data["summary"]["total_tokens"] += total_tokens
+        data["summary"]["total_cache_read_tokens"] += cache_read_tokens
+
+        if role == "assistant":
+            data["summary"]["agent_tokens"] += total_tokens
+            source = "agent"
+        else:
+            data["summary"]["tool_tokens"] += total_tokens
+            source = f"tool:{tool_name}" if tool_name else "tool"
+
+        # 更新models列表
+        model_entry = None
+        for m in data["models"]:
+            if m["model"] == model and m["source"] == source:
+                model_entry = m
+                break
+
+        if model_entry:
+            model_entry["prompt_tokens"] += prompt_tokens
+            model_entry["completion_tokens"] += completion_tokens
+            model_entry["total_tokens"] += total_tokens
+            model_entry["cache_read_tokens"] += cache_read_tokens
+            model_entry["call_count"] += 1
+        else:
+            data["models"].append({
+                "model": model,
+                "source": source,
+                "prompt_tokens": prompt_tokens,
+                "completion_tokens": completion_tokens,
+                "total_tokens": total_tokens,
+                "cache_read_tokens": cache_read_tokens,
+                "call_count": 1,
+            })
+            data["summary"]["total_models"] = len(data["models"])
+
+        # 添加到timeline
+        timeline_entry = {
+            "sequence": sequence,
+            "role": role,
+            "model": model,
+            "prompt_tokens": prompt_tokens,
+            "completion_tokens": completion_tokens,
+        }
+        if cache_read_tokens > 0:
+            timeline_entry["cache_read_tokens"] = cache_read_tokens
+        if tool_name:
+            timeline_entry["tool_name"] = tool_name
+        data["timeline"].append(timeline_entry)
+
+        # 写回文件
+        usage_file.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
+
+    # ===== 事件流操作(用于 WebSocket 断线续传)=====
+
+    async def get_events(
+        self,
+        trace_id: str,
+        since_event_id: int = 0
+    ) -> List[Dict[str, Any]]:
+        """获取事件流"""
+        events_file = self._get_events_file(trace_id)
+        if not events_file.exists():
+            return []
+
+        events = []
+        with events_file.open('r') as f:
+            for line in f:
+                try:
+                    event = json.loads(line.strip())
+                    if event.get("event_id", 0) > since_event_id:
+                        events.append(event)
+                except Exception:
+                    continue
+
+        return events
+
+    async def append_event(
+        self,
+        trace_id: str,
+        event_type: str,
+        payload: Dict[str, Any]
+    ) -> int:
+        """追加事件,返回 event_id"""
+        # 获取 trace 并递增 event_id
+        trace = await self.get_trace(trace_id)
+        if not trace:
+            return 0
+
+        trace.last_event_id += 1
+        event_id = trace.last_event_id
+
+        # 更新 trace 的 last_event_id
+        await self.update_trace(trace_id, last_event_id=event_id)
+
+        # 创建事件
+        event = {
+            "event_id": event_id,
+            "event": event_type,
+            "ts": datetime.now().isoformat(),
+            **payload
+        }
+
+        # 追加到 events.jsonl
+        events_file = self._get_events_file(trace_id)
+        with events_file.open('a', encoding='utf-8') as f:
+            f.write(json.dumps(event, ensure_ascii=False) + '\n')
+
+        return event_id

+ 147 - 0
agent/trace/trace_id.py

@@ -0,0 +1,147 @@
+"""
+Trace ID 生成和解析工具
+
+提供 Trace ID 的生成、解析等功能。
+
+Trace ID 格式:
+- 主 Trace: {uuid} (标准 UUID)
+- Sub-Trace: {parent_id}@{mode}-{timestamp}-{seq}
+  例如: 2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d@explore-20260204220012-001
+"""
+
+import uuid
+from datetime import datetime
+from threading import Lock
+from typing import Dict, Optional
+
+
+# 全局计数器(线程安全)
+_seq_lock = Lock()
+_seq_counter: Dict[str, int] = {}  # key: "{parent_id}@{mode}-{timestamp}"
+
+
+def generate_trace_id() -> str:
+    """
+    生成主 Trace ID
+
+    Returns:
+        标准 UUID 字符串
+    """
+    return str(uuid.uuid4())
+
+
+def generate_sub_trace_id(parent_id: str, mode: str) -> str:
+    """
+    生成 Sub-Trace ID
+
+    格式: {parent_id}@{mode}-{timestamp}-{seq}
+
+    使用完整的 parent_id(不截断),避免 ID 冲突风险。
+    同一秒内多次调用会递增序号。
+
+    Args:
+        parent_id: 父 Trace ID(完整 UUID)
+        mode: 运行模式(explore, delegate, compaction 等)
+
+    Returns:
+        Sub-Trace ID
+
+    Examples:
+        >>> generate_sub_trace_id("2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d", "explore")
+        '2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d@explore-20260204220012-001'
+
+        >>> generate_sub_trace_id("2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d", "delegate")
+        '2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d@delegate-20260204220030-001'
+    """
+    # 直接使用完整 UUID,不截断
+    timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
+
+    # 生成序号(同一秒内递增)
+    prefix = f"{parent_id}@{mode}-{timestamp}"
+    with _seq_lock:
+        seq = _seq_counter.get(prefix, 0) + 1
+        _seq_counter[prefix] = seq
+
+    return f"{prefix}-{seq:03d}"
+
+
+def parse_parent_trace_id(trace_id: str) -> Optional[str]:
+    """
+    从 trace_id 解析出 parent_trace_id
+
+    Args:
+        trace_id: Trace ID
+
+    Returns:
+        父 Trace ID,如果是主 Trace 则返回 None
+
+    Examples:
+        >>> parse_parent_trace_id("2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d@explore-20260204220012-001")
+        '2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d'
+
+        >>> parse_parent_trace_id("2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d")
+        None
+    """
+    if '@' in trace_id:
+        return trace_id.split('@')[0]
+    return None
+
+
+def is_sub_trace(trace_id: str) -> bool:
+    """
+    判断是否为 Sub-Trace
+
+    Args:
+        trace_id: Trace ID
+
+    Returns:
+        True 表示是 Sub-Trace,False 表示是主 Trace
+
+    Examples:
+        >>> is_sub_trace("2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d@explore-20260204220012-001")
+        True
+
+        >>> is_sub_trace("2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d")
+        False
+    """
+    return '@' in trace_id
+
+
+def extract_mode(trace_id: str) -> Optional[str]:
+    """
+    从 Sub-Trace ID 中提取运行模式
+
+    Args:
+        trace_id: Trace ID
+
+    Returns:
+        运行模式(explore, delegate 等),如果是主 Trace 则返回 None
+
+    Examples:
+        >>> extract_mode("2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d@explore-20260204220012-001")
+        'explore'
+
+        >>> extract_mode("2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d@delegate-20260204220030-001")
+        'delegate'
+
+        >>> extract_mode("2f8d3a1c-4b6e-4f9a-8c2d-1e5b7a9f3c4d")
+        None
+    """
+    if '@' not in trace_id:
+        return None
+
+    # 格式: parent@mode-timestamp-seq
+    parts = trace_id.split('@')[1]  # "mode-timestamp-seq"
+    mode = parts.split('-')[0]
+    return mode
+
+
+def reset_seq_counter():
+    """
+    重置序号计数器
+
+    主要用于测试,生产环境不应调用此函数。
+    """
+    global _seq_counter
+    with _seq_lock:
+        _seq_counter.clear()

+ 825 - 0
agent/trace/tree_dump.py

@@ -0,0 +1,825 @@
+"""
+Step 树 Debug 输出
+
+将 Step 树以完整格式输出到文件,便于开发调试。
+
+使用方式:
+    1. 命令行实时查看:
+       watch -n 0.5 cat .trace/tree.txt
+
+    2. VS Code 打开文件自动刷新:
+       code .trace/tree.txt
+
+    3. 代码中使用:
+       from agent.trace import dump_tree
+       dump_tree(trace, steps)
+"""
+
+import json
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+# 默认输出路径
+DEFAULT_DUMP_PATH = ".trace/tree.txt"
+DEFAULT_JSON_PATH = ".trace/tree.json"
+DEFAULT_MD_PATH = ".trace/tree.md"
+
+
+class StepTreeDumper:
+    """Step 树 Debug 输出器"""
+
+    def __init__(self, output_path: str = DEFAULT_DUMP_PATH):
+        self.output_path = Path(output_path)
+        self.output_path.parent.mkdir(parents=True, exist_ok=True)
+
+    def dump(
+        self,
+        trace: Optional[Dict[str, Any]] = None,
+        steps: Optional[List[Dict[str, Any]]] = None,
+        title: str = "Step Tree Debug",
+    ) -> str:
+        """
+        输出完整的树形结构到文件
+
+        Args:
+            trace: Trace 字典(可选)
+            steps: Step 字典列表
+            title: 输出标题
+
+        Returns:
+            输出的文本内容
+        """
+        lines = []
+
+        # 标题和时间
+        lines.append("=" * 60)
+        lines.append(f" {title}")
+        lines.append(f" Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+        lines.append("=" * 60)
+        lines.append("")
+
+        # Trace 信息
+        if trace:
+            lines.append("## Trace")
+            lines.append(f"  trace_id: {trace.get('trace_id', 'N/A')}")
+            lines.append(f"  task: {trace.get('task', 'N/A')}")
+            lines.append(f"  status: {trace.get('status', 'N/A')}")
+            lines.append(f"  total_steps: {trace.get('total_steps', 0)}")
+            lines.append(f"  total_tokens: {trace.get('total_tokens', 0)}")
+            lines.append(f"  total_cost: {trace.get('total_cost', 0.0):.4f}")
+            lines.append("")
+
+        # 统计摘要
+        if steps:
+            lines.append("## Statistics")
+            stats = self._calculate_statistics(steps)
+            lines.append(f"  Total steps: {stats['total']}")
+            lines.append(f"  By type:")
+            for step_type, count in sorted(stats['by_type'].items()):
+                lines.append(f"    {step_type}: {count}")
+            lines.append(f"  By status:")
+            for status, count in sorted(stats['by_status'].items()):
+                lines.append(f"    {status}: {count}")
+            if stats['total_duration_ms'] > 0:
+                lines.append(f"  Total duration: {stats['total_duration_ms']}ms")
+            if stats['total_tokens'] > 0:
+                lines.append(f"  Total tokens: {stats['total_tokens']}")
+            if stats['total_cost'] > 0:
+                lines.append(f"  Total cost: ${stats['total_cost']:.4f}")
+            lines.append("")
+
+        # Step 树
+        if steps:
+            lines.append("## Steps")
+            lines.append("")
+
+            # 构建树结构
+            tree = self._build_tree(steps)
+            tree_output = self._render_tree(tree, steps)
+            lines.append(tree_output)
+
+        content = "\n".join(lines)
+
+        # 写入文件
+        self.output_path.write_text(content, encoding="utf-8")
+
+        return content
+
+    def _calculate_statistics(self, steps: List[Dict[str, Any]]) -> Dict[str, Any]:
+        """计算统计信息"""
+        stats = {
+            'total': len(steps),
+            'by_type': {},
+            'by_status': {},
+            'total_duration_ms': 0,
+            'total_tokens': 0,
+            'total_cost': 0.0,
+        }
+
+        for step in steps:
+            # 按类型统计
+            step_type = step.get('step_type', 'unknown')
+            stats['by_type'][step_type] = stats['by_type'].get(step_type, 0) + 1
+
+            # 按状态统计
+            status = step.get('status', 'unknown')
+            stats['by_status'][status] = stats['by_status'].get(status, 0) + 1
+
+            # 累计指标
+            if step.get('duration_ms'):
+                stats['total_duration_ms'] += step.get('duration_ms', 0)
+            if step.get('tokens'):
+                stats['total_tokens'] += step.get('tokens', 0)
+            if step.get('cost'):
+                stats['total_cost'] += step.get('cost', 0.0)
+
+        return stats
+
+    def _build_tree(self, steps: List[Dict[str, Any]]) -> Dict[str, List[str]]:
+        """构建父子关系映射"""
+        # parent_id -> [child_ids]
+        children: Dict[str, List[str]] = {"__root__": []}
+
+        for step in steps:
+            step_id = step.get("step_id", "")
+            parent_id = step.get("parent_id")
+
+            if parent_id is None:
+                children["__root__"].append(step_id)
+            else:
+                if parent_id not in children:
+                    children[parent_id] = []
+                children[parent_id].append(step_id)
+
+        return children
+
+    def _render_tree(
+        self,
+        tree: Dict[str, List[str]],
+        steps: List[Dict[str, Any]],
+        parent_id: str = "__root__",
+        indent: int = 0,
+    ) -> str:
+        """递归渲染树结构"""
+        # step_id -> step 映射
+        step_map = {s.get("step_id"): s for s in steps}
+
+        lines = []
+        child_ids = tree.get(parent_id, [])
+
+        for i, step_id in enumerate(child_ids):
+            step = step_map.get(step_id, {})
+            is_last = i == len(child_ids) - 1
+
+            # 渲染当前节点
+            node_output = self._render_node(step, indent, is_last)
+            lines.append(node_output)
+
+            # 递归渲染子节点
+            if step_id in tree:
+                child_output = self._render_tree(tree, steps, step_id, indent + 1)
+                lines.append(child_output)
+
+        return "\n".join(lines)
+
+    def _render_node(self, step: Dict[str, Any], indent: int, is_last: bool) -> str:
+        """渲染单个节点的完整信息"""
+        lines = []
+
+        # 缩进和连接符
+        prefix = "  " * indent
+        connector = "└── " if is_last else "├── "
+        child_prefix = "  " * indent + ("    " if is_last else "│   ")
+
+        # 状态图标
+        status = step.get("status", "unknown")
+        status_icons = {
+            "completed": "✓",
+            "in_progress": "→",
+            "planned": "○",
+            "failed": "✗",
+            "skipped": "⊘",
+            "awaiting_approval": "⏸",
+        }
+        icon = status_icons.get(status, "?")
+
+        # 类型和描述
+        step_type = step.get("step_type", "unknown")
+        description = step.get("description", "")
+
+        # 第一行:类型和描述
+        lines.append(f"{prefix}{connector}[{icon}] {step_type}: {description}")
+
+        # 详细信息
+        step_id = step.get("step_id", "")[:8]  # 只显示前 8 位
+        lines.append(f"{child_prefix}id: {step_id}...")
+
+        # 关键字段:sequence, status, parent_id
+        sequence = step.get("sequence")
+        if sequence is not None:
+            lines.append(f"{child_prefix}sequence: {sequence}")
+        lines.append(f"{child_prefix}status: {status}")
+
+        parent_id = step.get("parent_id")
+        if parent_id:
+            lines.append(f"{child_prefix}parent_id: {parent_id[:8]}...")
+
+        # 执行指标
+        if step.get("duration_ms") is not None:
+            lines.append(f"{child_prefix}duration: {step.get('duration_ms')}ms")
+        if step.get("tokens") is not None:
+            lines.append(f"{child_prefix}tokens: {step.get('tokens')}")
+        if step.get("cost") is not None:
+            lines.append(f"{child_prefix}cost: ${step.get('cost'):.4f}")
+
+        # summary(如果有)
+        if step.get("summary"):
+            summary = step.get("summary", "")
+            # 截断长 summary
+            if len(summary) > 100:
+                summary = summary[:100] + "..."
+            lines.append(f"{child_prefix}summary: {summary}")
+
+        # 错误信息(结构化显示)
+        error = step.get("error")
+        if error:
+            lines.append(f"{child_prefix}error:")
+            lines.append(f"{child_prefix}  code: {error.get('code', 'UNKNOWN')}")
+            error_msg = error.get('message', '')
+            if len(error_msg) > 200:
+                error_msg = error_msg[:200] + "..."
+            lines.append(f"{child_prefix}  message: {error_msg}")
+            lines.append(f"{child_prefix}  retryable: {error.get('retryable', True)}")
+
+        # data 内容(格式化输出,更激进的截断)
+        data = step.get("data", {})
+        if data:
+            lines.append(f"{child_prefix}data:")
+            data_lines = self._format_data(data, child_prefix + "  ", max_value_len=150)
+            lines.append(data_lines)
+
+        # 时间
+        created_at = step.get("created_at", "")
+        if created_at:
+            if isinstance(created_at, str):
+                # 只显示时间部分
+                time_part = created_at.split("T")[-1][:8] if "T" in created_at else created_at
+            else:
+                time_part = created_at.strftime("%H:%M:%S")
+            lines.append(f"{child_prefix}time: {time_part}")
+
+        lines.append("")  # 空行分隔
+        return "\n".join(lines)
+
+    def _format_data(self, data: Dict[str, Any], prefix: str, max_value_len: int = 150) -> str:
+        """格式化 data 字典(更激进的截断策略)"""
+        lines = []
+
+        for key, value in data.items():
+            # 格式化值
+            if isinstance(value, str):
+                # 检测图片数据
+                if value.startswith("data:image") or (len(value) > 10000 and not "\n" in value[:100]):
+                    lines.append(f"{prefix}{key}: [IMAGE_DATA: {len(value)} chars, truncated]")
+                    continue
+
+                if len(value) > max_value_len:
+                    value_str = value[:max_value_len] + f"... ({len(value)} chars)"
+                else:
+                    value_str = value
+                # 处理多行字符串
+                if "\n" in value_str:
+                    first_line = value_str.split("\n")[0]
+                    line_count = value.count("\n") + 1
+                    value_str = first_line + f"... ({line_count} lines)"
+            elif isinstance(value, (dict, list)):
+                value_str = json.dumps(value, ensure_ascii=False, indent=2)
+                if len(value_str) > max_value_len:
+                    value_str = value_str[:max_value_len] + "..."
+                # 缩进多行
+                value_str = value_str.replace("\n", "\n" + prefix + "  ")
+            else:
+                value_str = str(value)
+
+            lines.append(f"{prefix}{key}: {value_str}")
+
+        return "\n".join(lines)
+
+    def dump_markdown(
+        self,
+        trace: Optional[Dict[str, Any]] = None,
+        steps: Optional[List[Dict[str, Any]]] = None,
+        title: str = "Step Tree Debug",
+        output_path: Optional[str] = None,
+    ) -> str:
+        """
+        输出 Markdown 格式(支持折叠,完整内容)
+
+        Args:
+            trace: Trace 字典(可选)
+            steps: Step 字典列表
+            title: 输出标题
+            output_path: 输出路径(默认 .trace/tree.md)
+
+        Returns:
+            输出的 Markdown 内容
+        """
+        lines = []
+
+        # 标题
+        lines.append(f"# {title}")
+        lines.append("")
+        lines.append(f"*Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
+        lines.append("")
+
+        # Trace 信息
+        if trace:
+            lines.append("## Trace")
+            lines.append("")
+            lines.append(f"- **trace_id**: `{trace.get('trace_id', 'N/A')}`")
+            lines.append(f"- **task**: {trace.get('task', 'N/A')}")
+            lines.append(f"- **status**: {trace.get('status', 'N/A')}")
+            lines.append(f"- **total_steps**: {trace.get('total_steps', 0)}")
+            lines.append(f"- **total_tokens**: {trace.get('total_tokens', 0)}")
+            lines.append(f"- **total_cost**: ${trace.get('total_cost', 0.0):.4f}")
+            lines.append("")
+
+        # 统计摘要
+        if steps:
+            lines.append("## Statistics")
+            lines.append("")
+            stats = self._calculate_statistics(steps)
+            lines.append(f"- **Total steps**: {stats['total']}")
+            lines.append("")
+            lines.append("**By type:**")
+            lines.append("")
+            for step_type, count in sorted(stats['by_type'].items()):
+                lines.append(f"- `{step_type}`: {count}")
+            lines.append("")
+            lines.append("**By status:**")
+            lines.append("")
+            for status, count in sorted(stats['by_status'].items()):
+                lines.append(f"- `{status}`: {count}")
+            lines.append("")
+            if stats['total_duration_ms'] > 0:
+                lines.append(f"- **Total duration**: {stats['total_duration_ms']}ms")
+            if stats['total_tokens'] > 0:
+                lines.append(f"- **Total tokens**: {stats['total_tokens']}")
+            if stats['total_cost'] > 0:
+                lines.append(f"- **Total cost**: ${stats['total_cost']:.4f}")
+            lines.append("")
+
+        # Steps
+        if steps:
+            lines.append("## Steps")
+            lines.append("")
+
+            # 构建树并渲染为 Markdown
+            tree = self._build_tree(steps)
+            step_map = {s.get("step_id"): s for s in steps}
+            md_output = self._render_markdown_tree(tree, step_map, level=3)
+            lines.append(md_output)
+
+        content = "\n".join(lines)
+
+        # 写入文件
+        if output_path is None:
+            output_path = str(self.output_path).replace(".txt", ".md")
+
+        Path(output_path).write_text(content, encoding="utf-8")
+        return content
+
+    def _render_markdown_tree(
+        self,
+        tree: Dict[str, List[str]],
+        step_map: Dict[str, Dict[str, Any]],
+        parent_id: str = "__root__",
+        level: int = 3,
+    ) -> str:
+        """递归渲染 Markdown 树"""
+        lines = []
+        child_ids = tree.get(parent_id, [])
+
+        for step_id in child_ids:
+            step = step_map.get(step_id, {})
+
+            # 渲染节点
+            node_md = self._render_markdown_node(step, level)
+            lines.append(node_md)
+
+            # 递归子节点
+            if step_id in tree:
+                child_md = self._render_markdown_tree(tree, step_map, step_id, level + 1)
+                lines.append(child_md)
+
+        return "\n".join(lines)
+
+    def _render_markdown_node(self, step: Dict[str, Any], level: int) -> str:
+        """渲染单个节点的 Markdown"""
+        lines = []
+
+        # 标题
+        status = step.get("status", "unknown")
+        status_icons = {
+            "completed": "✓",
+            "in_progress": "→",
+            "planned": "○",
+            "failed": "✗",
+            "skipped": "⊘",
+            "awaiting_approval": "⏸",
+        }
+        icon = status_icons.get(status, "?")
+
+        step_type = step.get("step_type", "unknown")
+        description = step.get("description", "")
+        heading = "#" * level
+
+        lines.append(f"{heading} [{icon}] {step_type}: {description}")
+        lines.append("")
+
+        # 基本信息
+        lines.append("**基本信息**")
+        lines.append("")
+        step_id = step.get("step_id", "")[:16]
+        lines.append(f"- **id**: `{step_id}...`")
+
+        # 关键字段
+        sequence = step.get("sequence")
+        if sequence is not None:
+            lines.append(f"- **sequence**: {sequence}")
+        lines.append(f"- **status**: {status}")
+
+        parent_id = step.get("parent_id")
+        if parent_id:
+            lines.append(f"- **parent_id**: `{parent_id[:16]}...`")
+
+        # 执行指标
+        if step.get("duration_ms") is not None:
+            lines.append(f"- **duration**: {step.get('duration_ms')}ms")
+        if step.get("tokens") is not None:
+            lines.append(f"- **tokens**: {step.get('tokens')}")
+        if step.get("cost") is not None:
+            lines.append(f"- **cost**: ${step.get('cost'):.4f}")
+
+        created_at = step.get("created_at", "")
+        if created_at:
+            if isinstance(created_at, str):
+                time_part = created_at.split("T")[-1][:8] if "T" in created_at else created_at
+            else:
+                time_part = created_at.strftime("%H:%M:%S")
+            lines.append(f"- **time**: {time_part}")
+
+        lines.append("")
+
+        # 错误信息
+        error = step.get("error")
+        if error:
+            lines.append("<details>")
+            lines.append("<summary><b>❌ Error</b></summary>")
+            lines.append("")
+            lines.append(f"- **code**: `{error.get('code', 'UNKNOWN')}`")
+            lines.append(f"- **retryable**: {error.get('retryable', True)}")
+            lines.append(f"- **message**:")
+            lines.append("```")
+            error_msg = error.get('message', '')
+            if len(error_msg) > 500:
+                error_msg = error_msg[:500] + "..."
+            lines.append(error_msg)
+            lines.append("```")
+            lines.append("")
+            lines.append("</details>")
+            lines.append("")
+
+        # Summary
+        if step.get("summary"):
+            lines.append("<details>")
+            lines.append("<summary><b>📝 Summary</b></summary>")
+            lines.append("")
+            summary = step.get('summary', '')
+            if len(summary) > 1000:
+                summary = summary[:1000] + "..."
+            lines.append(f"```\n{summary}\n```")
+            lines.append("")
+            lines.append("</details>")
+            lines.append("")
+
+        # Data(更激进的截断)
+        data = step.get("data", {})
+        if data:
+            lines.append(self._render_markdown_data(data))
+            lines.append("")
+
+        return "\n".join(lines)
+
+    def _render_markdown_data(self, data: Dict[str, Any]) -> str:
+        """渲染 data 字典为可折叠的 Markdown"""
+        lines = []
+
+        # 定义输出顺序(重要的放前面)
+        key_order = ["messages", "tools", "response", "content", "tool_calls", "model"]
+
+        # 先按顺序输出重要的 key
+        remaining_keys = set(data.keys())
+        for key in key_order:
+            if key in data:
+                lines.append(self._render_data_item(key, data[key]))
+                remaining_keys.remove(key)
+
+        # 再输出剩余的 key
+        for key in sorted(remaining_keys):
+            lines.append(self._render_data_item(key, data[key]))
+
+        return "\n".join(lines)
+
+    def _render_data_item(self, key: str, value: Any) -> str:
+        """渲染单个 data 项(更激进的截断)"""
+        # 确定图标
+        icon_map = {
+            "messages": "📨",
+            "response": "🤖",
+            "tools": "🛠️",
+            "tool_calls": "🔧",
+            "model": "🎯",
+            "error": "❌",
+            "content": "💬",
+            "output": "📤",
+            "arguments": "⚙️",
+        }
+        icon = icon_map.get(key, "📄")
+
+        # 特殊处理:跳过 None 值
+        if value is None:
+            return ""
+
+        # 特殊处理 messages 中的图片引用
+        if key == 'messages' and isinstance(value, list):
+            # 统计图片数量
+            image_count = 0
+            for msg in value:
+                if isinstance(msg, dict):
+                    content = msg.get('content', [])
+                    if isinstance(content, list):
+                        for item in content:
+                            if isinstance(item, dict) and item.get('type') == 'image_url':
+                                url = item.get('image_url', {}).get('url', '')
+                                if url.startswith('blob://'):
+                                    image_count += 1
+
+            if image_count > 0:
+                # 显示图片摘要
+                lines = []
+                lines.append("<details>")
+                lines.append(f"<summary><b>📨 Messages (含 {image_count} 张图片)</b></summary>")
+                lines.append("")
+                lines.append("```json")
+
+                # 渲染消息,图片显示为简化格式
+                simplified_messages = []
+                for msg in value:
+                    if isinstance(msg, dict):
+                        simplified_msg = msg.copy()
+                        content = msg.get('content', [])
+                        if isinstance(content, list):
+                            new_content = []
+                            for item in content:
+                                if isinstance(item, dict) and item.get('type') == 'image_url':
+                                    url = item.get('image_url', {}).get('url', '')
+                                    if url.startswith('blob://'):
+                                        blob_ref = url.replace('blob://', '')
+                                        size = item.get('image_url', {}).get('size', 0)
+                                        size_kb = size / 1024 if size > 0 else 0
+                                        new_content.append({
+                                            'type': 'image_url',
+                                            'image_url': {
+                                                'url': f'[IMAGE: {blob_ref[:8]}... ({size_kb:.1f}KB)]'
+                                            }
+                                        })
+                                    else:
+                                        new_content.append(item)
+                                else:
+                                    new_content.append(item)
+                            simplified_msg['content'] = new_content
+                        simplified_messages.append(simplified_msg)
+                    else:
+                        simplified_messages.append(msg)
+
+                lines.append(json.dumps(simplified_messages, ensure_ascii=False, indent=2))
+                lines.append("```")
+                lines.append("")
+                lines.append("</details>")
+                return "\n".join(lines)
+
+        # 判断是否需要折叠(长内容或复杂结构)
+        needs_collapse = False
+        if isinstance(value, str):
+            needs_collapse = len(value) > 100 or "\n" in value
+        elif isinstance(value, (dict, list)):
+            needs_collapse = True
+
+        if needs_collapse:
+            lines = []
+            # 可折叠块
+            lines.append("<details>")
+            lines.append(f"<summary><b>{icon} {key.capitalize()}</b></summary>")
+            lines.append("")
+
+            # 格式化内容(更激进的截断)
+            if isinstance(value, str):
+                # 检查是否包含图片 base64
+                if "data:image" in value or (isinstance(value, str) and len(value) > 10000 and not "\n" in value[:100]):
+                    lines.append("```")
+                    lines.append(f"[IMAGE DATA: {len(value)} chars, truncated for display]")
+                    lines.append("```")
+                elif len(value) > 2000:
+                    # 超长文本,只显示前500字符
+                    lines.append("```")
+                    lines.append(value[:500])
+                    lines.append(f"... (truncated, total {len(value)} chars)")
+                    lines.append("```")
+                else:
+                    lines.append("```")
+                    lines.append(value)
+                    lines.append("```")
+            elif isinstance(value, (dict, list)):
+                # 递归截断图片 base64
+                truncated_value = self._truncate_image_data(value)
+                json_str = json.dumps(truncated_value, ensure_ascii=False, indent=2)
+
+                # 如果 JSON 太长,也截断
+                if len(json_str) > 3000:
+                    json_str = json_str[:3000] + "\n... (truncated)"
+
+                lines.append("```json")
+                lines.append(json_str)
+                lines.append("```")
+
+            lines.append("")
+            lines.append("</details>")
+            return "\n".join(lines)
+        else:
+            # 简单值,直接显示
+            return f"- **{icon} {key}**: `{value}`"
+
+    def _truncate_image_data(self, obj: Any, max_length: int = 200) -> Any:
+        """递归截断对象中的图片 base64 数据"""
+        if isinstance(obj, dict):
+            result = {}
+            for key, value in obj.items():
+                # 检测图片 URL(data:image/...;base64,...)
+                if isinstance(value, str) and value.startswith("data:image"):
+                    # 提取 MIME 类型和数据长度
+                    header_end = value.find(",")
+                    if header_end > 0:
+                        header = value[:header_end]
+                        data = value[header_end+1:]
+                        data_size_kb = len(data) / 1024
+                        result[key] = f"<IMAGE_DATA: {data_size_kb:.1f}KB, {header}, preview: {data[:50]}...>"
+                    else:
+                        result[key] = value[:max_length] + f"... ({len(value)} chars)"
+                # 检测 blob 引用
+                elif isinstance(value, str) and value.startswith("blob://"):
+                    blob_ref = value.replace("blob://", "")
+                    result[key] = f"<BLOB_REF: {blob_ref[:8]}...>"
+                else:
+                    result[key] = self._truncate_image_data(value, max_length)
+            return result
+        elif isinstance(obj, list):
+            return [self._truncate_image_data(item, max_length) for item in obj]
+        elif isinstance(obj, str) and len(obj) > 100000:
+            # 超长字符串(可能是未检测到的 base64)
+            return obj[:max_length] + f"... (TRUNCATED: {len(obj)} chars total)"
+        else:
+            return obj
+
+
+def dump_tree(
+    trace: Optional[Any] = None,
+    steps: Optional[List[Any]] = None,
+    output_path: str = DEFAULT_DUMP_PATH,
+    title: str = "Step Tree Debug",
+) -> str:
+    """
+    便捷函数:输出 Step 树到文件
+
+    Args:
+        trace: Trace 对象或字典
+        steps: Step 对象或字典列表
+        output_path: 输出文件路径
+        title: 输出标题
+
+    Returns:
+        输出的文本内容
+
+    示例:
+        from agent.debug import dump_tree
+
+        # 每次 step 变化后调用
+        dump_tree(trace, steps)
+
+        # 自定义路径
+        dump_tree(trace, steps, output_path=".debug/my_trace.txt")
+    """
+    # 转换为字典
+    trace_dict = None
+    if trace is not None:
+        trace_dict = trace.to_dict() if hasattr(trace, "to_dict") else trace
+
+    steps_list = []
+    if steps:
+        for step in steps:
+            if hasattr(step, "to_dict"):
+                steps_list.append(step.to_dict())
+            else:
+                steps_list.append(step)
+
+    dumper = StepTreeDumper(output_path)
+    return dumper.dump(trace_dict, steps_list, title)
+
+
+def dump_json(
+    trace: Optional[Any] = None,
+    steps: Optional[List[Any]] = None,
+    output_path: str = DEFAULT_JSON_PATH,
+) -> str:
+    """
+    输出完整的 JSON 格式(用于程序化分析)
+
+    Args:
+        trace: Trace 对象或字典
+        steps: Step 对象或字典列表
+        output_path: 输出文件路径
+
+    Returns:
+        JSON 字符串
+    """
+    path = Path(output_path)
+    path.parent.mkdir(parents=True, exist_ok=True)
+
+    # 转换为字典
+    trace_dict = None
+    if trace is not None:
+        trace_dict = trace.to_dict() if hasattr(trace, "to_dict") else trace
+
+    steps_list = []
+    if steps:
+        for step in steps:
+            if hasattr(step, "to_dict"):
+                steps_list.append(step.to_dict())
+            else:
+                steps_list.append(step)
+
+    data = {
+        "generated_at": datetime.now().isoformat(),
+        "trace": trace_dict,
+        "steps": steps_list,
+    }
+
+    content = json.dumps(data, ensure_ascii=False, indent=2)
+    path.write_text(content, encoding="utf-8")
+
+    return content
+
+
+def dump_markdown(
+    trace: Optional[Any] = None,
+    steps: Optional[List[Any]] = None,
+    output_path: str = DEFAULT_MD_PATH,
+    title: str = "Step Tree Debug",
+) -> str:
+    """
+    便捷函数:输出 Markdown 格式(支持折叠,完整内容)
+
+    Args:
+        trace: Trace 对象或字典
+        steps: Step 对象或字典列表
+        output_path: 输出文件路径(默认 .trace/tree.md)
+        title: 输出标题
+
+    Returns:
+        输出的 Markdown 内容
+
+    示例:
+        from agent.debug import dump_markdown
+
+        # 输出完整可折叠的 Markdown
+        dump_markdown(trace, steps)
+
+        # 自定义路径
+        dump_markdown(trace, steps, output_path=".debug/debug.md")
+    """
+    # 转换为字典
+    trace_dict = None
+    if trace is not None:
+        trace_dict = trace.to_dict() if hasattr(trace, "to_dict") else trace
+
+    steps_list = []
+    if steps:
+        for step in steps:
+            if hasattr(step, "to_dict"):
+                steps_list.append(step.to_dict())
+            else:
+                steps_list.append(step)
+
+    dumper = StepTreeDumper(output_path)
+    return dumper.dump_markdown(trace_dict, steps_list, title, output_path)

+ 382 - 0
agent/trace/websocket.py

@@ -0,0 +1,382 @@
+"""
+Trace WebSocket 推送
+
+实时推送进行中 Trace 的更新,支持断线续传
+"""
+
+from typing import Dict, Set, Any
+from datetime import datetime
+import asyncio
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
+
+from .protocols import TraceStore
+
+
+router = APIRouter(prefix="/api/traces", tags=["websocket"])
+
+
+# ===== 全局状态 =====
+
+
+_trace_store: TraceStore = None
+_active_connections: Dict[str, Set[WebSocket]] = {}  # trace_id -> Set[WebSocket]
+
+
+def set_trace_store(store: TraceStore):
+    """设置 TraceStore 实例"""
+    global _trace_store
+    _trace_store = store
+
+
+def get_trace_store() -> TraceStore:
+    """获取 TraceStore 实例"""
+    if _trace_store is None:
+        raise RuntimeError("TraceStore not initialized")
+    return _trace_store
+
+
+# ===== WebSocket 路由 =====
+
+
+@router.websocket("/{trace_id}/watch")
+async def watch_trace(
+    websocket: WebSocket,
+    trace_id: str,
+    since_event_id: int = Query(0, description="从哪个事件 ID 开始(0=补发所有历史)")
+):
+    """
+    监听 Trace 的更新,支持断线续传
+
+    事件类型:
+    - connected: 连接成功,返回 goal_tree 和 sub_traces
+    - goal_added: 新增 Goal
+    - goal_updated: Goal 状态变化(含级联完成)
+    - message_added: 新 Message(含 affected_goals)
+    - sub_trace_started: Sub-Trace 开始执行
+    - sub_trace_completed: Sub-Trace 完成
+    - trace_completed: 执行完成
+
+    Args:
+        trace_id: Trace ID
+        since_event_id: 从哪个事件 ID 开始
+            - 0: 补发所有历史事件(初次连接)
+            - >0: 补发指定 ID 之后的事件(断线重连)
+    """
+    await websocket.accept()
+
+    # 验证 Trace 存在
+    store = get_trace_store()
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        await websocket.send_json({
+            "event": "error",
+            "message": "Trace not found"
+        })
+        await websocket.close()
+        return
+
+    # 注册连接
+    if trace_id not in _active_connections:
+        _active_connections[trace_id] = set()
+    _active_connections[trace_id].add(websocket)
+
+    try:
+        # 获取 GoalTree 和 Sub-Traces
+        goal_tree = await store.get_goal_tree(trace_id)
+
+        # 获取所有 Sub-Traces(通过 parent_trace_id 查询)
+        sub_traces = {}
+        all_traces = await store.list_traces(limit=1000)
+        for t in all_traces:
+            if t.parent_trace_id == trace_id:
+                sub_traces[t.trace_id] = t.to_dict()
+
+        # 发送连接成功消息 + 完整状态
+        await websocket.send_json({
+            "event": "connected",
+            "trace_id": trace_id,
+            "current_event_id": trace.last_event_id,
+            "goal_tree": goal_tree.to_dict() if goal_tree else None,
+            "sub_traces": sub_traces
+        })
+
+        # 补发历史事件(since_event_id=0 表示补发所有历史)
+        last_sent_event_id = since_event_id
+        if since_event_id >= 0:
+            missed_events = await store.get_events(trace_id, since_event_id)
+            # 限制补发数量(最多 100 条)
+            if len(missed_events) > 100:
+                await websocket.send_json({
+                    "event": "error",
+                    "message": f"Too many missed events ({len(missed_events)}), please reload via REST API"
+                })
+            else:
+                for evt in missed_events:
+                    await websocket.send_json(evt)
+                    if isinstance(evt, dict) and isinstance(evt.get("event_id"), int):
+                        last_sent_event_id = max(last_sent_event_id, evt["event_id"])
+
+        # 保持连接:同时支持心跳 + 轮询 events.jsonl(跨进程写入时也能实时推送)
+        while True:
+            try:
+                # 允许在没有客户端消息时继续轮询事件流
+                data = await asyncio.wait_for(websocket.receive_text(), timeout=0.5)
+                if data == "ping":
+                    await websocket.send_json({"event": "pong"})
+            except WebSocketDisconnect:
+                break
+            except asyncio.TimeoutError:
+                pass
+
+            new_events = await store.get_events(trace_id, last_sent_event_id)
+            if len(new_events) > 100:
+                await websocket.send_json({
+                    "event": "error",
+                    "message": f"Too many missed events ({len(new_events)}), please reload via REST API"
+                })
+                continue
+
+            for evt in new_events:
+                await websocket.send_json(evt)
+                if isinstance(evt, dict) and isinstance(evt.get("event_id"), int):
+                    last_sent_event_id = max(last_sent_event_id, evt["event_id"])
+
+    finally:
+        # 清理连接
+        if trace_id in _active_connections:
+            _active_connections[trace_id].discard(websocket)
+            if not _active_connections[trace_id]:
+                del _active_connections[trace_id]
+
+
+# ===== 广播函数(由 AgentRunner 或 TraceStore 调用)=====
+
+
+async def broadcast_goal_added(trace_id: str, goal_dict: Dict[str, Any]):
+    """
+    广播 Goal 添加事件
+
+    Args:
+        trace_id: Trace ID
+        goal_dict: Goal 字典(完整数据,含 stats)
+    """
+    if trace_id not in _active_connections:
+        return
+
+    store = get_trace_store()
+    event_id = await store.append_event(trace_id, "goal_added", {
+        "goal": goal_dict
+    })
+
+    message = {
+        "event": "goal_added",
+        "event_id": event_id,
+        "ts": datetime.now().isoformat(),
+        "goal": goal_dict
+    }
+
+    await _broadcast_to_trace(trace_id, message)
+
+
+async def broadcast_goal_updated(
+    trace_id: str,
+    goal_id: str,
+    updates: Dict[str, Any],
+    affected_goals: list[Dict[str, Any]] = None
+):
+    """
+    广播 Goal 更新事件(patch 语义)
+
+    Args:
+        trace_id: Trace ID
+        goal_id: Goal ID
+        updates: 更新字段(patch 格式)
+        affected_goals: 受影响的 Goals(含级联完成的父节点)
+    """
+    if trace_id not in _active_connections:
+        return
+
+    store = get_trace_store()
+    event_id = await store.append_event(trace_id, "goal_updated", {
+        "goal_id": goal_id,
+        "updates": updates,
+        "affected_goals": affected_goals or []
+    })
+
+    message = {
+        "event": "goal_updated",
+        "event_id": event_id,
+        "ts": datetime.now().isoformat(),
+        "goal_id": goal_id,
+        "patch": updates,
+        "affected_goals": affected_goals or []
+    }
+
+    await _broadcast_to_trace(trace_id, message)
+
+
+async def broadcast_message_added(
+    trace_id: str,
+    event_id: int,
+    message_dict: Dict[str, Any],
+    affected_goals: list[Dict[str, Any]] = None,
+):
+    """
+    广播 Message 添加事件(不在此处写入 events.jsonl)
+
+    说明:
+    - message_added 的 events.jsonl 写入由 TraceStore.append_event 负责
+    - 这里仅负责把“已经持久化”的事件推送给当前活跃连接
+    """
+    if trace_id not in _active_connections:
+        return
+
+    message = {
+        "event": "message_added",
+        "event_id": event_id,
+        "ts": datetime.now().isoformat(),
+        "message": message_dict,
+        "affected_goals": affected_goals or [],
+    }
+
+    await _broadcast_to_trace(trace_id, message)
+
+
+async def broadcast_sub_trace_started(
+    trace_id: str,
+    sub_trace_id: str,
+    parent_goal_id: str,
+    agent_type: str,
+    task: str
+):
+    """
+    广播 Sub-Trace 开始事件
+
+    Args:
+        trace_id: 主 Trace ID
+        sub_trace_id: Sub-Trace ID
+        parent_goal_id: 父 Goal ID
+        agent_type: Agent 类型
+        task: 任务描述
+    """
+    if trace_id not in _active_connections:
+        return
+
+    store = get_trace_store()
+    event_id = await store.append_event(trace_id, "sub_trace_started", {
+        "trace_id": sub_trace_id,
+        "parent_trace_id": trace_id,
+        "parent_goal_id": parent_goal_id,
+        "agent_type": agent_type,
+        "task": task
+    })
+
+    message = {
+        "event": "sub_trace_started",
+        "event_id": event_id,
+        "ts": datetime.now().isoformat(),
+        "trace_id": sub_trace_id,
+        "parent_goal_id": parent_goal_id,
+        "agent_type": agent_type,
+        "task": task
+    }
+
+    await _broadcast_to_trace(trace_id, message)
+
+
+async def broadcast_sub_trace_completed(
+    trace_id: str,
+    sub_trace_id: str,
+    status: str,
+    summary: str = "",
+    stats: Dict[str, Any] = None
+):
+    """
+    广播 Sub-Trace 完成事件
+
+    Args:
+        trace_id: 主 Trace ID
+        sub_trace_id: Sub-Trace ID
+        status: 状态(completed/failed)
+        summary: 总结
+        stats: 统计信息
+    """
+    if trace_id not in _active_connections:
+        return
+
+    store = get_trace_store()
+    event_id = await store.append_event(trace_id, "sub_trace_completed", {
+        "trace_id": sub_trace_id,
+        "status": status,
+        "summary": summary,
+        "stats": stats or {}
+    })
+
+    message = {
+        "event": "sub_trace_completed",
+        "event_id": event_id,
+        "ts": datetime.now().isoformat(),
+        "trace_id": sub_trace_id,
+        "status": status,
+        "summary": summary,
+        "stats": stats or {}
+    }
+
+    await _broadcast_to_trace(trace_id, message)
+
+
+async def broadcast_trace_completed(trace_id: str, total_messages: int):
+    """
+    广播 Trace 完成事件
+
+    Args:
+        trace_id: Trace ID
+        total_messages: 总 Message 数
+    """
+    if trace_id not in _active_connections:
+        return
+
+    store = get_trace_store()
+    event_id = await store.append_event(trace_id, "trace_completed", {
+        "total_messages": total_messages
+    })
+
+    message = {
+        "event": "trace_completed",
+        "event_id": event_id,
+        "ts": datetime.now().isoformat(),
+        "trace_id": trace_id,
+        "total_messages": total_messages
+    }
+
+    await _broadcast_to_trace(trace_id, message)
+
+    # 完成后清理所有连接
+    if trace_id in _active_connections:
+        del _active_connections[trace_id]
+
+
+# ===== 内部辅助函数 =====
+
+
+async def _broadcast_to_trace(trace_id: str, message: Dict[str, Any]):
+    """
+    向指定 Trace 的所有连接广播消息
+
+    Args:
+        trace_id: Trace ID
+        message: 消息内容
+    """
+    if trace_id not in _active_connections:
+        return
+
+    disconnected = []
+    for websocket in _active_connections[trace_id]:
+        try:
+            await websocket.send_json(message)
+        except Exception:
+            disconnected.append(websocket)
+
+    # 清理断开的连接
+    for ws in disconnected:
+        _active_connections[trace_id].discard(ws)

+ 7 - 0
agent/utils/__init__.py

@@ -0,0 +1,7 @@
+"""
+工具函数模块
+"""
+
+from agent.utils.logging import setup_logging
+
+__all__ = ["setup_logging"]

+ 40 - 0
agent/utils/logging.py

@@ -0,0 +1,40 @@
+"""
+日志配置工具
+
+提供统一的日志配置方法。
+"""
+
+import logging
+from typing import Optional
+
+
+def setup_logging(
+    level: str = "INFO",
+    format: Optional[str] = None,
+    file: Optional[str] = None
+):
+    """
+    配置日志系统
+
+    Args:
+        level: 日志级别(DEBUG, INFO, WARNING, ERROR, CRITICAL)
+        format: 日志格式(None 使用默认格式)
+        file: 日志文件路径(None 只输出到控制台)
+    """
+    log_level = getattr(logging, level.upper(), logging.INFO)
+    log_format = format or "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+
+    handlers = [logging.StreamHandler()]
+    if file:
+        handlers.append(logging.FileHandler(file, encoding="utf-8"))
+
+    logging.basicConfig(
+        level=log_level,
+        format=log_format,
+        handlers=handlers,
+        force=True
+    )
+
+    # 设置第三方库日志级别
+    logging.getLogger("httpx").setLevel(logging.WARNING)
+    logging.getLogger("httpcore").setLevel(logging.WARNING)

+ 130 - 0
api_server.py

@@ -0,0 +1,130 @@
+"""
+API Server - FastAPI 应用入口
+
+聚合所有模块的 API 路由:
+- GET  /api/traces — 查询(trace/api.py)
+- POST /api/traces — 执行控制(trace/run_api.py,需配置 Runner)
+- WS   /api/traces/{id}/watch — 实时推送(trace/websocket.py)
+- GET  /api/experiences — 经验查询(trace/run_api.py,需配置 Runner)
+"""
+
+import logging
+import json
+import os
+from fastapi import FastAPI, Request, WebSocket
+from fastapi.middleware.cors import CORSMiddleware
+import uvicorn
+
+from agent.trace import FileSystemTraceStore
+from agent.trace.api import router as api_router, set_trace_store as set_api_trace_store
+from agent.trace.run_api import router as run_router, experiences_router, set_runner
+from agent.trace.websocket import router as ws_router, set_trace_store as set_ws_trace_store
+from agent.trace.examples_api import router as examples_router
+from agent.trace.logs_websocket import router as logs_router, setup_websocket_logging
+
+
+# ===== 日志配置 =====
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
+)
+logger = logging.getLogger(__name__)
+
+# 设置WebSocket日志推送
+setup_websocket_logging(level=logging.INFO)
+
+
+# ===== FastAPI 应用 =====
+
+app = FastAPI(
+    title="Agent API",
+    description="Agent 查询 + 执行 API",
+    version="1.0.0"
+)
+
+# CORS 配置(允许前端跨域访问)
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],  # 生产环境应限制具体域名
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+
+# ===== 初始化存储 =====
+
+# 使用文件系统存储(支持跨进程和持久化)
+trace_store = FileSystemTraceStore(base_path=".trace")
+
+# 注入到 step_tree 模块
+set_api_trace_store(trace_store)
+set_ws_trace_store(trace_store)
+
+
+# ===== 可选:配置 Runner(启用执行 API)=====
+
+# 如需启用 POST /api/traces(新建/运行/停止/反思),取消以下注释并配置 LLM:
+
+from agent.core.runner import AgentRunner
+from agent.llm import create_openrouter_llm_call
+
+runner = AgentRunner(
+    trace_store=trace_store,
+    llm_call=create_openrouter_llm_call(model="anthropic/claude-sonnet-4.5"),
+)
+set_runner(runner)
+
+
+# ===== 注册路由 =====
+
+# Examples API(GET /api/examples)
+app.include_router(examples_router)
+
+# Trace 执行 API(POST + GET /running,需配置 Runner)
+# 注意:run_router 必须在 api_router 之前注册,否则 GET /running 会被 /{trace_id} 捕获
+app.include_router(run_router)
+
+# 经验 API(GET /api/experiences,需配置 Runner)
+app.include_router(experiences_router)
+
+# Trace 查询 API(GET)
+app.include_router(api_router)
+
+# Trace WebSocket(实时推送)
+app.include_router(ws_router)
+
+# Logs WebSocket(日志推送)
+app.include_router(logs_router)
+
+@app.websocket("/ws_ping")
+async def ws_ping(websocket: WebSocket):
+    await websocket.accept()
+    await websocket.send_text("pong")
+    await websocket.close()
+
+
+# ===== 健康检查 =====
+
+@app.get("/health")
+async def health_check():
+    """健康检查"""
+    return {
+        "status": "ok",
+        "service": "Agent Step Tree API",
+        "version": "1.0.0"
+    }
+
+
+# ===== 启动服务 =====
+
+if __name__ == "__main__":
+    logger.info("Starting API server...")
+    uvicorn.run(
+        "api_server:app",
+        host="0.0.0.0",
+        port=8000,
+        reload=True,  # 开发模式
+        log_level="info"
+    )

+ 28 - 0
config/feishu_contacts.json

@@ -0,0 +1,28 @@
+[
+    {
+        "name": "谭景玉",
+        "description": "",
+        "open_id": "ou_11fdbd559cc6513ab53ff06d6c63413d",
+        "chat_id": "oc_56e85f0e2c97405d176729b62d8f56e5"
+    },
+    {
+        "name": "王华东",
+        "description": "",
+        "open_id": "ou_82340312cf9d215f49a41b67fa9c02c2"
+    },
+    {
+        "name": "孙若天",
+        "description": "",
+        "open_id": "ou_ede69f28c2617bf80a7574f059879c8d"
+    },
+    {
+        "name": "刘斌",
+        "description": "",
+        "open_id": "ou_50c2307c3531e6293b3d5533d14592e9"
+    },
+    {
+        "name": "关涛",
+        "description": "",
+        "open_id": "ou_90b80ed994fe41b7f038a63cb9182f72"
+    }
+]

+ 224 - 0
config/pricing.yaml

@@ -0,0 +1,224 @@
+# LLM 定价配置
+#
+# 价格单位:美元 / 1M tokens
+# 支持通配符:* 匹配任意字符
+#
+# 字段说明:
+#   model: 模型名称(必填)
+#   input_price: 输入 token 价格(必填)
+#   output_price: 输出 token 价格(必填)
+#   reasoning_price: 推理 token 价格(可选,默认 = output_price)
+#   cache_creation_price: 缓存创建价格(可选,默认 = input_price * 1.25)
+#   cache_read_price: 缓存读取价格(可选,默认 = input_price * 0.1)
+#   provider: 提供商名称(可选,用于分类)
+#   description: 描述(可选)
+#
+# 使用方法:
+#   1. 复制此文件到项目根目录或 config/ 目录
+#   2. 或设置环境变量 AGENT_PRICING_CONFIG 指向配置文件
+#   3. 根据实际使用的模型修改价格
+
+models:
+  # ===== OpenAI =====
+  - model: gpt-4o
+    input_price: 2.50
+    output_price: 10.00
+    provider: openai
+
+  - model: gpt-4o-mini
+    input_price: 0.15
+    output_price: 0.60
+    provider: openai
+
+  - model: gpt-4-turbo
+    input_price: 10.00
+    output_price: 30.00
+    provider: openai
+
+  # o1 系列(有 reasoning tokens)
+  - model: o1
+    input_price: 15.00
+    output_price: 60.00
+    reasoning_price: 60.00  # reasoning tokens 和 output 同价
+    provider: openai
+
+  - model: o1-mini
+    input_price: 3.00
+    output_price: 12.00
+    reasoning_price: 12.00
+    provider: openai
+
+  - model: o3-mini
+    input_price: 1.10
+    output_price: 4.40
+    reasoning_price: 4.40
+    provider: openai
+
+  # ===== Anthropic Claude =====
+  # Claude 支持 prompt caching,缓存价格:
+  #   - cache_creation: 1.25x input_price
+  #   - cache_read: 0.1x input_price
+  - model: claude-3-5-sonnet-20241022
+    input_price: 3.00
+    output_price: 15.00
+    cache_creation_price: 3.75   # 3.00 * 1.25
+    cache_read_price: 0.30       # 3.00 * 0.1
+    provider: anthropic
+
+  - model: claude-3-5-haiku-20241022
+    input_price: 0.80
+    output_price: 4.00
+    provider: anthropic
+
+  - model: claude-3-opus-20240229
+    input_price: 15.00
+    output_price: 75.00
+    provider: anthropic
+
+  # Claude 通配符(匹配新版本)
+  - model: claude-3-5-sonnet*
+    input_price: 3.00
+    output_price: 15.00
+    provider: anthropic
+
+  - model: claude-sonnet-4*
+    input_price: 3.00
+    output_price: 15.00
+    provider: anthropic
+
+  - model: claude-opus-4*
+    input_price: 15.00
+    output_price: 75.00
+    provider: anthropic
+
+  # ===== Google Gemini =====
+  - model: gemini-2.5-pro
+    input_price: 1.25
+    output_price: 10.00
+    reasoning_price: 10.00  # thinking mode
+    provider: google
+
+  - model: gemini-2.0-flash
+    input_price: 0.10
+    output_price: 0.40
+    provider: google
+
+  - model: gemini-2.0-flash-thinking
+    input_price: 0.10
+    output_price: 0.40
+    reasoning_price: 0.40
+    provider: google
+
+  - model: gemini-1.5-pro
+    input_price: 1.25
+    output_price: 5.00
+    provider: google
+
+  - model: gemini-1.5-flash
+    input_price: 0.075
+    output_price: 0.30
+    provider: google
+
+  # Gemini 通配符
+  - model: gemini-2.5*
+    input_price: 1.25
+    output_price: 10.00
+    provider: google
+
+  - model: gemini-2.0*
+    input_price: 0.10
+    output_price: 0.40
+    provider: google
+
+  # ===== DeepSeek =====
+  - model: deepseek-chat
+    input_price: 0.14
+    output_price: 0.28
+    provider: deepseek
+
+  - model: deepseek-reasoner
+    input_price: 0.55
+    output_price: 2.19
+    reasoning_price: 2.19
+    provider: deepseek
+
+  - model: deepseek-r1*
+    input_price: 0.55
+    output_price: 2.19
+    reasoning_price: 2.19
+    provider: deepseek
+
+  # ===== OpenRouter 转发 =====
+  # OpenRouter 使用 provider/model 格式
+  - model: anthropic/claude-sonnet-4.5
+    input_price: 3.00
+    output_price: 15.00
+    provider: openrouter
+
+  - model: anthropic/claude-opus-4.5
+    input_price: 5.00
+    output_price: 25.00
+    provider: openrouter
+
+  - model: anthropic/claude-opus-4.6
+    input_price: 5.00
+    output_price: 25.00
+    provider: openrouter
+
+  - model: anthropic/claude-haiku-4.5
+    input_price: 1.00
+    output_price: 5.00
+    provider: openrouter
+
+  - model: anthropic/claude-sonnet-4
+    input_price: 3.00
+    output_price: 15.00
+    provider: openrouter
+
+  - model: anthropic/claude*
+    input_price: 3.00
+    output_price: 15.00
+    provider: openrouter
+
+  - model: openai/gpt-4o*
+    input_price: 2.50
+    output_price: 10.00
+    provider: openrouter
+
+  - model: openai/o1*
+    input_price: 15.00
+    output_price: 60.00
+    reasoning_price: 60.00
+    provider: openrouter
+
+  - model: google/gemini-3-pro-preview
+    input_price: 2
+    output_price: 12
+    reasoning_price: 12
+    provider: openrouter
+
+  - model: google/gemini-3-flash-preview
+    input_price: 0.50
+    output_price: 3
+    reasoning_price: 3
+    provider: openrouter
+
+  - model: google/gemini*
+    input_price: 0.30
+    output_price: 2.50
+    reasoning_price: 2.50
+    provider: openrouter
+
+  - model: deepseek/deepseek-r1*
+    input_price: 0.55
+    output_price: 2.19
+    reasoning_price: 2.19
+    provider: openrouter
+
+  # ===== Yescode 代理 =====
+  - model: claude-sonnet-4.5
+    input_price: 3.00
+    output_price: 15.00
+    cache_creation_price: 3.75
+    cache_read_price: 0.30
+    provider: yescode

+ 122 - 0
docs/README.md

@@ -0,0 +1,122 @@
+# Agent 系统文档
+
+## 文档导航
+
+本文档是项目总览和文档导航。详细的模块文档请参考:
+
+### 核心模块
+
+- **[Agent Core 模块](../agent/README.md)** - Agent 核心引擎、工具系统、记忆管理
+  - [架构设计](../agent/docs/architecture.md) - Agent 框架完整架构
+  - [工具系统](../agent/docs/tools.md)
+  - [Skills 指南](../agent/docs/skills.md)
+  - [Trace API](../agent/docs/trace-api.md)
+  - [多模态支持](../agent/docs/multimodal.md)
+  - [设计决策](../agent/docs/decisions.md)
+
+- **[Gateway 模块](../gateway/README.md)** - Agent 注册、消息路由、在线状态管理
+  - [架构设计](../gateway/docs/architecture.md)
+  - [部署指南](../gateway/docs/deployment.md)
+  - [API 参考](../gateway/docs/api.md)
+  - [设计决策](../gateway/docs/decisions.md)
+  - [Enterprise 层](../gateway/docs/enterprise/overview.md)
+  - [A2A IM 使用](../gateway/client/a2a_im.md) - Agent 间通讯工具
+
+### 跨模块文档
+
+- [A2A IM 系统](./a2a-im.md) - Agent 间即时通讯系统架构
+- [知识管理](../knowhub/docs/knowledge-management.md) - 知识结构、API、集成方式
+- [Context 管理](./context-management.md) - Goals、压缩、Plan 注入策略
+
+### 研究文档
+
+- [A2A 协议调研](./research/a2a-protocols.md) - 行业 A2A 通信协议和框架对比
+- [A2A 跨设备通信](./research/a2a-cross-device.md) - 跨设备 Agent 通信方案(内部)
+- [A2A Trace 存储](./research/a2a-trace-storage.md) - 跨设备 Trace 存储方案详细设计
+- [MAMP 协议](./research/a2a-mamp-protocol.md) - 与外部 Agent 系统的通用交互协议
+
+---
+
+## 文档维护规范
+
+0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
+1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议,或大量代码;决策依据或修改日志若有必要,可在模块的 decisions.md 另行记录
+
+---
+
+## 项目概览
+
+### 系统架构
+
+```
+Agent 系统
+├── agent/          # Agent Core - 核心引擎、工具、记忆
+├── gateway/        # Gateway - 消息路由、Agent 注册
+├── docs/           # 跨模块文档
+└── examples/       # 使用示例和集成示例
+```
+
+### 核心理念
+
+**所有 Agent 都是 Trace**
+
+| 类型 | 创建方式 | 父子关系 | 状态 |
+|------|---------|---------|------|
+| 主 Agent | 直接调用 `runner.run()` | 无 parent | 正常执行 |
+| 子 Agent | 通过 `agent` 工具 | `parent_trace_id` / `parent_goal_id` 指向父 | 正常执行 |
+| 人类协助 | 通过 `ask_human` 工具 | `parent_trace_id` 指向父 | 阻塞等待 |
+
+### 模块职责
+
+| 模块 | 职责 | 详细文档 |
+|-----|------|---------|
+| **agent/core/** | Agent 执行引擎 + 预设配置 | [架构设计](../agent/docs/architecture.md) |
+| **agent/trace/** | 执行追踪 + 计划管理 | [Trace API](../agent/docs/trace-api.md) |
+| **agent/tools/** | 与外部世界交互 | [工具系统](../agent/docs/tools.md) |
+| **agent/memory/** | 跨会话知识 | [Skills 指南](../agent/docs/skills.md) |
+| **agent/llm/** | LLM Provider 适配 | [架构设计](../agent/docs/architecture.md#llm-provider-适配) |
+| **gateway/core/** | Agent 注册和消息路由 | [Gateway 架构](../gateway/docs/architecture.md) |
+| **gateway/client/** | Gateway 客户端 SDK | [A2A IM](../gateway/client/a2a_im.md) |
+
+---
+
+## 快速开始
+
+### Agent Core
+
+```python
+from agent.core import AgentRunner, RunConfig
+
+runner = AgentRunner(...)
+
+async for item in runner.run(
+    messages=[{"role": "user", "content": "分析项目架构"}],
+    config=RunConfig(model="gpt-4o")
+):
+    print(item)
+```
+
+详见:[Agent Core README](../agent/README.md)
+
+### Gateway
+
+```bash
+# 安装 Gateway 客户端
+cd gateway
+pip install -e .
+
+# 使用 CLI
+gateway-cli send --from my-agent --to target-agent --message "Hello"
+gateway-cli list
+```
+
+详见:[Gateway README](../gateway/README.md) 和 [A2A IM 文档](../gateway/client/a2a_im.md)
+
+---
+
+## 相关文档
+
+完整的文档列表见各模块的 README:
+- [Agent Core 文档](../agent/README.md#文档)
+- [Gateway 文档](../gateway/README.md#文档)

+ 651 - 0
docs/a2a-im.md

@@ -0,0 +1,651 @@
+# A2A IM:Agent 即时通讯系统
+
+**更新日期:** 2026-03-04
+
+## 文档维护规范
+
+0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
+1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议,或大量代码;决策依据或修改日志若有必要,可在`docs/decisions.md`另行记录
+
+---
+
+## 文档说明
+
+本文档描述 Agent 间即时通讯(A2A IM)系统的架构和实现。
+
+**相关文档**:
+- [MAMP 协议](./research/a2a-mamp-protocol.md):消息格式和传输协议
+- [A2A 跨设备通信](./research/a2a-cross-device.md):内部 Agent 通信方案
+- [Agent 框架](./README.md):核心 Agent 能力
+- [Enterprise 层](../gateway/docs/enterprise/overview.md):组织级功能
+
+---
+
+## 系统概述
+
+A2A IM 是一个**任务导向的 Agent 即时通讯系统**,支持:
+- Agent 间消息传递(点对点、通过 Gateway)
+- 活跃协作者管理(当前任务)
+- 全局联系人管理(历史记录)
+- 在线状态查询
+- 对话历史追溯
+
+**与传统 IM 的区别**:
+- 任务导向(非纯聊天)
+- 长时间处理(分钟到小时)
+- 工具调用和执行记录
+- 完整的 Trace 追溯
+
+---
+
+## 架构层次关系
+
+A2A IM 在整体架构中的定位:
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Enterprise Layer(组织级)- 可选                              │
+│ - 认证和授权(飞书 OAuth、API Key、JWT)                      │
+│ - 审计和监控(操作日志、成本记录、安全事件)                    │
+│ - 多租户和权限控制(角色验证、资源访问控制)                    │
+│ - 成本管理和限额(用户级/组织级限额、超限告警)                 │
+│                                                              │
+│ 实现位置: gateway/enterprise/                                │
+│ 文档: gateway/docs/enterprise/overview.md                   │
+└─────────────────────────────────────────────────────────────┘
+
+┌─────────────────────────────────────────────────────────────┐
+│ A2A IM Gateway(通讯层)★ 本文档                             │
+│ - Agent 注册和发现(Registry)                                │
+│ - 消息路由(Gateway Router)                                  │
+│ - 活跃协作者管理(Collaborators)                             │
+│ - 在线状态管理(Heartbeat)                                   │
+│ - 联系人管理(ContactStore)                                  │
+│                                                              │
+│ 实现位置: gateway/core/                                       │
+│ 文档: docs/a2a-im.md(本文档)                                │
+└─────────────────────────────────────────────────────────────┘
+         ↕ 使用(单向依赖)
+┌─────────────────────────────────────────────────────────────┐
+│ Agent Core(核心层)                                          │
+│ - Trace、Message、Goal 管理                                  │
+│ - 工具系统(文件、命令、网络、浏览器)                          │
+│ - LLM 集成(Gemini、OpenRouter、Yescode)                    │
+│ - Skills 和 Memory(跨会话知识)                              │
+│ - 子 Agent 机制(agent 工具)                                 │
+│                                                              │
+│ 实现位置: agent/                                              │
+│ 文档: docs/README.md                                         │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### 层次说明
+
+**Agent Core(核心层)**:
+- 提供单个 Agent 的执行能力
+- 管理 Trace、Message、Goal
+- 提供工具系统和 LLM 集成
+- 支持子 Agent 创建(通过 `agent` 工具)
+- **独立部署**:可以不依赖 Gateway 运行
+
+**A2A IM Gateway(通讯层)**:
+- 与 Agent Core 并列,独立的系统
+- 提供 Agent 间通讯能力
+- 管理 Agent 注册和在线状态
+- 路由消息到目标 Agent
+- 维护活跃协作者和联系人
+- **依赖 Agent Core**:使用 ToolContext、TraceStore 等组件
+- **独立部署**:可以作为独立服务部署
+
+**Enterprise(组织层)**:
+- 可选的企业功能扩展
+- 提供企业级管理和控制
+- 认证、授权、审计
+- 多租户和成本管理
+- **可以集成到 Gateway**:作为 Gateway 的扩展模块
+- **也可以独立部署**:作为独立的 Enterprise Gateway 服务
+
+### 依赖关系
+
+```
+Enterprise → Gateway → Agent Core
+(可选)    (通讯)    (核心)
+
+- Agent Core 不依赖任何其他层(独立)
+- Gateway 依赖 Agent Core(单向依赖)
+- Enterprise 依赖 Gateway(可选扩展)
+```
+
+### 部署方式
+
+**方式 1:单体部署(个人/小团队)**
+```
+一个进程:
+├─ Agent Core
+└─ Gateway(包含 Enterprise 模块)
+```
+
+**方式 2:分离部署(中等规模)**
+```
+进程 1:Agent Core
+进程 2:Gateway(包含 Enterprise 模块)
+```
+
+**方式 3:分层部署(大规模/企业)**
+```
+进程 1:Agent Core
+进程 2:Gateway Core
+进程 3:Enterprise Gateway
+```
+
+---
+
+## 架构设计
+
+### 三层架构
+
+```
+┌─────────────────────────────────────────────────┐
+│ Layer 3: Agent 逻辑层                            │
+│ - Trace, Goal, Messages                         │
+│ - 工具调用和执行                                  │
+└─────────────────────────────────────────────────┘
+                    ↕
+┌─────────────────────────────────────────────────┐
+│ Layer 2: A2A IM 层                               │
+│ - 活跃协作者管理                                  │
+│ - 全局联系人管理                                  │
+│ - conversation_id ↔ trace_id 映射                │
+└─────────────────────────────────────────────────┘
+                    ↕
+┌─────────────────────────────────────────────────┐
+│ Layer 1: Gateway 层                              │
+│ - Agent 注册和发现                                │
+│ - 消息路由                                        │
+│ - 在线状态管理                                    │
+│ - WebSocket 长连接                                │
+└─────────────────────────────────────────────────┘
+```
+
+### 通信模式
+
+**模式 1:内部 Agent(同进程)**
+```
+Agent A → 直接调用 → Agent B
+(复用现有 agent 工具)
+```
+
+**模式 2:跨设备 Agent(组织内)**
+```
+PC Agent → WebSocket → Gateway → 云端 Agent
+(反向连接,无需公网 IP)
+```
+
+**模式 3:外部 Agent(跨组织)**
+```
+Agent A → MAMP 协议 → Agent B
+(点对点 HTTP)
+```
+
+---
+
+## 数据模型
+
+### 活跃协作者(Layer 2)
+
+存储在 `trace.context["collaborators"]`,记录当前任务的协作者。
+
+```python
+{
+    "name": "code-analyst",
+    "type": "agent",  # agent | human
+    "agent_uri": "agent://other.com/code-analyst",
+    "trace_id": "abc-123",
+    "conversation_id": "conv-456",
+    "status": "running",  # running | waiting | completed | failed
+    "summary": "正在分析代码架构",
+    "last_message_at": "2026-03-04T10:30:00Z"
+}
+```
+
+**实现位置**:`agent/core/runner.py:AgentRunner._build_context_injection`
+
+### 全局联系人(Layer 2)
+
+存储在 `.trace/contacts.json`,记录所有历史联系过的 Agent。
+
+```python
+{
+    "agent_uri": "agent://other.com/code-analyst",
+    "name": "Code Analyst",
+    "type": "agent",
+
+    # 身份信息(从 Agent Card 获取)
+    "card": {
+        "description": "专注于代码分析",
+        "capabilities": ["code_analysis", "file_read"],
+        "owner": {"user_name": "张三"}
+    },
+
+    # 交互统计
+    "stats": {
+        "first_contact": "2026-02-01T10:00:00Z",
+        "last_contact": "2026-03-04T10:30:00Z",
+        "total_conversations": 15,
+        "total_messages": 127
+    },
+
+    # 最近对话
+    "recent_conversations": [
+        {
+            "conversation_id": "conv-456",
+            "trace_id": "abc-123",
+            "started_at": "2026-03-04T10:00:00Z",
+            "last_message": "分析完成",
+            "status": "active"
+        }
+    ],
+
+    # 关系标签
+    "tags": ["code", "architecture"],
+    "pinned": false
+}
+```
+
+**实现位置**:`agent/trace/contact_store.py`
+
+### Agent 注册信息(Layer 1)
+
+存储在 Gateway,记录在线 Agent 的连接信息。
+
+```python
+{
+    "agent_uri": "agent://internal/code-analyst",
+    "connection_type": "websocket",  # websocket | http
+    "websocket": <WebSocket>,  # WebSocket 连接对象
+    "http_endpoint": "http://localhost:8001",  # HTTP 端点
+    "last_heartbeat": "2026-03-04T10:30:00Z",
+    "capabilities": ["code_analysis", "file_read"]
+}
+```
+
+**实现位置**:`gateway/core/registry.py`
+
+---
+
+## 核心功能
+
+### 1. Agent 注册和发现
+
+**PC Agent 启动时注册**:
+
+```python
+# 建立 WebSocket 长连接
+ws = await websockets.connect("wss://gateway.com/gateway/connect")
+
+# 注册
+await ws.send(json.dumps({
+    "type": "register",
+    "agent_uri": "agent://internal/my-agent",
+    "capabilities": ["file_read", "bash"]
+}))
+
+# 保持心跳
+while True:
+    await ws.send(json.dumps({"type": "heartbeat"}))
+    await asyncio.sleep(30)
+```
+
+**实现位置**:`gateway/core/client.py`
+
+### 2. 消息路由
+
+**通过 Gateway 发送消息**:
+
+```python
+# 发送方
+POST /gateway/send
+{
+    "to": "agent://internal/code-analyst",
+    "content": "帮我分析代码"
+}
+
+# Gateway 查找目标 Agent
+agent_info = registry.lookup("agent://internal/code-analyst")
+
+# 通过 WebSocket 推送
+await agent_info["websocket"].send(json.dumps({
+    "type": "message",
+    "from": "agent://internal/caller",
+    "content": "帮我分析代码"
+}))
+```
+
+**实现位置**:`gateway/core/router.py`
+
+### 3. 活跃协作者管理
+
+**发送消息时自动更新**:
+
+```python
+# agent/tools/builtin/a2a_im.py
+
+async def send_to_agent(...):
+    # 发送消息
+    response = await gateway_client.send(...)
+
+    # 更新活跃协作者
+    await update_active_collaborator(
+        trace_id=ctx.trace_id,
+        agent_uri=target_agent,
+        conversation_id=response["conversation_id"],
+        status="waiting"
+    )
+```
+
+**周期性注入到 Agent 上下文**:
+
+```python
+# agent/core/runner.py
+
+if iteration % 10 == 0:
+    collaborators = trace.context.get("collaborators", [])
+    inject_collaborators_markdown(collaborators)
+```
+
+### 4. 全局联系人管理
+
+**查询联系人**:
+
+```python
+# 通过工具查询
+contacts = await get_contacts(
+    type="agent",
+    status="online",
+    tags=["code"]
+)
+```
+
+**自动维护**:
+
+```python
+# 发送/接收消息时自动更新
+await contact_store.update(
+    agent_uri=target_agent,
+    last_contact=datetime.now(),
+    increment_message_count=True
+)
+```
+
+**实现位置**:`agent/trace/contact_store.py`
+
+### 5. 在线状态查询
+
+**查询 Agent 在线状态**:
+
+```python
+GET /gateway/status/{agent_uri}
+
+返回:
+{
+    "agent_uri": "agent://internal/code-analyst",
+    "status": "online",  # online | offline
+    "last_seen": "2026-03-04T10:30:00Z"
+}
+```
+
+**实现位置**:`gateway/core/router.py:get_agent_status`
+
+---
+
+## API 端点
+
+### Gateway API
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| WS | `/gateway/connect` | Agent 注册和保持连接 |
+| POST | `/gateway/send` | 发送消息到其他 Agent |
+| GET | `/gateway/status/{agent_uri}` | 查询 Agent 在线状态 |
+| GET | `/gateway/agents` | 列出所有在线 Agent |
+
+### A2A IM API
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/traces/{id}/collaborators` | 查询活跃协作者 |
+| GET | `/api/contacts` | 查询全局联系人 |
+| GET | `/api/contacts/{agent_uri}` | 查询特定联系人详情 |
+| GET | `/api/contacts/{agent_uri}/conversations` | 查询对话历史 |
+
+---
+
+## 工具系统
+
+### send_to_agent 工具
+
+发送消息到其他 Agent(内部或外部)。
+
+```python
+@tool(description="发送消息到其他 Agent")
+async def send_to_agent(
+    target_agent: str,  # agent://domain/id
+    message: str,
+    conversation_id: Optional[str] = None,
+    ctx: ToolContext = None
+) -> ToolResult
+```
+
+**实现位置**:`agent/tools/builtin/a2a_im.py:send_to_agent`
+
+### get_active_collaborators 工具
+
+查询当前任务的活跃协作者。
+
+```python
+@tool(description="查询当前任务的活跃协作者")
+async def get_active_collaborators(
+    ctx: ToolContext
+) -> ToolResult
+```
+
+**实现位置**:`agent/tools/builtin/a2a_im.py:get_active_collaborators`
+
+### get_contacts 工具
+
+查询全局联系人列表。
+
+```python
+@tool(description="查询所有联系过的 Agent")
+async def get_contacts(
+    type: Optional[str] = None,  # agent | human
+    status: Optional[str] = None,  # online | offline
+    tags: Optional[List[str]] = None,
+    ctx: ToolContext = None
+) -> ToolResult
+```
+
+**实现位置**:`agent/tools/builtin/a2a_im.py:get_contacts`
+
+---
+
+## Skill 系统
+
+### a2a_im.md Skill
+
+提供 A2A IM 使用指南,注入到 Agent 的 system prompt。
+
+**内容**:
+- 如何发送消息到其他 Agent
+- 如何查询活跃协作者
+- 如何查询联系人
+- 最佳实践
+
+**实现位置**:`agent/memory/skills/a2a_im.md`
+
+---
+
+## 使用示例
+
+### 场景 1:调用其他 Agent 协作
+
+```python
+# Agent A 需要代码分析帮助
+result = await send_to_agent(
+    target_agent="agent://internal/code-analyst",
+    message="帮我分析 /path/to/project 的架构"
+)
+
+# 继续对话
+result2 = await send_to_agent(
+    target_agent="agent://internal/code-analyst",
+    message="重点分析 core 模块",
+    conversation_id=result["conversation_id"]
+)
+```
+
+### 场景 2:查询活跃协作者
+
+```python
+# 查看当前任务中有哪些 Agent 在协作
+collaborators = await get_active_collaborators()
+
+# 输出:
+# ## 活跃协作者
+# - code-analyst [agent, completed]: 分析完成,发现3个问题
+# - test-runner [agent, running]: 正在运行测试
+```
+
+### 场景 3:查询联系人
+
+```python
+# 查找擅长代码分析的 Agent
+contacts = await get_contacts(
+    type="agent",
+    tags=["code", "architecture"]
+)
+
+# 输出:
+# ## 联系人列表
+# 🟢 code-analyst - agent://internal/code-analyst
+#    最后联系: 2026-03-04 10:30
+#    对话次数: 15
+```
+
+---
+
+## 架构决策
+
+### 决策 1:Gateway 与 Agent 并列而非包含
+
+**问题**:Gateway 应该放在 agent/ 内部还是与 agent/ 并列?
+
+**决策**:与 agent/ 并列
+
+**理由**:
+1. **解耦**:Gateway 和 Agent Core 是两个独立的系统
+2. **独立部署**:Gateway 可以独立部署和扩展
+3. **职责清晰**:Agent Core 负责单 Agent 执行,Gateway 负责 Agent 间通讯
+4. **依赖关系**:Gateway 依赖 Agent Core(单向),但 Agent Core 不依赖 Gateway
+
+**实现**:
+- 目录结构:`gateway/` 与 `agent/` 并列
+- Import 路径:`from gateway.core import ...`
+
+### 决策 2:Enterprise 与 Gateway 的关系
+
+**问题**:Enterprise 应该是 Gateway 的上层(分层架构)还是 Gateway 的模块(模块化架构)?
+
+**决策**:根据阶段选择
+
+**MVP 阶段(当前)**:模块化架构
+- Enterprise 作为 Gateway 的可选模块
+- 部署简单,快速迭代
+- 适合中小规模
+
+**大规模阶段(未来)**:可选分层架构
+- Enterprise 作为独立的 Gateway 层
+- 可独立扩容,团队协作
+- 适合大规模部署
+
+**理由**:
+1. **灵活性**:两种架构都可以实现可选部署
+2. **演进路径**:从模块化开始,需要时重构为分层
+3. **规模决定**:小规模用模块化,大规模用分层
+
+**实现**:
+- 当前:`gateway/enterprise/` 作为可选模块
+- 未来:可重构为独立的 `enterprise_gateway/` 服务
+
+### 决策 3:活跃协作者的管理方式
+
+**问题**:活跃协作者信息应该如何存储和管理?
+
+**决策**:存储在 `trace.context["collaborators"]`,由工具自动维护
+
+**理由**:
+1. **复用现有机制**:Agent Core 已有 context 机制
+2. **自动注入**:Runner 周期性注入到 Agent 上下文(每 10 轮)
+3. **工具维护**:send_to_agent 等工具自动更新
+4. **与 Goal 一致**:与 GoalTree 一同注入,保持一致性
+
+**实现位置**:
+- 存储:`trace.context["collaborators"]`
+- 注入:`agent/core/runner.py:AgentRunner._build_context_injection`
+- 更新:`agent/tools/builtin/a2a_im.py:_update_active_collaborator`
+
+---
+
+## 实现路线图
+
+### Phase 1:基础功能(1-2 周)
+
+**目标**:实现核心通信能力
+
+**任务**:
+1. 实现 Gateway(注册、路由、WebSocket)
+2. 实现 send_to_agent 工具
+3. 实现活跃协作者自动更新
+4. 实现 a2a_im.md Skill
+
+**实现位置**:
+- `gateway/core/`
+- `agent/tools/builtin/a2a_im.py`
+- `agent/memory/skills/a2a_im.md`
+
+### Phase 2:联系人管理(1 周)
+
+**目标**:完善联系人和历史记录
+
+**任务**:
+1. 实现 ContactStore
+2. 实现 get_contacts 工具
+3. 实现对话历史查询
+4. 实现在线状态查询
+
+**实现位置**:
+- `agent/trace/contact_store.py`
+- `agent/tools/builtin/a2a_im.py`
+
+### Phase 3:增强功能(可选)
+
+**目标**:提升用户体验
+
+**任务**:
+1. 实现消息队列(异步处理)
+2. 实现 Agent 发现和推荐
+3. 实现关系标签和分组
+4. 实现 UI 界面
+
+---
+
+## 相关文档
+
+- [MAMP 协议](./research/a2a-mamp-protocol.md):消息格式和传输协议
+- [A2A 跨设备通信](./research/a2a-cross-device.md):内部 Agent 通信方案
+- [工具系统](../agent/docs/tools.md):工具定义、注册
+- [Skills 指南](../agent/docs/skills.md):Skill 分类、编写、加载
+- [Agent 框架](./README.md):核心 Agent 能力
+- [Gateway 架构](../gateway/docs/architecture.md):Gateway 三层架构
+- [Gateway API](../gateway/docs/api.md):Gateway API 参考

+ 71 - 0
docs/research/README.md

@@ -0,0 +1,71 @@
+# Agent2Agent (A2A) 通信调研
+
+本目录包含 Agent2Agent 跨设备通信的调研和设计文档。
+
+## 文档列表
+
+| 文档 | 内容 | 状态 |
+|-----|------|------|
+| [a2a-protocols.md](./a2a-protocols.md) | 行业 A2A 协议和框架调研 | 已完成 |
+| [a2a-cross-device.md](./a2a-cross-device.md) | 跨设备通信方案设计 | 已完成 |
+| [a2a-trace-storage.md](./a2a-trace-storage.md) | 跨设备 Trace 存储方案 | 已完成 |
+| [a2a-continuous-dialogue.md](./a2a-continuous-dialogue.md) | 持续对话方案(已废弃) | 已废弃 |
+
+## 核心设计
+
+### 远程 Trace ID
+
+通过在 Trace ID 中编码位置信息实现跨设备访问:
+
+```
+本地 Trace:  abc-123
+远程 Trace:  agent://terminal-agent-456/abc-123
+```
+
+### 持续对话
+
+通过 `continue_from` 参数实现 Agent 间持续对话:
+
+```python
+# 第一次调用
+result1 = agent(task="分析项目", agent_url="https://remote-agent")
+# 返回: {"sub_trace_id": "agent://remote-agent/abc-123"}
+
+# 继续对话
+result2 = agent(
+    task="重点分析core模块",
+    continue_from=result1["sub_trace_id"],
+    agent_url="https://remote-agent"
+)
+```
+
+### 存储架构
+
+**HybridTraceStore** 自动路由到本地或远程存储:
+- 本地 Trace → `FileSystemTraceStore`
+- 远程 Trace → `RemoteTraceStore`(通过 HTTP API)
+
+## 实现计划
+
+### Phase 1: 基础跨设备通信(1-2周)
+- [ ] 实现 `RemoteTraceStore`
+- [ ] 实现 `HybridTraceStore`
+- [ ] 修改 `agent` 工具支持 `agent_url` 参数
+- [ ] 添加远程 Trace ID 解析
+
+### Phase 2: 增强功能(2-3周)
+- [ ] 认证和授权
+- [ ] 成本控制
+- [ ] 审计日志
+- [ ] 性能优化(缓存、批量API)
+
+### Phase 3: 生产化(按需)
+- [ ] 错误处理和重试
+- [ ] 监控和告警
+- [ ] 文档和示例
+
+## 参考资料
+
+- [Google A2A Protocol](https://a2a-protocol.org/latest/specification/)
+- [Anthropic MCP](https://modelcontextprotocol.io/specification/2025-06-18)
+- [Agent Interoperability Survey](https://arxiv.org/html/2505.02279v1)

+ 733 - 0
docs/research/a2a-continuous-dialogue.md

@@ -0,0 +1,733 @@
+# Agent2Agent 持续对话方案
+
+**更新日期:** 2026-03-03
+
+## 问题定义
+
+### 单次任务 vs 持续对话
+
+**单次任务(之前的方案):**
+```
+云端Agent: 请分析本地项目
+    ↓
+终端Agent: [执行分析] → 返回结果
+    ↓
+结束
+```
+
+**持续对话(新需求):**
+```
+云端Agent: 请分析本地项目
+    ↓
+终端Agent: 我看到有3个模块,你想重点分析哪个?
+    ↓
+云端Agent: 重点分析core模块
+    ↓
+终端Agent: core模块使用了X架构,需要我详细说明吗?
+    ↓
+云端Agent: 是的,请详细说明
+    ↓
+终端Agent: [详细分析] → 返回结果
+    ↓
+结束(或继续)
+```
+
+## 核心挑战
+
+1. **上下文延续** - 如何维护多轮对话的上下文?
+2. **状态管理** - 对话进行到哪一步?谁在等待谁?
+3. **消息路由** - 如何确保消息发送到正确的Agent?
+4. **会话生命周期** - 何时开始?何时结束?
+5. **异步通信** - Agent可能不在线,如何处理?
+
+## 方案对比
+
+### 方案1:基于Trace的持续对话(推荐)
+
+#### 核心思想
+
+**利用现有的Trace机制作为对话容器**
+
+- 每个A2A对话创建一个共享的Trace
+- 双方Agent都可以向这个Trace追加消息
+- 通过WebSocket实时同步消息
+- 利用现有的续跑机制(`continue_from`)
+
+#### 架构设计
+
+```
+云端Agent                共享Trace                终端Agent
+    |                       |                         |
+    | 创建对话              |                         |
+    |--------------------->|                         |
+    |                      |                         |
+    | 发送消息1            |                         |
+    |--------------------->|                         |
+    |                      |----WebSocket推送------->|
+    |                      |                         |
+    |                      |<----追加消息2-----------|
+    |<--WebSocket推送------|                         |
+    |                      |                         |
+    | 发送消息3            |                         |
+    |--------------------->|                         |
+    |                      |----WebSocket推送------->|
+    |                      |                         |
+    ...持续对话...
+```
+
+#### API设计
+
+```python
+# 1. 创建对话会话
+POST /api/a2a/sessions
+{
+    "participants": ["cloud-agent-123", "terminal-agent-456"],
+    "initial_message": "请分析本地项目",
+    "context": {...}
+}
+
+响应:
+{
+    "session_id": "sess-xxx",
+    "trace_id": "trace-yyy",  # 底层使用Trace
+    "ws_url": "wss://host/api/a2a/sessions/sess-xxx/stream"
+}
+
+# 2. 发送消息(追加到Trace)
+POST /api/a2a/sessions/{session_id}/messages
+{
+    "from": "cloud-agent-123",
+    "content": "重点分析core模块",
+    "wait_for_response": true  # 是否等待对方回复
+}
+
+响应:
+{
+    "message_id": "msg-xxx",
+    "status": "sent"
+}
+
+# 3. WebSocket监听(实时接收消息)
+WS /api/a2a/sessions/{session_id}/stream
+{
+    "type": "message",
+    "from": "terminal-agent-456",
+    "content": "我看到有3个模块,你想重点分析哪个?",
+    "message_id": "msg-yyy"
+}
+
+# 4. 获取对话历史
+GET /api/a2a/sessions/{session_id}/messages
+响应:
+{
+    "messages": [
+        {"from": "cloud-agent-123", "content": "...", "timestamp": "..."},
+        {"from": "terminal-agent-456", "content": "...", "timestamp": "..."},
+        ...
+    ]
+}
+
+# 5. 结束对话
+POST /api/a2a/sessions/{session_id}/close
+{
+    "reason": "completed"
+}
+```
+
+#### 实现细节
+
+```python
+# agent/api/a2a_session.py
+
+class A2ASession:
+    """A2A对话会话,基于Trace实现"""
+
+    def __init__(self, session_id: str, trace_id: str, participants: List[str]):
+        self.session_id = session_id
+        self.trace_id = trace_id
+        self.participants = participants
+        self.ws_connections = {}  # agent_id -> WebSocket
+
+    async def send_message(
+        self,
+        from_agent: str,
+        content: str,
+        wait_for_response: bool = False
+    ) -> Dict[str, Any]:
+        """发送消息到对话"""
+        # 1. 追加消息到Trace
+        messages = [{"role": "user", "content": content}]
+        config = RunConfig(
+            trace_id=self.trace_id,
+            after_sequence=None,  # 续跑模式
+            uid=from_agent
+        )
+
+        # 2. 执行(可能触发对方Agent的响应)
+        async for event in runner.run(messages, config):
+            if isinstance(event, Message):
+                # 3. 通过WebSocket推送给其他参与者
+                await self._broadcast_message(event, exclude=from_agent)
+
+                if wait_for_response and event.role == "assistant":
+                    # 等待对方回复
+                    return {"message_id": event.message_id, "status": "sent"}
+
+        return {"status": "completed"}
+
+    async def _broadcast_message(self, message: Message, exclude: str = None):
+        """广播消息给所有参与者(除了发送者)"""
+        for agent_id, ws in self.ws_connections.items():
+            if agent_id != exclude:
+                await ws.send_json({
+                    "type": "message",
+                    "from": exclude,
+                    "content": message.content,
+                    "message_id": message.message_id,
+                    "timestamp": message.created_at.isoformat()
+                })
+
+
+@app.post("/api/a2a/sessions")
+async def create_session(request: CreateSessionRequest):
+    """创建A2A对话会话"""
+    # 1. 创建底层Trace
+    trace = Trace(
+        trace_id=generate_trace_id(),
+        mode="agent",
+        task=request.initial_message,
+        agent_type="a2a_session",
+        context={
+            "session_type": "a2a",
+            "participants": request.participants
+        }
+    )
+    await store.create_trace(trace)
+
+    # 2. 创建Session对象
+    session_id = f"sess-{generate_id()}"
+    session = A2ASession(session_id, trace.trace_id, request.participants)
+
+    # 3. 存储Session(内存或Redis)
+    sessions[session_id] = session
+
+    # 4. 发送初始消息
+    if request.initial_message:
+        await session.send_message(
+            from_agent=request.participants[0],
+            content=request.initial_message
+        )
+
+    return {
+        "session_id": session_id,
+        "trace_id": trace.trace_id,
+        "ws_url": f"wss://{host}/api/a2a/sessions/{session_id}/stream"
+    }
+
+
+@app.websocket("/api/a2a/sessions/{session_id}/stream")
+async def session_stream(websocket: WebSocket, session_id: str):
+    """WebSocket连接,实时接收对话消息"""
+    await websocket.accept()
+
+    # 1. 获取Session
+    session = sessions.get(session_id)
+    if not session:
+        await websocket.close(code=404)
+        return
+
+    # 2. 识别连接的Agent
+    agent_id = await authenticate_websocket(websocket)
+
+    # 3. 注册WebSocket连接
+    session.ws_connections[agent_id] = websocket
+
+    try:
+        # 4. 保持连接,接收消息
+        async for message in websocket:
+            data = json.loads(message)
+            if data["type"] == "message":
+                # 发送消息到对话
+                await session.send_message(
+                    from_agent=agent_id,
+                    content=data["content"]
+                )
+    finally:
+        # 5. 清理连接
+        del session.ws_connections[agent_id]
+
+
+@app.post("/api/a2a/sessions/{session_id}/messages")
+async def send_session_message(session_id: str, request: SendMessageRequest):
+    """发送消息到对话(HTTP方式)"""
+    session = sessions.get(session_id)
+    if not session:
+        raise HTTPException(404, "Session not found")
+
+    result = await session.send_message(
+        from_agent=request.from_agent,
+        content=request.content,
+        wait_for_response=request.wait_for_response
+    )
+
+    return result
+```
+
+#### 客户端SDK
+
+```python
+# agent/client/a2a_session_client.py
+
+class A2ASessionClient:
+    """A2A持续对话客户端"""
+
+    def __init__(self, base_url: str, agent_id: str, api_key: str):
+        self.base_url = base_url
+        self.agent_id = agent_id
+        self.api_key = api_key
+        self.ws = None
+        self.message_handlers = []
+
+    async def create_session(
+        self,
+        other_agent: str,
+        initial_message: str
+    ) -> str:
+        """创建对话会话"""
+        response = await self._post("/api/a2a/sessions", {
+            "participants": [self.agent_id, other_agent],
+            "initial_message": initial_message
+        })
+
+        session_id = response["session_id"]
+
+        # 自动连接WebSocket
+        await self._connect_websocket(session_id)
+
+        return session_id
+
+    async def _connect_websocket(self, session_id: str):
+        """连接WebSocket接收消息"""
+        ws_url = f"{self.ws_url}/api/a2a/sessions/{session_id}/stream"
+        self.ws = await websockets.connect(
+            ws_url,
+            extra_headers={"Authorization": f"Bearer {self.api_key}"}
+        )
+
+        # 启动消息接收循环
+        asyncio.create_task(self._receive_messages())
+
+    async def _receive_messages(self):
+        """接收WebSocket消息"""
+        async for message in self.ws:
+            data = json.loads(message)
+            if data["type"] == "message":
+                # 调用注册的消息处理器
+                for handler in self.message_handlers:
+                    await handler(data)
+
+    async def send_message(self, session_id: str, content: str):
+        """发送消息"""
+        if self.ws:
+            # 通过WebSocket发送(实时)
+            await self.ws.send(json.dumps({
+                "type": "message",
+                "content": content
+            }))
+        else:
+            # 通过HTTP发送(备用)
+            await self._post(f"/api/a2a/sessions/{session_id}/messages", {
+                "from_agent": self.agent_id,
+                "content": content
+            })
+
+    def on_message(self, handler):
+        """注册消息处理器"""
+        self.message_handlers.append(handler)
+
+    async def close_session(self, session_id: str):
+        """关闭对话"""
+        await self._post(f"/api/a2a/sessions/{session_id}/close", {})
+        if self.ws:
+            await self.ws.close()
+```
+
+#### 使用示例
+
+```python
+# 云端Agent使用
+client = A2ASessionClient(
+    base_url="https://org.agent.cloud",
+    agent_id="cloud-agent-123",
+    api_key="ak_xxx"
+)
+
+# 创建对话
+session_id = await client.create_session(
+    other_agent="terminal-agent-456",
+    initial_message="请分析本地项目"
+)
+
+# 注册消息处理器
+@client.on_message
+async def handle_message(message):
+    print(f"收到消息: {message['content']}")
+
+    # 根据消息内容决定如何回复
+    if "哪个模块" in message['content']:
+        await client.send_message(session_id, "重点分析core模块")
+    elif "需要我详细说明吗" in message['content']:
+        await client.send_message(session_id, "是的,请详细说明")
+
+# 等待对话完成
+await asyncio.sleep(60)  # 或其他结束条件
+
+# 关闭对话
+await client.close_session(session_id)
+```
+
+#### 优势
+
+1. **复用Trace机制** - 所有消息管理、压缩、存储都复用
+2. **完整历史** - 对话历史自动保存在Trace中
+3. **实时通信** - WebSocket保证低延迟
+4. **状态追踪** - 利用Trace的状态管理
+5. **可回溯** - 可以查看完整的对话历史
+
+#### 劣势
+
+1. **Trace概念泄露** - 外部需要理解session_id和trace_id的关系
+2. **复杂度** - 需要管理WebSocket连接
+
+### 方案2:独立的对话管理器
+
+#### 核心思想
+
+**创建独立的对话管理系统,不依赖Trace**
+
+```python
+class Conversation:
+    """独立的对话对象"""
+    conversation_id: str
+    participants: List[str]
+    messages: List[ConversationMessage]
+    status: str  # active, waiting, completed
+    created_at: datetime
+
+class ConversationMessage:
+    """对话消息"""
+    message_id: str
+    from_agent: str
+    to_agent: Optional[str]  # None表示广播
+    content: str
+    timestamp: datetime
+    metadata: Dict
+```
+
+#### 优势
+
+1. **概念清晰** - 对话就是对话,不混淆Trace
+2. **轻量级** - 不需要Trace的重量级机制
+3. **灵活** - 可以自定义对话逻辑
+
+#### 劣势
+
+1. **重复实现** - 需要重新实现消息管理、存储、压缩
+2. **不一致** - 与现有Trace机制不一致
+3. **维护成本** - 需要维护两套系统
+
+### 方案3:混合模式(推荐)
+
+#### 核心思想
+
+**对话层(Session)+ 执行层(Trace)分离**
+
+```
+对话层(Session)
+    - 管理对话状态
+    - 路由消息
+    - WebSocket连接
+    |
+    | 每条消息触发
+    ↓
+执行层(Trace)
+    - 执行具体任务
+    - 调用工具
+    - 管理上下文
+```
+
+#### 架构
+
+```python
+class A2ASession:
+    """对话会话(轻量级)"""
+    session_id: str
+    participants: List[str]
+    current_speaker: str
+    waiting_for: Optional[str]
+    context: Dict  # 共享上下文
+    message_queue: List[Message]
+
+    async def send_message(self, from_agent: str, content: str):
+        """发送消息"""
+        # 1. 添加到消息队列
+        self.message_queue.append(Message(from_agent, content))
+
+        # 2. 如果需要执行(不是简单问答),创建Trace
+        if self._needs_execution(content):
+            trace_id = await self._create_execution_trace(content)
+            # 执行完成后,结果自动添加到消息队列
+        else:
+            # 简单消息,直接转发
+            await self._forward_message(content, to=self._get_other_agent(from_agent))
+
+    def _needs_execution(self, content: str) -> bool:
+        """判断是否需要创建Trace执行"""
+        # 例如:包含工具调用、复杂任务等
+        return "分析" in content or "执行" in content or "查询" in content
+```
+
+#### 优势
+
+1. **分层清晰** - 对话管理和任务执行分离
+2. **灵活** - 简单消息不需要Trace,复杂任务才创建
+3. **高效** - 避免为每条消息创建Trace
+4. **复用** - 复杂任务仍然复用Trace机制
+
+#### 实现示例
+
+```python
+@app.post("/api/a2a/sessions/{session_id}/messages")
+async def send_message(session_id: str, request: SendMessageRequest):
+    session = sessions[session_id]
+
+    # 1. 判断消息类型
+    if request.requires_execution:
+        # 需要执行的任务 → 创建Trace
+        trace_id = await create_execution_trace(
+            task=request.content,
+            parent_session=session_id,
+            agent_id=request.from_agent
+        )
+
+        # 执行完成后,结果自动推送到Session
+        result = await runner.run_result(
+            messages=[{"role": "user", "content": request.content}],
+            config=RunConfig(trace_id=trace_id)
+        )
+
+        # 将结果作为消息发送给对方
+        await session.send_message(
+            from_agent=request.from_agent,
+            content=result["summary"]
+        )
+    else:
+        # 简单消息 → 直接转发
+        await session.send_message(
+            from_agent=request.from_agent,
+            content=request.content
+        )
+
+    return {"status": "sent"}
+```
+
+## 关键设计决策
+
+### 1. 消息类型分类
+
+| 类型 | 示例 | 处理方式 |
+|------|------|----------|
+| **简单问答** | "你好"、"收到"、"明白了" | 直接转发,不创建Trace |
+| **信息查询** | "当前进度如何?" | 查询Session状态,返回 |
+| **任务请求** | "分析core模块" | 创建Trace执行 |
+| **工具调用** | "读取文件X" | 创建Trace执行 |
+
+### 2. 上下文管理
+
+**Session级上下文(轻量):**
+```python
+session.context = {
+    "current_topic": "项目分析",
+    "focus_module": "core",
+    "previous_results": {...}
+}
+```
+
+**Trace级上下文(完整):**
+- 完整的消息历史
+- 工具调用记录
+- Goal树
+
+### 3. 生命周期管理
+
+```python
+# Session生命周期
+created → active → waiting → active → ... → completed/timeout
+
+# Trace生命周期(每个任务)
+created → running → completed
+```
+
+### 4. 超时和重连
+
+```python
+class A2ASession:
+    timeout: int = 300  # 5分钟无活动则超时
+    last_activity: datetime
+
+    async def check_timeout(self):
+        if datetime.now() - self.last_activity > timedelta(seconds=self.timeout):
+            await self.close(reason="timeout")
+
+    async def reconnect(self, agent_id: str, ws: WebSocket):
+        """Agent重连"""
+        self.ws_connections[agent_id] = ws
+        # 发送未读消息
+        await self._send_unread_messages(agent_id)
+```
+
+## 实现路线图
+
+### Phase 1:基础对话能力(2-3周)
+
+1. **Session管理**
+   - 创建/关闭Session
+   - 消息路由
+   - WebSocket连接管理
+
+2. **简单消息转发**
+   - 不涉及Trace
+   - 纯消息传递
+
+3. **客户端SDK**
+   - `A2ASessionClient`
+   - 消息处理器
+
+### Phase 2:集成Trace执行(2-3周)
+
+1. **任务识别**
+   - 判断消息是否需要执行
+   - 自动创建Trace
+
+2. **结果集成**
+   - Trace结果转换为消息
+   - 自动推送给对方
+
+3. **上下文共享**
+   - Session上下文传递给Trace
+   - Trace结果更新Session上下文
+
+### Phase 3:高级功能(3-4周)
+
+1. **多方对话**
+   - 支持3个以上Agent
+   - 群聊模式
+
+2. **对话分支**
+   - 子对话
+   - 并行对话
+
+3. **持久化和恢复**
+   - Session持久化
+   - 断线重连
+
+## 示例场景
+
+### 场景:云端助理与终端Agent的持续对话
+
+```python
+# 云端助理
+client = A2ASessionClient("https://cloud", "cloud-agent", "ak_xxx")
+
+# 1. 创建对话
+session_id = await client.create_session(
+    other_agent="terminal-agent-456",
+    initial_message="请分析本地项目"
+)
+
+# 2. 注册消息处理器(自动响应)
+@client.on_message
+async def handle_message(msg):
+    content = msg['content']
+
+    if "哪个模块" in content:
+        # 简单回复,不需要执行
+        await client.send_message(session_id, "重点分析core模块")
+
+    elif "详细说明" in content:
+        # 需要进一步分析,触发执行
+        await client.send_message(
+            session_id,
+            "是的,请详细说明架构设计和关键组件",
+            requires_execution=True  # 标记需要执行
+        )
+
+# 3. 等待对话完成
+await client.wait_for_completion(session_id)
+```
+
+```python
+# 终端Agent
+client = A2ASessionClient("https://terminal", "terminal-agent-456", "ak_yyy")
+
+# 1. 监听新对话
+@client.on_new_session
+async def handle_new_session(session_id, initial_message):
+    # 分析项目
+    modules = await analyze_project()
+
+    # 询问用户
+    await client.send_message(
+        session_id,
+        f"我看到有{len(modules)}个模块:{', '.join(modules)},你想重点分析哪个?"
+    )
+
+# 2. 处理后续消息
+@client.on_message
+async def handle_message(msg):
+    if "core模块" in msg['content']:
+        # 执行分析
+        result = await analyze_module("core")
+
+        # 返回结果并询问
+        await client.send_message(
+            msg['session_id'],
+            f"core模块使用了{result['architecture']}架构,需要我详细说明吗?"
+        )
+
+    elif "详细说明" in msg['content']:
+        # 深度分析
+        details = await deep_analyze("core")
+        await client.send_message(
+            msg['session_id'],
+            f"详细架构:\n{details}"
+        )
+```
+
+## 总结
+
+### 推荐方案:混合模式
+
+**对话层(Session):**
+- 轻量级消息路由
+- WebSocket连接管理
+- 简单问答直接转发
+
+**执行层(Trace):**
+- 复杂任务创建Trace
+- 复用所有现有能力
+- 结果自动集成到对话
+
+### 关键优势
+
+1. **灵活** - 简单消息不需要Trace开销
+2. **强大** - 复杂任务复用Trace能力
+3. **清晰** - 对话和执行分层
+4. **高效** - 避免不必要的资源消耗
+
+### 实现优先级
+
+1. **Phase 1** - 基础Session + 简单消息(MVP)
+2. **Phase 2** - 集成Trace执行(核心能力)
+3. **Phase 3** - 高级功能(按需)
+

+ 640 - 0
docs/research/a2a-cross-device.md

@@ -0,0 +1,640 @@
+# Agent2Agent 跨设备通信方案
+
+**更新日期:** 2026-03-03
+
+## 场景分类
+
+### 场景1:云端Agent ↔ 云端Agent
+**示例:** 通用助理 → 爬虫运维Agent
+- **部署:** 同一服务器/进程
+- **通信:** 现有`agent`工具(内存调用)
+- **不需要HTTP接口**
+
+### 场景2:云端Agent ↔ 终端Agent ⭐
+**示例:**
+- 云端通用助理 → 用户笔记本上的代码分析Agent
+- 用户终端Agent → 云端知识库Agent
+
+**需求:**
+- 云端Agent需要调用终端Agent的能力
+  - 访问用户本地文件
+  - 执行本地命令
+  - 使用本地工具(IDE、浏览器等)
+- 终端Agent需要调用云端Agent
+  - 访问组织知识库
+  - 查询共享资源
+  - 协作任务
+
+**挑战:**
+- 网络连接(终端可能在NAT后)
+- 认证和授权
+- 数据安全
+
+**需要HTTP接口!**
+
+### 场景3:终端Agent ↔ 终端Agent
+**示例:** 团队成员的Agent互相协作
+- **可能性:** 较小,但可能存在
+- **通信:** 通过云端中转或P2P
+
+## 架构方案
+
+### 方案A:基于现有API封装(推荐)
+
+#### 架构图
+```
+云端Agent                    终端Agent
+    |                            |
+    | HTTP POST /api/a2a/call    |
+    |--------------------------->|
+    |                            |
+    |    创建Trace + 执行任务     |
+    |                            |
+    | WebSocket /api/a2a/watch   |
+    |<---------------------------|
+    |    实时进度推送              |
+    |                            |
+    | HTTP GET /api/a2a/result   |
+    |--------------------------->|
+    |    返回最终结果              |
+    |<---------------------------|
+```
+
+#### 核心设计
+
+**1. 简化的A2A端点**
+
+```python
+# agent/api/a2a.py
+
+@app.post("/api/a2a/call")
+async def a2a_call(request: A2ACallRequest):
+    """
+    简化的Agent调用接口
+
+    请求:
+    {
+        "task": "分析这个项目的架构",
+        "agent_type": "explore",  # 可选
+        "context": {              # 可选
+            "files": [...],
+            "previous_results": {...}
+        },
+        "callback_url": "https://..."  # 可选,完成后回调
+    }
+
+    响应:
+    {
+        "call_id": "a2a-xxx",
+        "status": "started",
+        "watch_url": "ws://host/api/a2a/watch/a2a-xxx"
+    }
+    """
+    # 1. 认证和授权检查
+    # 2. 转换为内部格式
+    messages = [{"role": "user", "content": request.task}]
+    if request.context:
+        messages[0]["content"] += f"\n\n上下文:{json.dumps(request.context)}"
+
+    # 3. 调用现有runner(复用所有逻辑)
+    config = RunConfig(
+        agent_type=request.agent_type or "default",
+        trace_id=None  # 新建
+    )
+
+    # 4. 后台执行
+    task_id = await start_background_task(runner.run(messages, config))
+
+    # 5. 返回call_id(映射到trace_id)
+    return {
+        "call_id": f"a2a-{task_id}",
+        "status": "started",
+        "watch_url": f"ws://{host}/api/a2a/watch/{task_id}"
+    }
+
+
+@app.websocket("/api/a2a/watch/{call_id}")
+async def a2a_watch(websocket: WebSocket, call_id: str):
+    """
+    实时监听执行进度(复用现有WebSocket)
+
+    推送消息:
+    {
+        "type": "progress",
+        "data": {
+            "goal": "正在分析文件结构",
+            "progress": 0.3
+        }
+    }
+
+    {
+        "type": "completed",
+        "data": {
+            "result": "...",
+            "stats": {...}
+        }
+    }
+    """
+    # 复用现有的 /api/traces/{id}/watch 逻辑
+    trace_id = call_id.replace("a2a-", "")
+    await watch_trace(websocket, trace_id)
+
+
+@app.get("/api/a2a/result/{call_id}")
+async def a2a_result(call_id: str):
+    """
+    获取执行结果
+
+    响应:
+    {
+        "status": "completed",
+        "result": {
+            "summary": "...",
+            "details": {...}
+        },
+        "stats": {
+            "duration_ms": 5000,
+            "tokens": 1500,
+            "cost": 0.05
+        }
+    }
+    """
+    trace_id = call_id.replace("a2a-", "")
+    trace = await store.get_trace(trace_id)
+    messages = await store.get_main_path_messages(trace_id, trace.head_sequence)
+
+    # 提取最后的assistant消息作为结果
+    result = extract_final_result(messages)
+
+    return {
+        "status": trace.status,
+        "result": result,
+        "stats": {
+            "duration_ms": trace.total_duration_ms,
+            "tokens": trace.total_tokens,
+            "cost": trace.total_cost
+        }
+    }
+```
+
+**2. 客户端SDK(终端Agent使用)**
+
+```python
+# agent/client/a2a_client.py
+
+class A2AClient:
+    """A2A客户端,用于调用远程Agent"""
+
+    def __init__(self, base_url: str, api_key: str):
+        self.base_url = base_url
+        self.api_key = api_key
+
+    async def call(
+        self,
+        task: str,
+        agent_type: Optional[str] = None,
+        context: Optional[dict] = None,
+        wait: bool = True
+    ) -> Dict[str, Any]:
+        """
+        调用远程Agent
+
+        Args:
+            task: 任务描述
+            agent_type: Agent类型
+            context: 上下文信息
+            wait: 是否等待完成(False则立即返回call_id)
+        """
+        # 1. 发起调用
+        response = await self._post("/api/a2a/call", {
+            "task": task,
+            "agent_type": agent_type,
+            "context": context
+        })
+
+        call_id = response["call_id"]
+
+        if not wait:
+            return {"call_id": call_id, "status": "started"}
+
+        # 2. 等待完成(通过WebSocket或轮询)
+        result = await self._wait_for_completion(call_id)
+        return result
+
+    async def _wait_for_completion(self, call_id: str):
+        """通过WebSocket监听完成"""
+        async with websockets.connect(
+            f"{self.ws_url}/api/a2a/watch/{call_id}",
+            extra_headers={"Authorization": f"Bearer {self.api_key}"}
+        ) as ws:
+            async for message in ws:
+                data = json.loads(message)
+                if data["type"] == "completed":
+                    return data["data"]
+                elif data["type"] == "failed":
+                    raise A2AError(data["data"]["error"])
+
+    async def get_result(self, call_id: str) -> Dict[str, Any]:
+        """获取执行结果(轮询方式)"""
+        return await self._get(f"/api/a2a/result/{call_id}")
+```
+
+**3. 作为工具集成到Agent**
+
+```python
+# agent/tools/builtin/remote_agent.py
+
+@tool(description="调用远程Agent执行任务")
+async def remote_agent(
+    task: str,
+    agent_url: str,
+    agent_type: Optional[str] = None,
+    context: Optional[dict] = None,
+    ctx: ToolContext = None
+) -> ToolResult:
+    """
+    调用远程Agent(云端或其他终端)
+
+    Args:
+        task: 任务描述
+        agent_url: 远程Agent的URL
+        agent_type: Agent类型
+        context: 上下文信息
+    """
+    # 1. 创建客户端
+    client = A2AClient(
+        base_url=agent_url,
+        api_key=ctx.config.get("remote_agent_api_key")
+    )
+
+    # 2. 调用远程Agent
+    result = await client.call(
+        task=task,
+        agent_type=agent_type,
+        context=context,
+        wait=True
+    )
+
+    # 3. 返回结果
+    return ToolResult(
+        title=f"远程Agent完成: {task[:50]}",
+        output=result["result"]["summary"],
+        long_term_memory=f"调用远程Agent完成任务,耗时{result['stats']['duration_ms']}ms"
+    )
+```
+
+#### 优势
+
+1. **复用现有逻辑** - 所有Trace、Message、Goal管理都复用
+2. **简单易用** - 外部只需要提供task,不需要理解Trace概念
+3. **完整功能** - 继承所有现有能力(压缩、续跑、回溯等)
+4. **渐进式** - 可以先实现基础版本,逐步增强
+
+### 方案B:实现标准A2A协议
+
+#### 架构
+
+```python
+# agent/api/a2a_standard.py
+
+@app.post("/api/a2a/v1/tasks")
+async def create_task(request: A2ATaskRequest):
+    """
+    符合Google A2A协议的端点
+
+    请求格式(A2A标准):
+    {
+        "header": {
+            "message_id": "msg_001",
+            "timestamp": "2026-03-03T10:30:00Z"
+        },
+        "task": {
+            "description": "分析项目架构",
+            "capabilities_required": ["file_read", "code_analysis"]
+        },
+        "context": {...}
+    }
+    """
+    # 转换A2A格式到内部格式
+    # 调用runner
+    # 转换结果为A2A格式
+```
+
+#### 优势
+
+1. **标准化** - 符合行业标准
+2. **互操作性** - 可以与其他A2A兼容的Agent通信
+3. **未来兼容** - 跟随行业发展
+
+#### 劣势
+
+1. **复杂度高** - 需要实现完整的A2A协议
+2. **过度设计** - MVP阶段可能不需要
+3. **标准未稳定** - A2A协议还在演进中
+
+## 网络拓扑
+
+### 拓扑1:云端中心化
+
+```
+        云端Gateway
+            |
+    +-------+-------+
+    |       |       |
+  通用    爬虫    成本
+  助理    运维    统计
+    |
+    +-- 调用终端Agent(HTTP)
+            |
+        用户终端Agent
+```
+
+**特点:**
+- 云端Agent作为中心
+- 终端Agent需要暴露HTTP端点
+- 需要处理NAT穿透
+
+### 拓扑2:终端主动连接
+
+```
+用户终端Agent
+    |
+    | WebSocket长连接
+    |
+云端Gateway
+    |
+    +-- 通过连接推送任务
+```
+
+**特点:**
+- 终端Agent主动连接云端
+- 云端通过WebSocket推送任务
+- 无需NAT穿透
+- 类似飞书Bot的模式
+
+### 拓扑3:混合模式(推荐)
+
+```
+云端Agent <--HTTP--> 云端Agent(内存调用)
+    |
+    | WebSocket双向
+    |
+终端Agent <--HTTP--> 终端Agent(如果需要)
+```
+
+**特点:**
+- 云端Agent间用内存调用
+- 云端↔终端用WebSocket
+- 终端间可选HTTP(通过云端中转)
+
+## 认证和授权
+
+### 1. API Key认证
+
+```python
+# 终端Agent启动时注册
+POST /api/a2a/register
+{
+    "agent_id": "user123-laptop",
+    "capabilities": ["file_read", "bash", "browser"],
+    "device_info": {...}
+}
+
+# 返回API Key
+{
+    "api_key": "ak_xxx",
+    "agent_id": "user123-laptop"
+}
+
+# 后续调用携带API Key
+Authorization: Bearer ak_xxx
+```
+
+### 2. 权限控制
+
+```yaml
+# config/a2a_permissions.yaml
+agents:
+  user123-laptop:
+    can_access:
+      - conversations/user123/*
+      - resources/public/*
+    cannot_access:
+      - conversations/other_users/*
+      - agents/*/memory/*
+```
+
+### 3. 数据隔离
+
+- 终端Agent只能访问自己用户的数据
+- 云端Agent可以访问组织共享数据
+- 通过Gateway强制执行
+
+## 实现路线图
+
+### Phase 1:基础A2A接口(MVP)
+
+**目标:** 云端Agent ↔ 终端Agent基础通信
+
+**实现:**
+1. `/api/a2a/call` - 简化调用接口
+2. `/api/a2a/watch` - WebSocket监听
+3. `/api/a2a/result` - 获取结果
+4. `A2AClient` - 客户端SDK
+5. `remote_agent` - 工具集成
+
+**时间:** 1-2周
+
+### Phase 2:增强功能
+
+**目标:** 完善A2A能力
+
+**实现:**
+1. 认证和授权
+2. 数据隔离
+3. 成本控制
+4. 审计日志
+5. 错误处理和重试
+
+**时间:** 2-3周
+
+### Phase 3:标准化(可选)
+
+**目标:** 兼容A2A标准协议
+
+**实现:**
+1. 实现Google A2A协议
+2. 能力协商机制
+3. 与其他A2A Agent互操作
+
+**时间:** 3-4周
+
+## 示例场景
+
+### 场景:云端助理调用终端Agent分析代码
+
+**1. 用户在飞书问:** "帮我分析一下我笔记本上的项目架构"
+
+**2. 云端通用助理:**
+```python
+# 识别需要访问用户终端
+result = await remote_agent(
+    task="分析 /Users/sunlit/Code/Agent 的项目架构",
+    agent_url="https://user123-laptop.agent.local",
+    agent_type="explore"
+)
+```
+
+**3. 终端Agent:**
+- 接收任务
+- 创建本地Trace
+- 使用本地工具(read, glob, grep)
+- 分析代码结构
+- 返回结果
+
+**4. 云端助理:**
+- 接收终端Agent结果
+- 整合到回复中
+- 通过飞书返回给用户
+
+### 场景:终端Agent查询云端知识库
+
+**1. 用户在终端运行:**
+```bash
+agent-cli ask "公司的爬虫部署规范是什么?"
+```
+
+**2. 终端Agent:**
+```python
+# 识别需要查询组织知识库
+result = await remote_agent(
+    task="查询爬虫部署规范",
+    agent_url="https://org.agent.cloud",
+    agent_type="knowledge_query"
+)
+```
+
+**3. 云端知识库Agent:**
+- 查询resources/docs/
+- 查询experiences数据库
+- 返回相关文档
+
+**4. 终端Agent:**
+- 接收结果
+- 展示给用户
+
+## 技术细节
+
+### 1. NAT穿透方案
+
+**方案A:终端主动连接(推荐)**
+```python
+# 终端Agent启动时建立WebSocket长连接
+ws = await websockets.connect("wss://org.agent.cloud/api/a2a/connect")
+
+# 云端通过连接推送任务
+await ws.send(json.dumps({
+    "type": "task",
+    "task_id": "xxx",
+    "data": {...}
+}))
+
+# 终端执行并返回结果
+result = await execute_task(task)
+await ws.send(json.dumps({
+    "type": "result",
+    "task_id": "xxx",
+    "data": result
+}))
+```
+
+**方案B:使用ngrok等隧道服务**
+- 终端Agent启动时创建隧道
+- 注册公网URL到云端
+- 云端通过公网URL调用
+
+### 2. 消息序列化
+
+```python
+# 简化格式(内部使用)
+{
+    "task": "string",
+    "context": {...}
+}
+
+# 标准A2A格式(外部互操作)
+{
+    "header": {...},
+    "task": {...},
+    "capabilities": [...]
+}
+
+# 自动转换
+def to_a2a_format(internal_msg):
+    return {
+        "header": generate_header(),
+        "task": {"description": internal_msg["task"]},
+        "context": internal_msg.get("context", {})
+    }
+```
+
+### 3. 流式响应
+
+```python
+# 支持流式返回中间结果
+@app.websocket("/api/a2a/stream/{call_id}")
+async def a2a_stream(websocket: WebSocket, call_id: str):
+    async for event in runner.run(...):
+        if isinstance(event, Message):
+            await websocket.send_json({
+                "type": "message",
+                "data": event.to_dict()
+            })
+```
+
+## 安全考虑
+
+1. **认证:** API Key + JWT
+2. **授权:** 基于角色的访问控制
+3. **加密:** HTTPS/WSS强制
+4. **限流:** 防止滥用
+5. **审计:** 所有A2A调用记录
+6. **隔离:** 数据访问严格隔离
+
+## 成本控制
+
+```python
+# 每次A2A调用记录成本
+{
+    "call_id": "a2a-xxx",
+    "caller": "user123-laptop",
+    "callee": "org-cloud-agent",
+    "tokens": 1500,
+    "cost": 0.05,
+    "duration_ms": 5000
+}
+
+# 限额检查
+if user_cost_today > user_limit:
+    raise CostLimitExceeded()
+```
+
+## 总结
+
+### 推荐方案
+
+**Phase 1(MVP):** 方案A - 基于现有API封装
+- 简单、快速
+- 复用所有现有逻辑
+- 满足跨设备通信需求
+
+**Phase 3+:** 可选实现标准A2A协议
+- 如果需要与外部系统互操作
+- 跟随行业标准发展
+
+### 关键优势
+
+1. **复用现有能力** - Trace、Message、Goal、压缩等
+2. **渐进式实现** - 先简单后复杂
+3. **灵活扩展** - 可以逐步增强功能
+4. **标准兼容** - 未来可以支持A2A标准

+ 504 - 0
docs/research/a2a-mamp-protocol.md

@@ -0,0 +1,504 @@
+# MAMP:Minimal Agent Message Protocol
+
+**更新日期:** 2026-03-04
+
+## 设计目标
+
+实现与**其他 Agent 系统**(非本系统)的通用交互接口,保持最简化原则。
+
+**与现有方案的关系**:
+- [A2A 跨设备通信](./a2a-cross-device.md):内部 Agent 间通信(基于 Trace API)
+- **MAMP 协议**(本文档):与外部 Agent 系统的通用交互
+
+---
+
+## 核心设计原则
+
+1. **最小化协议**:只定义消息信封,不管内容格式
+2. **适配器模式**:通过适配器层与内部系统集成
+3. **松耦合**:各家 Agent 保持独立实现
+4. **渐进式**:先实现基础功能,需要时再扩展
+
+---
+
+## 消息格式
+
+### 基础消息结构
+
+```json
+{
+  "protocol": "mamp/1.0",
+  "message_id": "msg-uuid-123",
+  "conversation_id": "conv-uuid-456",
+  "from": "agent://your-domain.com/agent-123",
+  "to": "agent://other-domain.com/agent-456",
+  "content": [...],
+  "metadata": {
+    "timestamp": "2026-03-04T10:00:00Z"
+  }
+}
+```
+
+### 字段说明
+
+| 字段 | 类型 | 必需 | 说明 |
+|------|------|------|------|
+| `protocol` | string | 是 | 协议版本标识 |
+| `message_id` | string | 是 | 消息唯一标识 |
+| `conversation_id` | string | 否 | 对话标识(不提供则新建对话) |
+| `from` | string | 是 | 发送方 Agent URI |
+| `to` | string | 是 | 接收方 Agent URI |
+| `content` | string/array | 是 | 消息内容(支持多模态) |
+| `metadata` | object | 是 | 元数据(时间戳等) |
+
+### 新建 vs 续跑
+
+**规则**:通过 `conversation_id` 字段判断
+
+- **无 `conversation_id`**(null 或不存在)→ 新建对话,接收方生成并返回 conversation_id
+- **有 `conversation_id`** → 续跑对话,接收方查找对应的内部 trace_id
+
+**conversation_id 与 trace_id 的关系**:
+- `conversation_id`:跨 Agent 的对话标识符,双方共享
+- `trace_id`:每个 Agent 内部的执行记录,各自独立
+- 每个 Agent 维护 `conversation_id → trace_id` 映射
+
+---
+
+## 多模态内容格式
+
+### Content 结构
+
+参考 Anthropic SDK 和现有多模态实现(`agent/docs/multimodal.md`):
+
+```json
+{
+  "content": [
+    {
+      "type": "text",
+      "text": "这是文本内容"
+    },
+    {
+      "type": "image",
+      "source": {
+        "type": "url",
+        "url": "https://...",
+        "media_type": "image/png"
+      }
+    },
+    {
+      "type": "image",
+      "source": {
+        "type": "base64",
+        "media_type": "image/jpeg",
+        "data": "base64..."
+      }
+    },
+    {
+      "type": "code",
+      "language": "python",
+      "code": "def hello(): pass"
+    },
+    {
+      "type": "file",
+      "name": "report.pdf",
+      "mime_type": "application/pdf",
+      "source": {
+        "type": "url",
+        "url": "https://..."
+      }
+    }
+  ]
+}
+```
+
+### 纯文本简写
+
+```json
+{
+  "content": "纯文本消息"
+}
+```
+
+等价于:
+
+```json
+{
+  "content": [{"type": "text", "text": "纯文本消息"}]
+}
+```
+
+---
+
+## Agent Card(身份与能力)
+
+每个 Agent 提供静态的 card 端点,用于身份识别和能力发现。
+
+### 端点
+
+```
+GET https://your-agent.com/mamp/v1/card
+```
+
+### 响应格式
+
+```json
+{
+  "protocol": "mamp/1.0",
+  "agent_id": "agent://your-domain.com/agent-123",
+  "name": "Code Analyst",
+  "description": "专注于代码分析的 Agent",
+
+  "owner": {
+    "user_id": "user-789",
+    "user_name": "张三",
+    "organization": "YourCompany"
+  },
+
+  "device": {
+    "device_id": "device-mac-001",
+    "device_name": "MacBook Pro",
+    "location": "Beijing Office",
+    "platform": "darwin"
+  },
+
+  "capabilities": {
+    "content_types": ["text", "image", "code"],
+    "max_message_size": 10485760,
+    "streaming": true,
+    "async": true,
+    "tools": ["code_analysis", "file_read", "web_search"]
+  },
+
+  "access": {
+    "public": false,
+    "allowed_agents": ["agent://trusted.com/*"],
+    "require_auth": true
+  }
+}
+```
+
+---
+
+## 传输层
+
+### HTTP REST(最简实现)
+
+**发送消息**:
+
+```http
+POST https://other-agent.com/mamp/v1/messages
+Content-Type: application/json
+Authorization: Bearer {api_key}
+
+{MAMP 消息体}
+```
+
+**响应**:
+
+```json
+{
+  "conversation_id": "conv-abc-123",
+  "message_id": "msg-xyz-456",
+  "status": "received"
+}
+```
+
+**错误响应**:
+
+```json
+{
+  "error": "conversation_not_found",
+  "message": "Conversation conv-xxx not found",
+  "status_code": 404
+}
+```
+
+### 可选扩展
+
+- **WebSocket**:实时双向通信
+- **Server-Sent Events**:流式响应
+- **Message Queue**:异步消息(NATS/Redis)
+
+---
+
+## 寻址方案
+
+使用 URI 格式:`agent://domain/agent-id`
+
+**示例**:
+- `agent://your-domain.com/trace-123` - 你的 Agent
+- `agent://claude.ai/session-456` - Claude
+- `agent://openai.com/assistant-789` - OpenAI Assistant
+
+每个 Agent 系统自己决定如何解析 `agent-id` 部分。
+
+---
+
+## 系统集成
+
+### 三层架构
+
+```
+┌─────────────────────────────────────────────────┐
+│ Layer 3: 内部 Agent 逻辑                         │
+│ (Trace, Goal, Messages...)                      │
+└─────────────────────────────────────────────────┘
+                    ↕
+┌─────────────────────────────────────────────────┐
+│ Layer 2: MAMP 适配器                             │
+│ - 内部格式 ↔ MAMP 格式转换                        │
+│ - conversation_id ↔ trace_id 映射                │
+└─────────────────────────────────────────────────┘
+                    ↕
+┌─────────────────────────────────────────────────┐
+│ Layer 1: 传输层(HTTP/WebSocket/MQ)             │
+└─────────────────────────────────────────────────┘
+```
+
+### 接收端实现
+
+**实现位置**:`agent/trace/mamp_api.py`
+
+```python
+@app.post("/mamp/v1/messages")
+async def receive_mamp_message(msg: dict):
+    """接收外部 Agent 的 MAMP 消息"""
+
+    conv_id = msg.get("conversation_id")
+
+    if not conv_id:
+        # 新建对话
+        conv_id = f"conv-{generate_uuid()}"
+
+        # 创建新 Trace
+        async for item in runner.run(
+            messages=[{"role": "user", "content": msg["content"]}],
+            config=RunConfig(
+                context={
+                    "mamp_conversation_id": conv_id,
+                    "mamp_from": msg["from"]
+                }
+            )
+        ):
+            if isinstance(item, Trace):
+                await store_conversation_mapping(conv_id, item.trace_id)
+
+        return {
+            "conversation_id": conv_id,
+            "message_id": msg["message_id"],
+            "status": "received"
+        }
+
+    else:
+        # 续跑对话
+        trace_id = await get_trace_by_conversation_id(conv_id)
+        if not trace_id:
+            raise HTTPException(404, f"Conversation {conv_id} not found")
+
+        await runner.run(
+            messages=[{"role": "user", "content": msg["content"]}],
+            config=RunConfig(trace_id=trace_id)
+        )
+
+        return {
+            "conversation_id": conv_id,
+            "message_id": msg["message_id"],
+            "status": "received"
+        }
+```
+
+### 发送端实现
+
+**实现位置**:`agent/tools/builtin/mamp_adapter.py`
+
+```python
+@tool(description="与外部 Agent 通信")
+async def send_to_agent(
+    target_agent: str,
+    message: str,
+    conversation_id: Optional[str] = None,
+    ctx: ToolContext = None
+) -> ToolResult:
+    """
+    发送消息到外部 Agent
+
+    Args:
+        target_agent: 目标 Agent URI (agent://domain/id)
+        message: 消息内容
+        conversation_id: 对话 ID(可选,不提供则新建)
+    """
+
+    # 构建 MAMP 消息
+    mamp_msg = {
+        "protocol": "mamp/1.0",
+        "message_id": generate_uuid(),
+        "from": f"agent://{config.domain}/{ctx.trace_id}",
+        "to": target_agent,
+        "content": message,
+        "metadata": {"timestamp": datetime.now().isoformat()}
+    }
+
+    if conversation_id:
+        mamp_msg["conversation_id"] = conversation_id
+
+    # 发送
+    agent_url = parse_agent_url(target_agent)
+    response = await http_post(f"{agent_url}/mamp/v1/messages", mamp_msg)
+
+    # 新建时存储映射
+    if not conversation_id:
+        await store_conversation_mapping(
+            response["conversation_id"],
+            ctx.trace_id
+        )
+
+    return ToolResult(
+        title=f"已发送到 {target_agent}",
+        output=f"Conversation ID: {response['conversation_id']}",
+        long_term_memory=f"与 {target_agent} 的对话 {response['conversation_id']}"
+    )
+```
+
+### conversation_id 映射存储
+
+**实现位置**:`agent/trace/conversation_store.py`
+
+```python
+class ConversationStore:
+    """管理 MAMP conversation_id 和内部 trace_id 的映射"""
+
+    def __init__(self, base_dir: str = ".trace"):
+        self.mapping_file = Path(base_dir) / "mamp_conversations.json"
+
+    async def store_mapping(self, conversation_id: str, trace_id: str):
+        """存储映射关系"""
+        mappings = await self._load_mappings()
+        mappings[conversation_id] = {
+            "trace_id": trace_id,
+            "created_at": datetime.now().isoformat(),
+            "last_message_at": datetime.now().isoformat()
+        }
+        await self._save_mappings(mappings)
+
+    async def get_trace_id(self, conversation_id: str) -> Optional[str]:
+        """根据 conversation_id 查找 trace_id"""
+        mappings = await self._load_mappings()
+        mapping = mappings.get(conversation_id)
+        return mapping["trace_id"] if mapping else None
+```
+
+---
+
+## 使用示例
+
+### 新建对话
+
+```python
+# 调用外部 Agent
+result = await send_to_agent(
+    target_agent="agent://other.com/code-analyst",
+    message="帮我分析这段代码的性能"
+)
+# 返回: {"conversation_id": "conv-abc-123", ...}
+```
+
+### 续跑对话
+
+```python
+# 继续之前的对话
+result = await send_to_agent(
+    target_agent="agent://other.com/code-analyst",
+    message="那如果用异步方案呢?",
+    conversation_id="conv-abc-123"
+)
+```
+
+### 多模态消息
+
+```python
+# 发送图片
+result = await send_to_agent(
+    target_agent="agent://other.com/image-analyst",
+    message={
+        "content": [
+            {"type": "text", "text": "分析这张图片"},
+            {
+                "type": "image",
+                "source": {
+                    "type": "base64",
+                    "media_type": "image/png",
+                    "data": encode_image_base64("screenshot.png")
+                }
+            }
+        ]
+    }
+)
+```
+
+---
+
+## 与现有标准的关系
+
+MAMP 可以作为其他标准的"翻译层":
+
+- **MCP (Model Context Protocol)** → 写 MCP ↔ MAMP 适配器
+- **OpenAI Assistant API** → 写 OpenAI ↔ MAMP 适配器
+- **自定义协议** → 写对应的适配器
+
+**核心思想**:不要试图统一所有 Agent 的内部实现,而是提供一个最薄的互操作层。
+
+---
+
+## 可选扩展
+
+如果需要更丰富的功能,可以逐步添加:
+
+- **认证**:在 metadata 中加 `auth_token`
+- **流式传输**:使用 Server-Sent Events 或 WebSocket
+- **异步回调**:加 `callback_url` 字段
+- **能力协商**:通过 `/mamp/v1/card` 端点
+- **错误处理**:标准化错误码
+
+---
+
+## 实现路线图
+
+### Phase 1:基础协议(1-2 周)
+
+**目标**:实现最简 MAMP 协议
+
+**任务**:
+1. 实现 `/mamp/v1/messages` 端点(接收消息)
+2. 实现 `/mamp/v1/card` 端点(Agent 身份)
+3. 实现 `send_to_agent` 工具(发送消息)
+4. 实现 `ConversationStore`(映射管理)
+5. 支持纯文本消息
+
+### Phase 2:多模态支持(1 周)
+
+**目标**:支持图片、代码等多模态内容
+
+**任务**:
+1. 扩展 content 格式处理
+2. 集成现有多模态实现(`agent/llm/prompts/wrapper.py`)
+3. 支持 base64 和 URL 两种图片传输方式
+
+### Phase 3:增强功能(可选)
+
+**目标**:认证、流式、异步等高级功能
+
+**任务**:
+1. API Key 认证
+2. WebSocket 流式传输
+3. 异步回调机制
+4. 错误处理和重试
+
+---
+
+## 相关文档
+
+- [A2A 跨设备通信](./a2a-cross-device.md):内部 Agent 间通信方案
+- [多模态支持](../../agent/docs/multimodal.md):图片、PDF 处理
+- [工具系统](../../agent/docs/tools.md):工具定义、注册
+- [Agent 框架](../README.md):核心 Agent 能力

+ 114 - 0
docs/research/a2a-protocols.md

@@ -0,0 +1,114 @@
+# Agent2Agent (A2A) 通信协议调研
+
+**调研日期:** 2026-03-03
+
+## 一、行业标准协议
+
+### 1. Google A2A Protocol (2025.04)
+- **定位:** Agent间任务协调和协作
+- **特性:** 标准化消息格式、能力协商、异步通信
+- **适用:** 企业级跨平台Agent协作
+
+### 2. Anthropic MCP (2024.11)
+- **定位:** AI助手与工具/数据系统连接
+- **特性:** JSON-RPC 2.0、即插即用工具
+- **适用:** Agent与工具交互(非Agent间通信)
+- **采用:** OpenAI (2025.03)、Google DeepMind
+
+### 3. IBM ACP (2025初)
+- **定位:** 基于HTTP的Agent消息传递
+- **特性:** 消息代理(Kafka/RabbitMQ)、会话跟踪
+- **适用:** 生产级系统的模块化和可追溯性
+
+### 4. Huawei A2A-T (2026.03开源)
+- **定位:** A2A协议的扩展实现
+- **状态:** 刚开源,推动标准应用
+
+## 二、主流框架实现
+
+### AutoGen (Microsoft)
+- **通信模式:** 对话式多Agent协作
+- **核心:** ConversableAgent + GroupChat
+- **消息管理:** 每个Agent维护对话历史,GroupChat维护全局记录
+- **特点:** 自然语言驱动、支持人机协作
+
+### LangGraph (LangChain)
+- **通信模式:** 基于状态图的消息传递
+- **核心:** State Graph + Persistent State + Message Bus
+- **消息管理:** 状态图管理 + 检查点机制
+- **特点:** 生产级、可追溯、原生支持A2A协议
+
+### CrewAI
+- **通信模式:** 基于角色的任务委派
+- **核心:** Role-Based Agents + Task Delegation + Crew Coordination
+- **消息管理:** Crew级任务历史 + 委派记录
+- **特点:** 类似人类团队、层次化任务分配
+
+## 三、通信模式对比
+
+| 模式 | 优点 | 缺点 | 适用场景 |
+|------|------|------|----------|
+| **直接调用** | 简单、低延迟 | 紧耦合、难扩展 | 小规模简单协作 |
+| **消息队列** | 解耦、异步、可靠 | 复杂、需基础设施 | 企业级大规模系统 |
+| **共享状态** | 知识全局可见、紧密协调 | 并发控制、状态冲突 | 高度协同团队 |
+| **混合模式** | 灵活、可优化 | 架构复杂 | 复杂生产系统 |
+
+## 四、消息历史管理策略
+
+1. **滑动窗口:** 保留最近N条消息
+2. **智能截断:** 基于重要性评分删除
+3. **自动总结:** 接近限制时总结历史(Claude Code使用)
+4. **分层存储:** 短期完整 + 长期总结
+5. **溢出修剪:** 从最旧消息开始修剪
+
+## 五、关键挑战
+
+### 1. 消息历史维护
+- 上下文窗口限制
+- 需要智能压缩策略
+- 跨Agent的上下文共享
+
+### 2. 异步通信
+- 事件驱动架构
+- 回调机制
+- 状态更新和轮询
+
+### 3. 多Agent协作复杂性
+- 协调模式(集中式 vs 去中心化)
+- 冲突解决
+- 死锁预防
+- 可观测性
+
+## 六、标准化趋势
+
+**当前状态(2024-2026):**
+- 协议层分化:MCP(工具层)、A2A(协作层)、ACP(传输层)
+- 行业共识形成中:Google、OpenAI、Anthropic、IBM、Huawei推动
+- 互操作性是关键
+
+**未来展望:**
+- 2026-2027:协议标准逐步成熟
+- 2028-2030:可能出现统一标准
+- 长期:Agent网络成为基础设施
+
+## 七、实践建议
+
+### 架构设计
+1. **分层设计:** 工具层(MCP)+ 协作层(A2A)+ 传输层(ACP)
+2. **消息管理:** 自动总结 + 分层存储
+3. **异步处理:** 事件驱动 + 超时重试
+4. **可观测性:** 结构化日志 + 分布式追踪
+
+### 选择建议
+- **小规模:** AutoGen、CrewAI
+- **大规模:** LangGraph + ACP/A2A
+- **工具集成:** 优先MCP
+- **Agent协作:** 优先A2A
+
+## 参考资料
+
+- [Google A2A Protocol](https://a2a-protocol.org/latest/specification/)
+- [Anthropic MCP](https://modelcontextprotocol.io/specification/2025-06-18)
+- [Huawei A2A-T](https://www.huawei.com/en/news/2026/2/mwc-a2at-opensource)
+- [Agent Interoperability Survey](https://arxiv.org/html/2505.02279v1)
+- [Framework Comparison 2026](https://markaicode.com/crewai-vs-autogen-vs-langgraph-2026/)

Неке датотеке нису приказане због велике количине промена