소스 검색

Initial import

SamLee 3 일 전
커밋
f169a26b4f
100개의 변경된 파일31884개의 추가작업 그리고 0개의 파일을 삭제
  1. 2 0
      .gitignore
  2. 1453 0
      agent-architecture-evolution-strategy.md
  3. 15 0
      agent/.env.template
  4. 113 0
      agent/.gitignore
  5. 3 0
      agent/.gitmodules
  6. 385 0
      agent/agent/README.md
  7. 67 0
      agent/agent/__init__.py
  8. 11 0
      agent/agent/cli/__init__.py
  9. 267 0
      agent/agent/cli/extraction_review.py
  10. 504 0
      agent/agent/cli/interactive.py
  11. 234 0
      agent/agent/client.py
  12. 30 0
      agent/agent/core/__init__.py
  13. 393 0
      agent/agent/core/dream.py
  14. 100 0
      agent/agent/core/memory.py
  15. 149 0
      agent/agent/core/presets.py
  16. 59 0
      agent/agent/core/prompts/__init__.py
  17. 73 0
      agent/agent/core/prompts/compression.py
  18. 116 0
      agent/agent/core/prompts/knowledge.py
  19. 39 0
      agent/agent/core/prompts/runner.py
  20. 3121 0
      agent/agent/core/runner.py
  21. 1433 0
      agent/agent/docs/architecture.md
  22. 396 0
      agent/agent/docs/cognition-log.md
  23. 467 0
      agent/agent/docs/comparison-with-claude-code.md
  24. 277 0
      agent/agent/docs/context-management.md
  25. 1372 0
      agent/agent/docs/decisions.md
  26. 620 0
      agent/agent/docs/memory.md
  27. 126 0
      agent/agent/docs/multimodal.md
  28. 189 0
      agent/agent/docs/prompt-guidelines.md
  29. 329 0
      agent/agent/docs/scope-design.md
  30. 234 0
      agent/agent/docs/skills.md
  31. 475 0
      agent/agent/docs/tools-refactor-plan.md
  32. 1541 0
      agent/agent/docs/tools.md
  33. 414 0
      agent/agent/docs/trace-api.md
  34. 36 0
      agent/agent/llm/__init__.py
  35. 209 0
      agent/agent/llm/claude.py
  36. 277 0
      agent/agent/llm/claude_code_oauth.py
  37. 464 0
      agent/agent/llm/gemini.py
  38. 932 0
      agent/agent/llm/openrouter.py
  39. 354 0
      agent/agent/llm/pricing.py
  40. 6 0
      agent/agent/llm/prompts/__init__.py
  41. 190 0
      agent/agent/llm/prompts/loader.py
  42. 168 0
      agent/agent/llm/prompts/wrapper.py
  43. 210 0
      agent/agent/llm/qwen.py
  44. 297 0
      agent/agent/llm/usage.py
  45. 488 0
      agent/agent/llm/yescode.py
  46. 16 0
      agent/agent/skill/__init__.py
  47. 99 0
      agent/agent/skill/models.py
  48. 402 0
      agent/agent/skill/skill_loader.py
  49. 46 0
      agent/agent/skill/skills/browser.md
  50. 121 0
      agent/agent/skill/skills/core.md
  51. 65 0
      agent/agent/skill/skills/planning.md
  52. 419 0
      agent/agent/skill/skills/research.md
  53. 21 0
      agent/agent/tools/__init__.py
  54. 13 0
      agent/agent/tools/adapters/__init__.py
  55. 62 0
      agent/agent/tools/adapters/base.py
  56. 120 0
      agent/agent/tools/adapters/opencode-wrapper.ts
  57. 138 0
      agent/agent/tools/adapters/opencode_bun_adapter.py
  58. 15 0
      agent/agent/tools/advanced/__init__.py
  59. 52 0
      agent/agent/tools/advanced/lsp.py
  60. 60 0
      agent/agent/tools/advanced/webfetch.py
  61. 86 0
      agent/agent/tools/builtin/__init__.py
  62. 315 0
      agent/agent/tools/builtin/bash.py
  63. 56 0
      agent/agent/tools/builtin/browser/__init__.py
  64. 2494 0
      agent/agent/tools/builtin/browser/baseClass.py
  65. 86 0
      agent/agent/tools/builtin/browser/sync_mysql_help.py
  66. 29 0
      agent/agent/tools/builtin/content/__init__.py
  67. 5 0
      agent/agent/tools/builtin/content/__main__.py
  68. 175 0
      agent/agent/tools/builtin/content/cache.py
  69. 46 0
      agent/agent/tools/builtin/content/ingestion.py
  70. 114 0
      agent/agent/tools/builtin/content/media.py
  71. 1 0
      agent/agent/tools/builtin/content/platforms/__init__.py
  72. 450 0
      agent/agent/tools/builtin/content/platforms/aigc_channel.py
  73. 315 0
      agent/agent/tools/builtin/content/platforms/x.py
  74. 463 0
      agent/agent/tools/builtin/content/platforms/youtube.py
  75. 125 0
      agent/agent/tools/builtin/content/registry.py
  76. 305 0
      agent/agent/tools/builtin/content/tools.py
  77. 353 0
      agent/agent/tools/builtin/content/transcription.py
  78. 85 0
      agent/agent/tools/builtin/context.py
  79. 90 0
      agent/agent/tools/builtin/feishu/FEISHU_TOOLS_PROMPT.md
  80. 9 0
      agent/agent/tools/builtin/feishu/__init__.py
  81. 505 0
      agent/agent/tools/builtin/feishu/chat.py
  82. 79 0
      agent/agent/tools/builtin/feishu/chat_test.py
  83. 892 0
      agent/agent/tools/builtin/feishu/feishu_agent.py
  84. 945 0
      agent/agent/tools/builtin/feishu/feishu_client.py
  85. 92 0
      agent/agent/tools/builtin/feishu/websocket_event.py
  86. 21 0
      agent/agent/tools/builtin/file/__init__.py
  87. 531 0
      agent/agent/tools/builtin/file/edit.py
  88. 108 0
      agent/agent/tools/builtin/file/glob.py
  89. 216 0
      agent/agent/tools/builtin/file/grep.py
  90. 125 0
      agent/agent/tools/builtin/file/image_cdn.py
  91. 322 0
      agent/agent/tools/builtin/file/read.py
  92. 321 0
      agent/agent/tools/builtin/file/read_images.py
  93. 138 0
      agent/agent/tools/builtin/file/write.py
  94. 99 0
      agent/agent/tools/builtin/file/write_json.py
  95. 108 0
      agent/agent/tools/builtin/glob_tool.py
  96. 17 0
      agent/agent/tools/builtin/im/__init__.py
  97. 337 0
      agent/agent/tools/builtin/im/chat.py
  98. 977 0
      agent/agent/tools/builtin/knowledge.py
  99. 96 0
      agent/agent/tools/builtin/memory.py
  100. 66 0
      agent/agent/tools/builtin/resource.py

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+.DS_Store
+**/.DS_Store

+ 1453 - 0
agent-architecture-evolution-strategy.md

@@ -0,0 +1,1453 @@
+# 脚本构建第三版:递归 Agent 架构演进策略
+
+> 文档状态:架构建议稿 0.2(已经两路独立校验并修订)  
+> 日期:2026-07-16  
+> 适用范围:脚本创作系统的宏观 Agent 架构、递归业务协议、运行时边界与演进路线  
+> 暂不讨论:当前 Agent SDK 已完成到什么程度、具体框架迁移工作量、数据库表和 API 的最终实现
+
+## 1. 结论摘要
+
+第三版不应把旧的固定 Workflow 改名为“多 Agent 系统”,也不应预先固定为:
+
+```text
+目标 Agent → 脉络 Agent → 元素 Agent → 装配 Agent → 评估 Agent
+```
+
+建议建设一个“递归创作内核”:
+
+- 固定的是递归协议、父子责任、验收合同、预算、安全边界和可恢复机制;
+- 动态的是局部目标、任务树、执行顺序、Agent 数量、所需 traits、工具组合和修正方向;
+- `目标`、`脉络`、`元素`、`文稿`不再是必须依次经过的工序,而是运行中可创建、引用、修订、分叉和合并的 Artifact;
+- 每个业务节点都可在任意深度运行同一套闭环,并在完成一次动作后选择:横向补充、继续下钻或者返回父层;
+- 子节点完成不等于父节点完成。父节点必须重新进入判断,决定是否创建下一个同层节点、继续细化、修改方向或向上交付;
+- 多 Agent 只用于高价值、可并行、高不确定的探索,不用于所有节点。强顺序、强共享上下文的叙事推演应尽量保持单 Agent 连续执行;
+- Agent SDK 适合作为模型、工具、会话、追踪和子 Agent 调用的执行层,但不应拥有业务递归树的最终语义。
+
+一句话概括:
+
+> 第三版要建设的是“固定协议、动态计划”的递归创作系统,而不是一条更复杂的智能流水线。
+
+### 阅读路径
+
+- 业务和产品决策:先读 1、2、9、16、19 节;
+- 架构和实现:重点读 5–8、12–15 节;
+- 评价和试验:重点读 10、11、16–18 节。
+
+文档中最容易混淆的几个词:
+
+| 词 | 大白话含义 |
+|---|---|
+| `RootIntent` | 用户最终想要什么,以及不能被下游擅改的边界 |
+| `GoalSpec` | 某个局部责任要达成的结果,是不可变的版本化对象 |
+| `WorkNode` | 对一个 GoalSpec 负责到底的业务节点,不等于一次模型调用 |
+| `NodeAttempt` | WorkNode 的一轮“判断—执行—验证”,返工会产生新 Attempt |
+| `Artifact` | 目标、脉络、证据、元素、草稿等可持久化业务产物 |
+| `Trait` | Agent “倾向怎么做”的软性行为策略,不是权限或固定角色 |
+
+本文的字段和枚举是架构级协议草案,用于固定边界和语义;进入开发前还应单独产出可执行的 Schema、事件定义和 ADR。
+
+---
+
+## 2. 为什么旧系统需要从 Workflow 演进为递归 Agent System
+
+旧系统围绕“创作表”逐步落数据,主要执行顺序是:
+
+```text
+制定目标
+→ 产生脉络
+→ 产生元素
+→ 将元素填入脉络
+→ 整体评估
+→ 修补
+```
+
+它已经具备不少智能能力:
+
+- 能读取选题和账号约束;
+- 能从 Case、Data、Knowledge 中检索材料;
+- 能建立段落、元素和段落—元素关联;
+- 能整体评估并补字段、补内容或补层级;
+- 新版本还能进行多分支实现、比较和合并。
+
+但它仍然是 Workflow,根本原因不是 Agent 数量不够,而是业务责任仍集中在主编排器:
+
+- 主编排器拥有全局目标、下一步决策、写入权和收口权;
+- 其他 Agent 主要返回候选或评价,没有自己负责到底的局部业务目标;
+- 任务不能在任意深度使用同一种父子协议继续递归;
+- 子节点完成后,父节点缺少强制重入、重新验收和重新规划的业务语义;
+- `build_workflow` 混合了“作品最终怎么展开”和“系统如何把作品造出来”两类不同概念。
+
+第三版需要把“创作表”从控制平面降级为 Artifact 的一种投影视图:系统如何思考,不再由表格字段的填写顺序决定;表格只负责呈现已经形成的创作结果。
+
+---
+
+## 3. 架构目标与非目标
+
+### 3.1 架构目标
+
+1. 任意深度的业务节点都使用同一种 `WorkNode` 合同。
+2. 节点能够在运行时自主发现缺口、创建子目标和调整局部计划。
+3. 父节点拥有直接子节点的创建、验收、合并和关闭责任。
+4. 目标、脉络、元素能够相互影响,而不是只能单向流动。
+5. 创作候选和中间版本完整保留,支持比较、组合、回选和回滚。
+6. 运行时能够根据任务特征选择单 Agent、并行候选、生成—评价循环或人工介入。
+7. 每棵递归树都有深度、宽度、Token、时间、工具、修订次数等整体预算。
+8. 长任务能够 checkpoint、暂停、恢复、重放和定位失败节点。
+9. 每个结果都能追溯到目标、节点、Agent、工具、来源和评价记录。
+10. 架构可以适应模型能力变化,而不需要每次模型升级都重写固定 Workflow。
+
+### 3.2 非目标
+
+- 不让 Agent 自由改变用户的最终意图;
+- 不追求每个步骤都由多个 Agent 参与;
+- 不追求一个完全无约束、可以无限生成子 Agent 的 Swarm;
+- 不允许生产运行中的 Agent 自己修改、批准并发布自己的系统 Prompt 或调度策略;
+- 不把 LLM 的自我评价当成唯一验收标准;
+- 不在第一阶段解决通用 AGI 或任意领域的自治问题;
+- 不要求一次重构就替换所有旧工具和业务数据能力。
+
+---
+
+## 4. 来自外部系统和论文的关键启示
+
+### 4.1 Anthropic Research:动态探索,而不是预设完整路径
+
+[Anthropic 多 Agent Research 系统](https://www.anthropic.com/engineering/multi-agent-research-system)采用 Lead Agent 动态派生并行 Subagent:Lead 根据中间发现决定下一批探索方向,子 Agent 使用隔离上下文搜索并返回压缩后的结果。
+
+值得吸收的原则:
+
+- 开放问题无法提前硬编码完整路径;
+- 子任务必须有明确目标、边界、输出格式、工具和来源要求;
+- 子 Agent 应直接写入持久化 Artifact,向父 Agent 返回引用和结论,避免多层“传话”损失;
+- 并行数量和工具调用量应随问题复杂度变化;
+- 长任务依赖 checkpoint、retry、恢复执行和完整 tracing;
+- 评价应重视最终状态和关键 checkpoint,而不是要求每次运行走同一条过程。
+
+需要避免照搬的部分:Anthropic 当前公开的 Research 架构主要仍是 Lead → Subagent 两层动态 fan-out,不等于任意层业务递归。
+
+### 4.2 WriteHERE:创作最接近的动态递归案例
+
+[WriteHERE](https://github.com/principia-ai/WriteHERE)及其[论文](https://arxiv.org/abs/2503.08275)不要求写作先完成固定大纲,再依序检索和创作;它把任务动态分成检索、推理、创作三类节点,并交错进行规划与执行。
+
+对第三版最重要的启示是:
+
+- “检索、思考、表达”是可调用的能力类型,不是固定流水线阶段;
+- 当前执行结果可以改变后续计划;
+- 脉络可以在创作过程中继续生长,而不是必须先一次性冻结;
+- 长文创作需要结构化任务图和上下文控制,不能只依赖一段不断增长的对话历史。
+
+### 4.3 ROMA:递归需要 Atomizer 和向上聚合
+
+[ROMA](https://github.com/sentient-agi/ROMA)及其[2026 年论文](https://arxiv.org/abs/2602.01848)把递归拆成四类职责:
+
+```text
+Atomizer:判断当前目标是否已足够原子化
+Planner:把非原子目标拆成有依赖关系的子目标
+Executor:执行原子目标
+Aggregator:把子结果压缩、验证并上升为父目标的结果
+```
+
+第三版应吸收 `Atomizer` 和 `Aggregator` 两个思想:
+
+- 不是所有目标都需要继续拆;
+- 子节点的原始输出不能简单拼接,父层必须重新解释、压缩、验证和形成自己的业务结论。
+
+但第三版不能只停留在“拆—做—汇总”。创作还需要候选分叉、局部修正、方向切换、父目标重开和版本回选。
+
+### 4.4 Anthropic Generator–Evaluator:生成者和评价者分离
+
+[Anthropic 长任务 Harness 工程实验](https://www.anthropic.com/engineering/harness-design-long-running-apps)显示:让生成者评价自己,尤其在主观创作任务中,容易产生过度肯定;把生成和评价分离,并用明确 rubric 与样例校准评价者,更容易形成有效修正。这是对本系统有价值的工程假设,不应被当成所有任务上的普适定律。
+
+同时需要吸收两个反直觉结论:
+
+- 评价后既可以继续精修,也可以彻底 pivot;
+- 最后一轮不一定最好,中间版本可能更优,因此每轮结果必须版本化保存,不能覆盖式更新。
+
+### 4.5 Google Co-Scientist:让候选群体进化
+
+[Google AI Co-Scientist](https://research.google/blog/accelerating-scientific-breakthroughs-with-an-ai-co-scientist/)通过 Generation、Reflection、Ranking、Evolution 和 Meta-review 等机制,让候选假设不断生成、比较和进化。
+
+对应到创作系统:
+
+- 创作目标、叙事主张和表达方案都可以形成候选群体;
+- 评价不只是指出问题,还应决定把后续算力投给哪些候选;
+- 优秀候选可以组合,不必只选唯一胜者;
+- 对主观创作不应只保留一个总分,而应维护多维质量向量和 Pareto 候选集。
+
+### 4.6 Google Agent Scaling:多 Agent 必须与任务结构匹配
+
+[Google 对 Agent System 的控制实验](https://research.google/blog/towards-a-science-of-scaling-agent-systems-when-and-why-agent-systems-work/)显示:可并行任务适合中央协调的多 Agent,而严格顺序任务使用多 Agent 会因沟通和上下文割裂显著退化。该结论来自特定 benchmark,并未直接验证脚本创作,因此只应作为第三版初始调度假设,后续必须用自有 Eval 验证。
+
+因此第三版不能采用“每个 WorkNode 默认生成多个 Agent”的策略。是否多 Agent,应由节点特征决定,而不是由层级深度或角色模板决定。
+
+### 4.7 OpenAI Symphony:给目标,不给死路径
+
+[OpenAI Symphony](https://openai.com/index/open-source-codex-orchestration-symphony/)的启示是:把业务目标和可观察状态固定下来,把完成目标的具体路径交给 Agent,并允许 Agent 提出后续工作。Symphony 自身仍使用明确工作状态和 `WORKFLOW.md`,它不是任意层递归业务树的现成实现。
+
+第三版应固定“目标、协议和护栏”,而不是固定“每一步业务动作”。
+
+---
+
+## 5. 总体架构
+
+```mermaid
+flowchart TB
+    U["用户与上游 Topic"] --> RI["RootIntent\n最终意图、账号约束、事实边界、禁区"]
+    RI --> RK["Recursive Work Kernel\nWorkNode、父层重入、调度、预算、恢复"]
+
+    RK --> NP["Node Policy\n判断粒度、缺口与执行形态"]
+    NP --> AR["Agent Runtime\n模型、工具、traits、上下文、结构化输出"]
+    NP --> CH["Child WorkNodes\n可顺序、并行或依赖执行"]
+
+    AR --> AS["Artifact/Candidate Store\n目标、脉络、证据、元素、草稿、评价"]
+    CH --> AS
+    AS --> EV["Evaluation Router\n硬校验、目标、连贯、原创、受众、事实"]
+    EV --> PR["Parent Re-entry\n接纳、修正、补同层、下钻、回退、升级"]
+    PR --> RK
+
+    RK --> ES["Event & Checkpoint Store\n完整历史、恢复、重放、审计"]
+```
+
+建议分成七层:
+
+1. **Intent 层**:保存用户最终意图和不可被子 Agent 擅改的全局约束。
+2. **Recursive Kernel 层**:定义 WorkNode、父子协议、树级预算和父层重入。
+3. **Policy 层**:判断是否拆分、是否并行、调用什么能力、何时停止。
+4. **Agent Runtime 层**:运行模型、工具、traits、会话和结构化输出。
+5. **Artifact 层**:保存所有目标、脉络、元素、证据、候选和版本关系。
+6. **Evaluation 层**:进行硬约束和创作品质验证,产生可执行反馈。
+7. **Durability/Observability 层**:事件日志、checkpoint、恢复、追踪和成本统计。
+
+---
+
+## 6. 核心业务对象
+
+### 6.1 RootIntent:允许自治,但不能目标漂移
+
+“Agent 自己划定目标”不意味着 Agent 可以改变用户的最终目的。
+
+应区分三层目标:
+
+- `RootIntent`:用户或上游业务给出的最终意图,原则上不可由普通节点擅自修改;
+- `GoalSpec`:在 RootIntent 下生成的局部期望状态、约束和质量标准,不可变且带版本;
+- `CreativeIntent`:尚在探索的创作目标 Artifact。只有被有权父节点接纳并提升后,才会形成新的 GoalSpec。
+
+WorkNode 不内嵌一段可被静默改写的 objective,而是保存 `objective_ref = GoalSpec@version`。Replan 会产生新 GoalSpec 和新 NodeContract,旧 Attempt 被标记为 `SUPERSEDED`。
+
+RootIntent 建议明确区分:
+
+```text
+hard_constraints
+  用户最终诉求、事实与来源边界、禁区、必须条件
+
+negotiable_preferences
+  受众偏好、语气、长度、形式、创意风险偏好
+
+autonomy_scope
+  系统可自主划定哪些局部目标,可调动什么资源
+
+human_gates
+  哪些高主观、高风险或不可逆决定必须由人批准
+```
+
+当下游发现 RootIntent 冲突或信息不足时,只能产生 `RootIntentChangeProposal`。`RootAuthority` 是人类或明确授权的业务策略;只有它能批准 hard constraints 的改变。根 Agent 可以提议,不能自行批准。最终关闭由 `RootCompletionGate` 对 RootIntent、最终 Artifact Projection、风险和人工门禁做验收。
+
+### 6.2 WorkNode:递归业务责任的最小单元
+
+WorkNode 不是一次 LLM Call,也不是一条表格记录。它表示一个需要负责到底的局部业务目标。
+
+建议字段:
+
+```text
+node_id / parent_node_id
+objective_ref = GoalSpec@version
+contract_ref = NodeContract@version
+scope / owns / forbids
+facts / hypotheses / unknowns
+owning_parent / dependencies / children
+artifact_refs / evidence_refs
+required_capabilities
+behavioral_preferences / forbidden_behavior
+node_profile
+capability_lease / budget_lease
+status / state_revision
+attempt_ids / progress / stall_count
+```
+
+`owning_parent` 和 `dependencies` 必须分开:一个 WorkNode 只有一个负责验收的直接父节点;跨分支依赖只影响调度和 Artifact 失效传播,不改变所有权。
+
+其中 `node_profile` 用于调度判断:
+
+```text
+decomposability          是否容易拆成相互独立的部分
+sequential_dependency    前一步对后一步的依赖强度
+shared_context_need      多个执行者共享同一上下文的必要程度
+uncertainty              当前方向和答案的不确定性
+verifiability            是否存在可靠的外部验证方式
+tool_density             需要协调多少工具
+expected_value           继续投入的预期价值
+subjectivity             多大程度依赖品味和受众感受
+```
+
+### 6.3 NodeAttempt:返工不覆盖上一轮
+
+`NodeAttempt` 是 WorkNode 的一轮完整尝试:
+
+```text
+attempt_id / node_id
+objective_ref / contract_ref
+input_snapshot_ref
+base_artifact_versions
+decision
+agent_run_refs / tool_run_refs
+artifact_proposal_refs
+evaluation_refs
+started_at / ended_at
+status / termination_reason
+```
+
+`ChildDisposition=REQUEST_REVISION` 会创建新 Attempt,而不是清空旧尝试。这样才能回放“为什么改、改了什么、质量是否真的提升”。
+
+### 6.4 NodeContract:父子开始工作前先约定“完成是什么”
+
+父节点创建子节点时,必须同时提供不可变、带版本的可检查合同:
+
+```text
+contract_id / contract_version
+objective_ref
+为什么需要这个子节点
+它拥有和不得修改的范围
+必须读取的 Context/Artifact 引用
+期望交付物及结构
+验收维度和最低通过条件
+来源与证据要求
+必需业务能力 required_capabilities
+行为偏好 behavioral_preferences / forbidden_behavior
+授权与副作用边界
+预算与截止条件
+失败时允许采取的动作
+返回父层必须说明的未解决问题
+```
+
+子节点可以接受合同,也可以返回 `CONTRACT_CHALLENGE`:指出目标冲突、上下文缺失、验收不可验证或者预算不足。父节点如果接纳,必须创建新合同版本,不能就地改动旧合同。
+
+合同的价值不是把步骤写死,而是固定责任边界,让子节点可以自主选择实现路径。
+
+### 6.5 Artifact 与 Candidate:结果不能只存在于聊天消息中
+
+建议把以下内容都建模为 Artifact:
+
+```text
+CreativeIntent         创作目标候选
+AudienceModel          受众状态和预期变化
+NarrativeGraph         主张、证据、情绪与行动的叙事关系
+EvidenceItem           事实、案例、实验和来源
+ElementCandidate       人物、场景、概念、方法、视觉、形式元素
+CompositionCandidate   某个局部表达或段落方案
+Draft                   文稿或执行稿版本
+EvaluationReport        评价结果与修正建议
+DecisionRecord          父节点的取舍与理由
+```
+
+Artifact 应采用不可变版本或 append-only 变更:
+
+- 每次修改产生新版本,不覆盖旧版本;
+- 记录 `derived_from`、贡献节点、使用来源和评价;
+- 父节点可以 `accept / merge / park / reject / supersede`;
+- 当前最佳版本只是一个指针,不等于删除其他候选;
+- 最终创作表由已接受 Artifact 投影生成。
+
+`Candidate` 不是另一种与 Artifact 并列的业务对象,而是“尚未被 owner 提升为当前权威版本”的 Artifact 版本状态。执行者不直接写权威指针,而是提交 `ArtifactProposal/ChangeSet`:
+
+```text
+proposal_id
+artifact_type / artifact_id
+expected_base_version
+new_version_ref / patch
+rationale / evidence_refs
+affected_refs
+idempotency_key
+```
+
+父节点或 Artifact owner 通过 compare-and-swap / 事务接纳,避免并发子节点静默覆盖彼此。
+
+### 6.6 ReturnEnvelope:子节点向父节点交付的统一协议
+
+子节点不能只返回一段自然语言总结。建议统一返回:
+
+```text
+run_id
+node_id
+attempt_id
+contract_id / contract_version
+parent_goal_ref
+parent_contract_ref
+spawn_generation
+spawned_from_parent_revision      # 追踪用,不直接决定语义失效
+input_snapshot_ref
+base_artifact_versions
+budget_lease_id
+idempotency_key
+completion_status
+return_reason
+deliverable_refs
+artifact_proposal_refs
+evidence_refs
+validation_reports
+assumptions_made
+unresolved_gaps
+risks
+sibling_proposals
+parent_assumptions_to_reconsider
+recommended_disposition
+budget_used
+trace_ref
+```
+
+`completion_status` 表示交付完成程度:
+
+```text
+SATISFIED
+PARTIAL
+UNSATISFIED
+FAILED
+CANCELLED
+SUPERSEDED
+```
+
+`return_reason` 表示为什么现在返回:
+
+```text
+NORMAL
+CONTRACT_CHALLENGE
+ESCALATION
+BUDGET
+DEADLINE
+RISK
+DEPENDENCY_BLOCKED
+```
+
+任何结果都可以合法返回,不是只有“已完成”才能交付。父节点先检查 `parent_goal_ref + parent_contract_ref + spawn_generation` 的语义有效性,再检查 Artifact 基线版本;语义陈旧的返回只能 rebase、park 或 reject,不得静默合并。单纯因另一子节点先返回而导致父状态 revision 增长,不等于这个子结果已过时。检查通过后,父节点必须重新进入 deliberation,而不是由框架自动把父节点标记为完成。
+
+---
+
+## 7. Goal、Capability 与 Traits 如何耦合
+
+### 7.1 先划清“要什么、能做什么、怎么做”
+
+这几个对象不能混在 Trait 或 Agent 角色里:
+
+| 对象 | 职责 | 不应包含 |
+|---|---|---|
+| `GoalSpec` | 期望状态、约束、质量标准 | 模型名、工具名、Trait 名 |
+| `Capability` | 有类型输入输出的业务能力,如 `search_evidence`、`compare_candidates`、`compose_narrative` | 具体 Agent 人设 |
+| `Tool` | Capability 的一种具体实现或外部动作接口 | 是否有权调用自己的决定 |
+| `Trait` | “倾向怎么做”的软性行为策略 | 权限、硬验收、输出 Schema |
+| `AuthorizationPolicy / CapabilityLease` | 可使用哪些能力、资源范围、副作用、预算、是否可继续委派 | 创作风格偏好 |
+| `ContextRecipe` | 本次读哪些上下文与 Artifact | 给 Agent 新的业务权限 |
+| `OutputContract / ValidatorSet` | 必须交付什么、如何验收 | 软性人设 |
+
+Trait 不应只是一句 Prompt 形容词,例如“你要有创造力”或“你要严谨”。它应是一份可版本化、可组合、可评价的行为策略模块:
+
+```text
+trait_id / version
+purpose
+activation_conditions
+behavioral_rules
+compatible_traits / conflicting_traits
+cost_and_latency_hint
+```
+
+Trait 永远不能授予权限,也不能绕过 RootIntent、NodeContract、Schema 或确定性护栏。
+
+脚本创作可能需要的 Traits:
+
+| Trait | 作用 | 典型使用位置 |
+|---|---|---|
+| `creative_divergence` | 主动生成差异化候选,避免过早收敛 | 创作目标、角度、形式探索 |
+| `evidence_grounded` | 所有事实和案例保留来源,区分事实与推断 | 机制、实验、案例节点 |
+| `causal_reasoning` | 检查主张、原因、证据和结论的因果链 | 机制解释、知识内容 |
+| `narrative_continuity` | 维护上下文、段间承接和受众认知变化 | 脉络与连续写作 |
+| `audience_empathy` | 从目标受众视角预测理解、情绪和行动 | 目标、表达和整体评价 |
+| `account_consistency` | 约束账号语言、价值和表现习惯 | 目标、形式与最终文稿 |
+| `skeptical_evaluator` | 主动寻找遗漏、模板化和虚假完成 | 独立评价节点 |
+
+`evidence_grounded` 可以影响推理风格,但“来源必须可追溯”是硬合同;“不直接覆盖主版本”和“权限最小化”是 Kernel Policy,不应伪装成 Trait。
+
+### 7.2 Goal 不应直接绑定 Agent 或 Trait
+
+不建议这样设计:
+
+```text
+GoalType = 产生脉络
+固定调用 OutlineAgent
+OutlineAgent 固定带 narrative_trait
+```
+
+建议增加中间层:
+
+```text
+GoalSpec + NodeContract + NodeProfile
+→ CapabilityResolver
+→ TraitComposer
+→ ModelRouter / ToolBinder
+→ 与 AuthorizationPolicy / CapabilityLease 求交集
+→ ContextBuilder + OutputContract + ValidatorSet
+→ ExecutionProfile
+→ 临时 AgentInstance
+```
+
+`ExecutionProfile` 可以理解为本次执行的完整配方:
+
+```text
+model_class
+instructions
+capabilities
+traits
+tools
+capability_lease
+context_recipe
+output_contract / output_schema
+validators
+budget
+```
+
+最终运行的 Agent 是一次临时实例化:
+
+```text
+AgentInstance
+= Model
++ Tools
++ Traits
++ CapabilityLease
++ Scoped Context
++ Output Schema
++ Budget
+```
+
+这个公式只描述执行配方,不赋予 AgentInstance 业务所有权。WorkNode 才是持久责任主体;AgentInstance 可以在暂停、恢复、模型更换后被重建。
+
+这样有几个好处:
+
+- 同一个目标可以先用探索型 Agent 生成候选,再用因果型 Agent 深化;
+- 同一个 Trait 可以被不同目标复用;
+- 模型升级或工具变化不会迫使业务目标模型跟着变化;
+- 不会因为预先创建了几十个角色,而让系统退化成固定组织架构。
+
+### 7.3 Trait 的继承规则
+
+子节点应继承全局硬约束、父合同和权限上限,不是父 Agent 的全部 Traits。
+
+决策时先合并硬约束:
+
+```text
+Safety / AuthorizationPolicy
+∩ RootIntent hard_constraints
+∩ NodeContract
+∩ Parent CapabilityLease
+```
+
+任一硬约束冲突都应返回 ContractChallenge 或 Escalation,不由软性 Trait 决胜。硬约束通过后,再按 `Domain behavioral defaults → Task behavioral preferences → 模型默认行为` 组合 Trait,且越靠前优先级越高。
+
+例如:
+
+```text
+父节点:探索“谷歌效应”创作目标
+  traits = creative_divergence + audience_empathy
+
+子节点:验证认知卸载机制
+  traits = evidence_grounded + causal_reasoning
+
+子节点:形成连续叙事
+  traits = narrative_continuity + account_consistency
+
+评价节点:检查原创性和逻辑漏洞
+  traits = skeptical_evaluator
+```
+
+创作发散和怀疑式评价通常存在行为冲突,最好由不同 Agent 实例承担,而不是要求同一个模型在同一次调用中既大胆发散又立即否定自己。
+
+---
+
+## 8. 父子节点机制
+
+### 8.1 所有权原则
+
+- 父节点只拥有自己的直接子节点;
+- 子节点可以创建自己的子节点,但不能直接创建自己的兄弟节点;
+- 子节点可以通过 `sibling_proposals` 建议父节点创建 `1.1.2`;
+- 是否创建 `1.1.2`,最终由 `1.1` 决定;
+- 子节点不得直接修改父节点已接受的 Artifact,只能提交 Candidate/Patch;
+- 父节点负责对子结果进行验收、合并、驳回、停车或要求返工。
+
+### 8.2 三个树方向
+
+用户提出的三个方向应保留为产品语言,但不宜直接做成一个状态机枚举:
+
+```text
+横向细化
+  子节点返回 sibling_proposals,父节点验收后自己决定 SPAWN_CHILDREN
+
+向下
+  当前节点的 ParentNextAction=SPAWN_CHILDREN,拆出自己负责的下一层
+
+回退 / 向上
+  当前节点的 ParentNextAction=RETURN,通过 CompletionStatus + ReturnReason 把当前责任交给上层
+```
+
+因此“横向”不是子节点直接改树的权限,而是一次向上返回中的建议。`SPAWN_CHILDREN` 已表达“继续拆分”,不再另设与它重叠的 `DECOMPOSE_AFTER_FAILURE`。
+
+### 8.3 结果处置维度
+
+一次父层重入必须明确下面四轴,不把“结果怎样”、“为什么返回”、“如何处置子责任”和“父节点自己下一步做什么”混在一起:
+
+```text
+CompletionStatus
+  SATISFIED | PARTIAL | UNSATISFIED | FAILED |
+  CANCELLED | SUPERSEDED
+
+ReturnReason
+  NORMAL | CONTRACT_CHALLENGE | ESCALATION | BUDGET |
+  DEADLINE | RISK | DEPENDENCY_BLOCKED
+
+ChildDisposition
+  ACCEPT | REQUEST_REVISION | PARK | REJECT | CANCEL | SUPERSEDE
+
+ParentNextAction
+  EXECUTE_LOCAL | SPAWN_CHILDREN | WAIT_CHILDREN |
+  PIVOT | REPLAN | REASSIGN | RETURN
+```
+
+`ESCALATION` 是当前节点向上返回的原因,不是父节点对子责任的处置。父节点如果也无法解决,再选择自己的 `ParentNextAction=RETURN` 和 `ReturnReason=ESCALATION`。
+
+ChildDisposition 与 NodeStatus 的确定性映射为:
+
+| ChildDisposition | 子 WorkNode 下一状态 | 附加动作 |
+|---|---|---|
+| `ACCEPT` | `COMPLETED` | 记录父层验收证据 |
+| `REQUEST_REVISION` | `READY` | 创建新 NodeAttempt,保留旧 Attempt |
+| `PARK` | `SUSPENDED` | 保留恢复条件和预算归还记录 |
+| `REJECT` | `REJECTED` | 保留结果与驳回理由 |
+| `CANCEL` | `CANCELLED` | 取消未完成副作用或进入补偿 |
+| `SUPERSEDE` | `SUPERSEDED` | 关联新 GoalSpec / NodeContract / spawn generation |
+
+Artifact 处置仍是独立的 `ACCEPT | MERGE | PARK | REJECT | SUPERSEDE | REQUIRE_REVALIDATION`。一个子节点可以交付三个 Artifact,父节点接纳两个、停放一个;因此 Artifact 处置不能反向代表整个节点状态。
+
+### 8.4 父层重入
+
+每个子节点返回都先以 `idempotency_key` 写入 append-only `ChildReturnInbox`,不拿子节点启动时看到的旧 parent revision 直接做 CAS。调度器可合并同一批返回,然后读取父节点当前的 `state_revision`,生成完整 `ParentReentryContext`:
+
+```text
+parent_snapshot / parent_state_revision
+parent_goal_ref / parent_contract_ref / active_spawn_generation
+received_envelopes / pending_children / child_return_inbox
+child_group_policy
+artifact_base_versions
+accepted_child_artifacts / unresolved_gaps
+sibling_proposals
+evaluation_reports / conflicts
+remaining_budget / marginal_value
+```
+
+子节点组策略至少支持:
+
+```text
+ALL          等所有直接子节点
+QUORUM       达到足够有效结果
+FIRST_VALID  第一个通过合同即可收敛
+DEADLINE     到截止时间用已有结果整合
+STREAMING    每个返回都允许父层重入并调整计划
+```
+
+父节点必须先产生一等对象 `IntegrationPlan`,执行后得到 `IntegrationResult`:
+
+```text
+IntegrationPlan
+  expected_parent_state_revision
+  selected_envelopes
+  conflict_resolution
+  artifact_changesets
+  children_to_wait / cancel / revise
+  gaps_after_integration
+
+IntegrationResult
+  committed_artifact_versions
+  rejected_or_parked_refs
+  invalidated_refs
+  remaining_gaps
+parent_contract_validation
+```
+
+提交 ParentDecision / IntegrationPlan 时才使用 `expected_parent_state_revision` 做 CAS。如果另一次父层重入已经推进 revision,当前 IntegrationPlan 重读 inbox 和新快照后重算,而不是将后返回的合法子结果判为陈旧。只有 `parent_goal_ref`、`parent_contract_ref` 或 `spawn_generation` 不再活跃时,子结果才是语义陈旧;Artifact 的 `expected_base_version` 冲突则单独走 rebase / merge / reject。
+
+完成整合后,`1.1` 才决定要求 `1.1.1` 修订、创建 `1.1.2`、自己执行、重新规划,或确认当前合同完成并返回 `1`。当 QUORUM、FIRST_VALID 或 DEADLINE 已满足时,IntegrationPlan 必须明确等待、取消还是停放其余子节点,不得让它们在背景继续无限消耗。
+
+```mermaid
+sequenceDiagram
+    participant P as 父 WorkNode
+    participant K as Recursive Kernel
+    participant C as 子 WorkNode
+    participant A as Artifact Store
+    participant E as Evaluator
+    P->>K: 提交 ChildSpec + NodeContract + BudgetLease
+    K->>C: 启动独立、持久化 Child Run
+    C->>A: 提交 Candidate / ChangeSet
+    C-->>K: ReturnEnvelope
+    K->>K: 幂等写 ChildReturnInbox,校验语义来源
+    K->>P: 以当前 state_revision 恢复 ParentReentryContext
+    P->>E: 评价待整合结果
+    P->>K: IntegrationPlan + expected_parent_state_revision
+    K->>A: CAS + 事务执行 ChangeSets
+    P-->>K: 继续执行 / 新建子节点 / 返回上层
+```
+
+这个“返回事件 → 版本检查 → 父层整合 → 父合同重验”闭环,才是第三版区别于普通 GoalTree 的核心语义。
+
+### 8.5 节点生命周期
+
+节点运行状态、父层处置和 Artifact 处置必须分开。`NodeStatus` 固定的是协议状态,不是业务工序:
+
+```text
+PROPOSED
+→ CONTRACTING
+→ READY
+→ RUNNING
+→ WAITING_CHILDREN 或 SYNTHESIZING
+→ READY_TO_RETURN
+→ RETURNED
+→ COMPLETED / SUSPENDED / REJECTED / CANCELLED / SUPERSEDED
+```
+
+父层 `REQUEST_REVISION` 会让同一 WorkNode 创建新 NodeAttempt 并回到 `READY/RUNNING`;Replan 创建新 GoalSpec 和 NodeContract,旧 Attempt 及其陈旧返回被 `SUPERSEDED`。`CompletionStatus=FAILED` 只说明本次交付失败,父层仍可选择 REQUEST_REVISION、PARK 或 REJECT,因此它不直接等于 WorkNode 终态。`RETURNED` 也不等于 `COMPLETED`,只有 owning parent 接纳责任,或 RootCompletionGate 通过,节点才最终关闭。
+
+任何层级都运行这套协议。不同节点的业务步骤可以完全不同,严禁用 `GOAL_STAGE → OUTLINE_STAGE → ELEMENT_STAGE` 一类枚举驱动节点状态机。Artifact 类型是数据 Schema,不是流程阶段。
+
+---
+
+## 9. 创作领域如何动态运行
+
+### 9.1 目标、脉络、元素不是固定顺序
+
+旧系统倾向于:
+
+```text
+先定目标 → 再建完整脉络 → 再找齐元素 → 再装配
+```
+
+第三版允许出现:
+
+```text
+发现受众矛盾
+→ 下钻验证矛盾是否真实
+→ 形成一个创作目标候选
+→ 建立初步叙事主张
+→ 发现证据缺口
+→ 创建检索子节点
+→ 新案例带来更强的表达元素
+→ 元素反向触发脉络重排
+→ 连续写作
+→ 评价发现目标本身过于普通
+→ 返回目标父层,保留旧稿并生成新方向
+```
+
+固定的是 Artifact 类型和父子协议,不是 Artifact 出现的先后顺序。
+
+### 9.2 目标建模
+
+建议把创作目标建模为“受众变化”,而不是一句抽象口号:
+
+```text
+受众当前认知/情绪/行为
+→ 作品希望造成的变化
+→ 核心主张和信息增量
+→ 为什么适合这个账号
+→ 可以怎样验证是否实现
+```
+
+根节点可以并行生成若干 `CreativeIntent Candidate`,再由父层按目标价值、原创性、账号一致性、可证据化程度和可执行性选择或组合。
+
+### 9.3 脉络建模
+
+脉络不应首先等同于 P1–P5 段落。建议先表示为 `NarrativeGraph`:
+
+```text
+受众初始状态
+→ 矛盾/悬念
+→ 核心主张
+→ 解释或证据
+→ 情绪桥
+→ 行动或价值收束
+→ 受众目标状态
+```
+
+段落结构是 NarrativeGraph 的一种表现形式。NarrativeGraph 变化后,可以重新投影为不同的段落数量、层级和顺序。
+
+### 9.4 元素建模
+
+元素应围绕“叙事需要”出现,而不是进入统一的元素生产阶段:
+
+| 当前缺口 | 可能创建的元素节点 |
+|---|---|
+| 缺可信度 | 事实、实验、数据、真实案例 |
+| 缺理解 | 概念、类比、机制解释、反例 |
+| 缺情绪 | 人物、生活场景、冲突、细节 |
+| 缺行动 | 方法、步骤、工具、检查表 |
+| 缺记忆点 | 金句、视觉意象、形式结构 |
+| 缺账号感 | 历史表达、语言习惯、视觉习惯 |
+
+元素与脉络是双向关系:脉络发现缺口后请求元素;强元素也可以反向改变主张和段落结构。
+
+### 9.5 跨分支影响:元素不能静默篡改脉络
+
+当子节点发现自己的结果会改变另一分支时,它不直接改对方的 Artifact,而是提交:
+
+```text
+ImpactProposal
+  source_artifact_version
+  target_artifact_refs
+  relation:
+    SUPPORTS | CONTRADICTS | REQUIRES_CHANGE | INVALIDATES
+  rationale / evidence_refs
+  proposed_change
+  affected_dependents
+```
+
+审批者是目标 Artifact 的 owner;如果涉及多个 owner,由最近公共父节点协调。接纳后生成新 Artifact 版本,Artifact Graph 只沿 `DERIVED_FROM / USES / PROJECTED_FROM` 等 DependencyEdge 把真正下游标记为 `STALE` 或 `REVALIDATION_REQUIRED`;`SUPPORTS / CONTRADICTS` 等 SemanticEdge 只触发评价或 ImpactProposal,不自动级联失效。
+
+例如:
+
+```text
+Evidence v1
+→ 否定 NarrativeClaim v1
+→ 1.3.1 提交 ImpactProposal
+→ 最近公共父节点重开 1.2
+→ 产生 NarrativeGraph v2
+→ Draft v1 标记 STALE
+→ 产生 Draft v2
+→ 重新验证 CreativeIntent 是否仍成立
+```
+
+如果 RootIntent 或 GoalSpec 本身版本改变,所有尚在运行的旧子 Attempt 回来时都先做版本检查,不能用陈旧结果把新方向改回去。
+
+### 9.6 一个示例递归树
+
+```text
+1 完成“谷歌效应”脚本创作
+├── 1.1 划定最有价值的创作目标
+│   ├── 1.1.1 探索受众真实矛盾
+│   │   ├── 1.1.1.1 检索数字失忆场景
+│   │   └── 1.1.1.2 验证断网焦虑是否适合主线
+│   └── 1.1.2 验证目标与账号定位是否匹配
+├── 1.2 构建叙事主张
+│   ├── 1.2.1 解释认知卸载机制
+│   └── 1.2.2 建立从机制到行动的过渡
+├── 1.3 为叙事缺口寻找元素
+│   ├── 1.3.1 Betsy Sparrow 实验
+│   └── 1.3.2 主动提取练习
+└── 1.4 连续创作与整体收口
+```
+
+这只是某一时刻的树快照,不是启动前固定的阶段。它可能由 `1.1.1` 的结果逐步生长,`1.3.1` 也可能让父层重新修改 `1.2`。为避免实现者又照抄成“目标 → 脉络 → 元素”流水线,最少应将下面三类非线性路径做成架构一致性测试:
+
+```text
+证据先行:先发现强案例 → 再提炼 CreativeIntent → 再生长 NarrativeGraph
+元素反推:强 Element → 推翻 NarrativeClaim → 必要时重开 GoalSpec
+整体回跳:终局评价 → 跨过当前稿件节点 → 返回上两层重定局部目标
+```
+
+---
+
+## 10. 如何选择单 Agent、多 Agent 和工具执行
+
+建议由 `ExecutionModePolicy` 根据 NodeProfile 选择:
+
+| 节点特征 | 推荐执行形态 |
+|---|---|
+| 高可分解、低顺序依赖、高不确定 | 父节点中央协调,多候选子 Agent 并行 |
+| 高顺序依赖、高共享上下文 | 单 Agent 连续执行,必要时只调用工具 |
+| 有可靠硬验证器 | Generator → Validator → Revision Loop |
+| 高主观、需要多样性 | 多候选生成 + 独立评价 + Pareto 保留 |
+| 工具多、调用复杂但目标明确 | 一个主 Agent 统一调用工具,避免多 Agent 协调税 |
+| 连续无进展 | 更换 traits/模型/方法,或回到父层重规划 |
+| 高风险或评价分歧大 | 请求人类确认 |
+
+创作中的推荐划分:
+
+适合并行:
+
+- 创作目标和角度候选;
+- 受众洞察假设;
+- Case、Knowledge、Data 和证据检索;
+- 元素和表现形式候选;
+- 不同创意方向的草案;
+- 从事实、原创、账号、受众等不同视角评价。
+
+适合连续单 Agent:
+
+- 一条已选叙事链的因果推演;
+- 一个段落内部的连续展开;
+- 强依赖前文语气和节奏的写作;
+- 最终全篇一致性修订;
+- 需要大量共享上下文的装配任务。
+
+---
+
+## 11. 评价与递归自动修正
+
+### 11.1 评价分层
+
+建议拆成可动态路由的评价能力:
+
+```text
+Hard Validator
+  字段、结构、长度、禁区、来源、事实冲突、权限和副作用
+
+Goal Critic
+  当前 Artifact 是否真正完成局部目标
+
+Coherence Critic
+  与父目标、兄弟结果和整篇叙事是否连贯
+
+Originality Critic
+  是否模板化、陈词滥调或过度借用
+
+Audience Simulator
+  目标受众会如何理解、感受和行动
+
+Account Critic
+  是否符合账号价值和表达习惯
+
+Meta Reviewer
+  多个评价者冲突时进行裁决或请求人工
+```
+
+并非每个节点都调用所有评价者。Evaluator Router 应根据风险、主观性、Artifact 类型和历史失败动态选择。
+
+### 11.2 多维质量,而不是单一总分
+
+建议至少记录:
+
+```text
+goal_achievement
+factual_grounding
+narrative_coherence
+information_gain
+originality
+account_fit
+audience_effect
+actionability
+cost_efficiency
+```
+
+总分可以用于粗筛,但不能作为唯一决策依据。系统应保留在不同维度占优的 Pareto 候选,避免所有内容被一个 rubric 推向同一种安全风格。
+
+### 11.3 修正阶梯
+
+发现问题后,不要只做机械 retry。自动修正应复用第 8 节的正交协议,不再发明一套重叠枚举:
+
+```text
+1. 局部修订
+   父节点自己修改:ParentNextAction=EXECUTE_LOCAL
+   让原子节点修改:ChildDisposition=REQUEST_REVISION
+
+2. 方向切换
+   ParentNextAction=PIVOT,保留旧 Candidate
+
+3. 局部重规划
+   ParentNextAction=REPLAN,生成新 GoalSpec / NodeContract / spawn generation
+
+4. 继续向下拆分
+   ParentNextAction=SPAWN_CHILDREN
+
+5. 更换执行配方
+   ParentNextAction=REASSIGN,重新解析模型、Traits 和能力实现
+
+6. 向上返回或升级
+   ParentNextAction=RETURN
+   例如 CompletionStatus=UNSATISFIED + ReturnReason=CONTRACT_CHALLENGE / ESCALATION
+```
+
+### 11.4 停止条件
+
+递归系统最危险的不是不会继续,而是不知道何时停止。每轮至少检查:
+
+- 父合同是否通过;
+- 最近若干轮是否仍有真实质量提升;
+- 是否出现已有能力回归;
+- 是否只是复杂度增加,质量没有增加;
+- 评价者是否发生无法解决的分歧;
+- 剩余预算是否值得继续;
+- 是否缺少可靠验证器;
+- 是否已经进入必须依赖人类品味的区域。
+
+终止原因应被结构化记录:
+
+```text
+ACCEPTED_BY_PARENT
+MARGINAL_GAIN_TOO_LOW
+STALL_LIMIT_REACHED
+BUDGET_EXHAUSTED
+RISK_ESCALATION
+HUMAN_JUDGMENT_REQUIRED
+NO_RELIABLE_VALIDATOR
+```
+
+---
+
+## 12. 三棵图和四类状态必须分开
+
+第三版至少需要维护三套相互关联但不能混为一体的结构:
+
+### 12.1 WorkNode Tree
+
+回答:为什么做、谁对什么目标负责、父子如何收敛。
+
+### 12.2 Artifact/Candidate Graph
+
+回答:实际产生了哪些目标、脉络、证据、元素、草稿和版本,它们如何派生、组合和被淘汰。
+
+Artifact 不一定严格是树,因为一个证据可以被多个段落使用,一个元素也可能支持多个候选。但图中两类边必须分开:
+
+```text
+SemanticEdge
+  SUPPORTS | CONTRADICTS | ANALOGOUS_TO | ALTERNATIVE_TO
+  用于推理和评价,不自动传播 STALE
+
+DependencyEdge
+  DERIVED_FROM | USES | PROJECTED_FROM
+  表示产物真正依赖的基线,上游失效时才自动传播 STALE / REVALIDATION_REQUIRED
+```
+
+否则 `A SUPPORTS B`、`B CONTRADICTS C` 一类双向语义网络可能形成循环失效,把整张 Artifact Graph 无意义地全部重开。
+
+### 12.3 Execution Trace Tree
+
+回答:哪个 Agent、模型、Prompt、工具和调用产生了某个结果,成本、延迟和错误在哪里发生。
+
+此外还要区分四类状态:
+
+| 状态 | 用途 |
+|---|---|
+| Session/Event History | 完整、append-only、不可因上下文压缩而丢失的运行历史 |
+| Working Context | 当前节点本轮真正需要送进模型的最小高信号上下文 |
+| Artifact Store | 目标、脉络、元素、文稿和评价等业务产物 |
+| Checkpoint | 可恢复执行所需的节点、消息、预算和待处理动作快照 |
+
+模型上下文可以压缩或重建,但 Session 和 Artifact 不能随模型上下文一起消失。
+
+为了让“临时 AgentInstance”与“连续创作所需的语境”同时成立,建议增加:
+
+```text
+ContextManifest
+  本轮使用的 RootIntent、GoalSpec、Artifact 版本、摘要和来源引用
+
+SessionContinuityPolicy
+  REUSE_CONTROLLED_SESSION | REBUILD_FROM_MANIFEST | ISOLATED_FRESH_CONTEXT
+```
+
+强顺序的叙事节点可复用受控会话,并行探索则使用隔离上下文。恢复时优先从 ContextManifest 重建,不把 SDK Session 当成唯一业务真相。
+
+---
+
+## 13. Agent SDK 应承担什么
+
+结论:应该使用 Agent SDK,但只把它当作执行底座,不把业务递归语义交给 SDK。
+
+### 13.1 SDK 适合负责
+
+- Agent/模型调用循环;
+- Function Tool、MCP 和业务工具调用;
+- 原子、短时、无独立生命周期的 Agent-as-tool 执行;
+- 结构化输出和 Schema 校验;
+- Session、上下文注入和流式事件;
+- Guardrail、人工审批和工具权限;
+- Trace、Span、Token 和延迟统计;
+- 模型切换、重试和基础错误处理。
+
+SDK 可执行 Guardrail 和工具权限,但权限上限由 Kernel 签发的 CapabilityLease 决定;SDK 不能自行扩大委派、Artifact 写入或预算权限。
+
+### 13.2 Recursive Kernel 必须自己负责
+
+- WorkNode 树及稳定 node_id;
+- 父子 NodeContract;
+- 子节点完成后的父层重入;
+- 直接子节点的所有权;
+- 全树深度、宽度、Token、工具和修订预算;
+- Artifact/Candidate 的提交、比较、合并、停车和回滚;
+- 业务级 checkpoint 和幂等副作用;
+- Capability、Traits、CapabilityLease 与执行配方的解析;
+- 动态执行模式选择;
+- 父合同的最终验收责任。
+
+### 13.3 正式父子节点由 Kernel 管理 Child Run
+
+以 [OpenAI Agents SDK 的编排模型](https://openai.github.io/openai-agents-python/multi_agent/)为例:
+
+- `Handoff`:用于用户会话或当前 Run 的控制权转移,不是父子交付;
+- `Agent-as-tool`:适合短时、同步、可视为原子能力、不需要独立 checkpoint 的叶子调用;
+- `Kernel-managed Child Run`:适合有 `node_id`、合同、预算、独立生命周期、可暂停恢复且可再递归的正式业务节点。
+
+第三版的默认父子机制必须是第三种:
+
+```text
+父 WorkNode
+→ 持久化 ChildSpec / NodeContract / BudgetLease
+→ Kernel 启动独立 Child Run,父节点挂起或继续可并行工作
+→ 子 WorkNode 内部可继续递归、checkpoint、暂停和恢复
+→ ReturnEnvelope 作为事件返回 Kernel
+→ Kernel 校验版本与幂等后恢复父 WorkNode
+```
+
+不要把正式 WorkNode 嵌套在一次父 Agent 的同步调用栈中,也不要依赖 SDK 默认 Handoff 模拟业务父子关系。Agent-as-tool 是局部优化,不是 Recursive Kernel 的生命周期基础。
+
+### 13.4 推荐的抽象接口
+
+```text
+NodeDecisionPort.propose(node_id, parent_state_revision) -> NodeDecision
+KernelCommandPort.apply(command, idempotency_key) -> Events
+ChildRunPort.spawn(child_specs, leases) -> ChildHandles
+ChildReturnInbox.append(ReturnEnvelope, idempotency_key)
+ParentReentryPolicy.decide(ParentReentryContext) -> ParentDecision
+IntegrationPort.apply(IntegrationPlan, expected_parent_state_revision) -> IntegrationResult
+CapabilityResolver.resolve(goal, contract, profile) -> CapabilityPlan
+TraitComposer.compose(behavioral_preferences) -> Traits
+AgentRuntimePort.execute(ExecutionProfile) -> AgentResult
+ArtifactPort.propose(ChangeSet) / commit(IntegrationPlan)
+EvaluationPort.evaluate(artifact_refs) -> EvaluationReports
+CheckpointPort.save(run_state) / restore(checkpoint_ref)
+```
+
+LLM 可以提出 `NodeDecision`,但由确定性的 Kernel 校验权限、Schema、依赖、版本和预算后才能执行。Kernel 依赖上面这些 Port,OpenAI Agents SDK 或其他框架只在 Adapter 层实现 Port;模型负责提出计划,内核负责授权和落地。
+
+---
+
+## 14. 推荐模块边界
+
+```text
+recursive_kernel/
+  work_node
+  goal_spec
+  node_contract
+  node_attempt
+  node_state_machine
+  child_return_inbox
+  parent_reentry
+  integration
+  child_scheduler
+  budget_ledger
+  decision_policy
+  root_completion_gate
+
+ports/
+  agent_runtime_port
+  child_run_port
+  artifact_port
+  evaluation_port
+  checkpoint_port
+  event_port
+
+agent_runtime/
+  sdk_adapters
+  agent_runner_adapter
+  model_router
+  tool_runtime
+  capability_resolver
+  capability_lease
+  trait_resolver
+  context_builder
+  structured_output
+
+script_build_domain/
+  root_intent
+  creative_intent
+  narrative_graph
+  element_candidate
+  composition_candidate
+  domain_capabilities
+  domain_traits
+  domain_evaluators
+
+artifacts/
+  artifact_store
+  candidate_repository
+  changeset
+  impact_proposal
+  lineage
+  invalidation_propagation
+  merge_policy
+  projection
+
+durability/
+  event_store
+  checkpoint_store
+  idempotency
+  recovery
+
+observability/
+  traces
+  node_timeline
+  cost_metrics
+  decision_explanations
+```
+
+依赖方向建议为:
+
+```text
+script_build_domain → recursive_kernel abstractions
+recursive_kernel → ports
+SDK / model / tool adapters → ports
+artifact / durability adapters → ports
+```
+
+箭头表示源码依赖方向:具体 Adapter 实现 Kernel 声明的 Port,Kernel 不引用某个 SDK 的 Agent 类。业务领域也不能反向依赖具体模型 SDK,否则业务递归会再次被框架实现绑死。
+
+---
+
+## 15. 初始预算和护栏建议
+
+以下仅作为第一轮实验默认值,不是最终业务规则:
+
+```text
+max_depth: 5
+max_children_per_node: 4
+max_total_nodes: 40
+max_parallel_children: 4
+max_revisions_per_node: 3
+max_stalls_per_node: 2
+```
+
+预算必须由父向子分配:
+
+- 子节点不能绕过父预算无限扩展;
+- 子节点未用完的预算可以归还父层;
+- 高价值候选可以申请追加预算,但必须说明预期收益;
+- 并行分支应设置最慢节点超时和部分结果收敛策略;
+- 根节点同时维护 Token、金额、时间、工具调用和总节点数预算。
+
+---
+
+## 16. 演进路线
+
+### 阶段 0:先建立评价基线
+
+目标:在改变架构前,明确什么叫“比旧 Workflow 更好”。
+
+建议:
+
+- 使用真实案例 388 重放旧流程;
+- 建立覆盖不同账号、选题类型和素材稀疏度的代表性任务集;
+- 保存旧流程的质量、成本、时长、工具数、修补轮数和人类偏好;
+- 定义目标达成、事实、连贯、原创、账号适配和受众效果 rubric;
+- 确立人工盲评和自动评价的校准方法。
+
+工程门禁:
+
+- 初始 Eval 集至少 20 个真实任务,覆盖不同账号、选题类型、素材稀疏度和质量风险;
+- 旧 Workflow 对每个任务至少重复 3 次,记录质量和成本方差;
+- 核心样本由至少 2 位人工盲评者重叠评分,评审一致性达到预先约定的门槛;
+- 自动评价与人工判断的相关或一致标准在看新系统结果之前锁定。
+
+### 阶段 1:实现最小 Recursive Kernel
+
+目标:先打通真正的父子业务递归,不追求复杂多 Agent。
+
+至少实现:
+
+- GoalSpec / WorkNode / NodeContract / NodeAttempt / ReturnEnvelope;
+- 父节点创建直接子节点;
+- 子节点返回后父层强制重入;
+- CompletionStatus、ReturnReason、ChildDisposition、ParentNextAction 四轴分离,Artifact 处置另行独立;
+- 全树预算;
+- Event、Artifact 和 Trace 分离;
+- checkpoint 与恢复;
+- 旧固定 Workflow 作为一条 `baseline policy` 运行在新内核上。
+
+工程门禁:
+
+- 真实演示 `1 → 1.1 → 1.1.1 → 返回 1.1 → 创建 1.1.2 → 返回 1`,且子节点返回不自动关闭父节点;
+- 先实现顺序 Child Run 和基础 `ALL` 聚合;用两个来自同一 spawn generation 的模拟返回验证:父 `state_revision` 推进后,第二个合法结果仍能幂等进入 inbox;
+- 父合同或 GoalSpec 升版后,旧 Attempt 返回必被 rebase、park 或 reject,不得直接接纳;
+- checkpoint 恢复后不重复执行已成功的副作用,Event 可重放得到同一节点状态,所有预算不越界;
+- 根节点只能通过 RootCompletionGate 关闭,Agent 不能自批 RootIntent 硬约束变更。
+
+### 阶段 2:引入动态创作 Artifact
+
+目标:让目标、脉络和元素不再是固定工序。
+
+至少实现:
+
+- CreativeIntent Candidate;
+- NarrativeGraph;
+- Evidence/Element Candidate;
+- 不可变版本和 lineage;
+- 父层 accept/merge/park/reject;
+- 创作表 Projection;
+- 元素反向触发脉络修改;
+- 评价触发目标或父层重开。
+
+工程门禁:
+
+- 同一选题能产生并回选至少两种不同创作路径,lineage 从最终稿可追溯到 GoalSpec、证据、节点和评价;
+- 真实演示“证据或强元素提交 ImpactProposal → 修改 NarrativeGraph → 下游 Draft 失效 → 重写和重验”;
+- 通过证据先行、元素反推和整体回跳三类非线性架构一致性测试。
+
+### 阶段 3:自适应单/多 Agent 调度
+
+目标:让 Agent 数量由任务结构决定。
+
+至少实现:
+
+- NodeProfile;
+- CapabilityResolver、TraitComposer、CapabilityLease 和 ExecutionProfile;
+- 可并行候选 fan-out/fan-in;
+- 强顺序节点单 Agent 连续执行;
+- Evaluator Router;
+- stall 检测和 revise/pivot/replan/reassign;
+- 异步子节点、取消、超时和部分结果返回。
+- `ALL / QUORUM / FIRST_VALID / DEADLINE / STREAMING` 子节点组策略。
+
+工程门禁:
+
+- 每次动态路由都能解释为什么选单 Agent、多候选或评价循环;
+- 与 `always-single`、`always-multi` 两个简单策略在同一 Eval 上对照,动态路由在质量—成本 Pareto 上有预先约定的收益;
+- 高级子节点组策略均能确定性选择整合、等待、取消或停放其余子节点;
+- 取消、超时、部分返回和恢复执行均不产生重复 Artifact 写入或预算泄漏。
+
+### 阶段 4:离线系统自我改进
+
+目标:从真实 Trace 中改进 traits、Prompt、调度策略和评价标准。
+
+建议参考 [AFlow](https://proceedings.iclr.cc/paper_files/paper/2025/hash/5492ecbce4439401798dcd2c90be94cd-Abstract-Conference.html)这类把 Agent Workflow 优化视为搜索问题的工作,但只允许在离线评测环境中进行:
+
+```text
+收集失败和成功 Trace
+→ 生成策略/Prompt/Trait 候选
+→ 在固定 Eval 集回放
+→ 比较质量、成本和稳定性
+→ 人工或发布门禁批准
+→ 版本化上线
+```
+
+生产中的 Agent 不能同时担任规则修改者、评价者和发布批准者。
+
+工程门禁:新策略必须通过从未参与调参的 holdout Eval,关键质量维度无回归,经人工或发布策略批准后才能上线,且必须能一键回滚到上一个已验证版本。
+
+---
+
+## 17. 验证指标
+
+### 17.1 作品质量
+
+- 创作目标达成率;
+- 事实和来源准确率;
+- 叙事连贯性;
+- 信息增量;
+- 原创性和非模板化程度;
+- 账号一致性;
+- 受众理解、情绪和行动效果;
+- 人类最终偏好。
+
+### 17.2 Agent System 质量
+
+- 父合同一次通过率;
+- 修订后通过率;
+- 目标漂移率;
+- 无效子节点比例;
+- 重复工作比例;
+- 平均/最大递归深度;
+- 节点数、分支数和被 Park 的候选数;
+- stall、replan、reassign 和 escalate 次数;
+- checkpoint 恢复成功率;
+- Artifact 来源可追溯率;
+- Token、金额、延迟和工具调用量;
+- 相对旧 Workflow 的质量收益/成本比。
+
+### 17.3 必须保留的调试证据
+
+对于任一最终段落,应能回答:
+
+```text
+它服务哪个 RootIntent 和局部目标?
+由哪个 WorkNode 产生?
+使用了哪些 traits、工具和上下文?
+引用了哪些证据和元素?
+经历了哪些候选和修订?
+谁评价过?
+父节点为什么接纳它?
+如果重跑,能够从哪个 checkpoint 继续?
+```
+
+---
+
+## 18. 主要风险与控制措施
+
+| 风险 | 表现 | 控制措施 |
+|---|---|---|
+| Agent 爆炸 | 节点无限下钻和 fan-out | 父预算继承、最大深宽、预期收益检查、全树节点上限 |
+| 目标漂移 | 子节点解决了另一个更容易的问题 | RootIntent 不可静默修改、NodeContract、父层验收 |
+| 评价自我确认 | 生成者和评价者互相放水 | 生成/评价分离、硬验证、样例校准、人工抽检 |
+| 风格收敛 | 所有内容被同一总分推成模板 | 多维评价、Pareto 候选、保留中间版本和多样性 |
+| 顺序任务被打碎 | 段落衔接和因果链断裂 | NodeProfile 判断,强顺序任务保持单 Agent 连续上下文 |
+| 上下文污染 | 全量历史塞给每个子 Agent | Context Recipe、Artifact 引用、按需检索、阶段摘要 |
+| 子结果传话损失 | 多层摘要丢失原始细节 | 子 Agent 直接写 Artifact,ReturnEnvelope 只传引用和关键结论 |
+| 无限修订 | 复杂度上升但质量不再提升 | 边际收益、stall、revision budget 和停止条件 |
+| 并发冲突 | 多子节点同时覆盖同一产物 | 不可变候选、Patch 提交、父层单点合并、幂等键 |
+| SDK 绑架业务 | 业务树受限于某框架的 handoff/goal 语义 | 自研 Recursive Kernel,SDK 只做执行适配 |
+| 线上自修改失控 | Agent 修改 Prompt 后自行宣布成功 | 离线优化、固定 Eval、人工/发布门禁、版本回滚 |
+
+---
+
+## 19. 关键架构决策
+
+1. **固定递归协议,动态生成业务计划。**
+2. **RootIntent 硬约束不可被 Agent 自批改动,局部 GoalSpec 可在父层授权下动态创建和升版。**
+3. **父节点拥有直接子节点;子节点只能建议兄弟节点。**
+4. **子节点完成不自动完成父节点;父层重入是强制过程。**
+5. **CompletionStatus、ReturnReason、ChildDisposition 和 ParentNextAction 是四个正交轴。**
+6. **语义来源版本与并发 CAS state_revision 必须分开,合法的后返回子结果不能被误杀。**
+7. **GoalSpec 与 Agent 解耦,通过 Capability、NodeProfile、Traits、CapabilityLease 和 ExecutionProfile 临时组装。**
+8. **Trait 是版本化软性行为策略,不是固定角色、权限包或 Prompt 形容词。**
+9. **目标、脉络、元素和文稿都是版本化 Artifact,不是固定阶段。**
+10. **WorkNode Tree、Artifact Graph 和 Execution Trace 必须分开。**
+11. **并行只用于可分解、高价值的探索;顺序创作保持连续上下文。**
+12. **生成者和评价者分离,硬验证优先于模型自评。**
+13. **多维评价和候选保留优先于单一总分和覆盖式修改。**
+14. **Agent SDK 是执行适配层,Recursive Kernel 才是业务控制层,正式子节点必须是 Kernel-managed Child Run。**
+15. **运行时自我修正与离线系统自我改进严格分开。**
+
+---
+
+## 20. 参考资料
+
+- [Anthropic: How we built our multi-agent research system](https://www.anthropic.com/engineering/multi-agent-research-system)
+- [Anthropic: Harness design for long-running application development](https://www.anthropic.com/engineering/harness-design-long-running-apps)
+- [Anthropic: Effective context engineering for AI agents](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)
+- [OpenAI: An open-source spec for Codex orchestration — Symphony](https://openai.com/index/open-source-codex-orchestration-symphony/)
+- [OpenAI Agents SDK: Agent orchestration](https://openai.github.io/openai-agents-python/multi_agent/)
+- [Google Research: Towards a science of scaling agent systems](https://research.google/blog/towards-a-science-of-scaling-agent-systems-when-and-why-agent-systems-work/)
+- [Google Research: AI Co-Scientist](https://research.google/blog/accelerating-scientific-breakthroughs-with-an-ai-co-scientist/)
+- [WriteHERE: Beyond Outlining](https://arxiv.org/abs/2503.08275)
+- [WriteHERE GitHub](https://github.com/principia-ai/WriteHERE)
+- [ROMA: Recursive Open Meta-Agent Framework](https://arxiv.org/abs/2602.01848)
+- [ROMA GitHub](https://github.com/sentient-agi/ROMA)
+- [MASC: Metacognitive Self-Correction for Multi-Agent Systems](https://arxiv.org/abs/2510.14319)
+- [Recursive Multi-Agent Systems](https://arxiv.org/abs/2604.25917)
+- [Recursive Self-Improvement in AI Survey](https://arxiv.org/abs/2607.07663)
+- [AFlow: Automating Agentic Workflow Generation](https://proceedings.iclr.cc/paper_files/paper/2025/hash/5492ecbce4439401798dcd2c90be94cd-Abstract-Conference.html)
+
+---
+
+## 21. 最终建议
+
+第一阶段不要先讨论要创建多少个 Agent、每个 Agent 叫什么,也不要先把旧流程拆成更多角色。
+
+应先把以下四件事做对:
+
+```text
+1. WorkNode 与父子责任
+2. GoalSpec、NodeContract、NodeAttempt 与 ReturnEnvelope
+3. 父层重入、正交控制协议和 Kernel-managed Child Run
+4. Artifact 候选、版本、失效传播和评价闭环
+```
+
+这四件事成立以后,Agent、模型、工具、traits 和 SDK 都可以成为可替换的执行资源;否则即使增加很多 Agent,系统仍会回到“一个主编排器控制固定创作表 Workflow”的旧形态。

+ 15 - 0
agent/.env.template

@@ -0,0 +1,15 @@
+KNOWHUB_API=http://43.106.118.91:9999
+BROWSER_USE_API_KEY=
+
+# qwen模型
+QWEN_BASE_URL=
+QWEN_API_KEY=
+
+# sonnet模型
+OPEN_ROUTER_API_KEY=
+# postgreSQL database配置
+KNOWHUB_DB=gp-t4n72471pkmt4b9q7o-master.gpdbmaster.singapore.rds.aliyuncs.com
+KNOWHUB_PORT=5432
+KNOWHUB_USER=aiddit_aigc
+KNOWHUB_PASSWORD=%a&&yqNxg^V1$toJ*WOa^-b^X=QJ
+KNOWHUB_DB_NAME=knowhub

+ 113 - 0
agent/.gitignore

@@ -0,0 +1,113 @@
+# API-KEY
+.env
+.mcp.json
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+/lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual environments
+venv/
+ENV/
+env/
+.venv/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+CLAUDE.md
+# 默认忽略 .claude/ 下所有子项(IDE 状态、session 等)
+.claude/*
+# 但保留项目级 Claude Code skill(版本化)
+!.claude/skills/
+
+# Testing
+.pytest_cache/
+.coverage
+htmlcov/
+.tox/
+.nox/
+scratch/
+
+# Misc
+.DS_Store
+Thumbs.db
+
+.env
+debug.log
+info.log
+.cache
+output
+
+
+
+# Debug output
+.trace/
+.trace_test/
+.trace_test2/
+examples/**/output*/
+examples/**/test*/
+outputs/
+
+frontend/htmlTemplate/mock_data
+frontend/htmlTemplate/api_data/
+frontend/htmlTemplate/ws_data/
+frontend/react-template/yarn.lock
+frontend/react-template/node_modules/
+
+# Feishu 运行时聊天记录(自动维护,包含联系人 PII)
+agent/tools/builtin/feishu/chat_history/
+
+# Runtime artifacts (one-off scripts, data, cache)
+.image_cache/
+cache/
+pending_uploads/
+knowledge/
+knowledge_batch_*.json
+knowledge-*.json
+tools/image_gen/
+tools/upload/
+
+# data
+knowhub/knowhub.db
+knowhub/knowhub.db-shm
+knowhub/knowhub.db-wal
+examples/archive/*
+examples/research/
+examples/downloader/
+examples/production_restore/features/
+# im-server data
+data/
+
+# Vendor (non-submodule)
+vendor/browser-use/
+
+# im-client data
+data/.mcp.json
+
+# batch files
+*.bat
+HOW_IT_RUNS.md
+PROJECT_STRUCTURE.md
+项目导读.md

+ 3 - 0
agent/.gitmodules

@@ -0,0 +1,3 @@
+[submodule "vendor/opencode"]
+	path = vendor/opencode
+	url = https://github.com/anomalyco/opencode.git

+ 385 - 0
agent/agent/README.md

@@ -0,0 +1,385 @@
+# 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(领域知识注入)
+- 子 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)
+│
+├── skill/                 # 技能系统
+│   ├── models.py          # Skill
+│   ├── 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
+from agent.trace import FileSystemTraceStore, Trace, Message
+from agent.llm import create_qwen_llm_call
+
+runner = AgentRunner(
+    llm_call=create_qwen_llm_call(model="qwen3.5-plus"),
+    trace_store=FileSystemTraceStore(base_path=".trace"),
+    skills_dir="./skills",      # 项目 skills 目录(可选)
+)
+
+async for item in runner.run(
+    messages=[{"role": "user", "content": "分析项目架构"}],
+    config=RunConfig(model="qwen3.5-plus"),
+):
+    if isinstance(item, Trace):
+        print(f"Trace: {item.trace_id}")
+    elif isinstance(item, Message) and item.role == "assistant":
+        print(item.content)
+```
+
+### RunConfig 关键参数
+
+```python
+RunConfig(
+    model="qwen3.5-plus",
+    temperature=0.3,
+    max_iterations=200,
+    agent_type="default",           # Agent 预设(对应 presets.json)
+    name="任务名称",
+    tools=None,                      # 精确指定工具列表(优先于 tool_groups)
+    tool_groups=["core", "browser"], # 工具分组白名单,默认 ["core"]
+    goal_compression="on_overflow", # Goal 压缩:"none" / "on_complete" / "on_overflow"
+    knowledge=KnowledgeConfig(...), # 知识提取配置
+)
+```
+
+### 续跑已有 Trace
+
+```python
+config = RunConfig(model="qwen3.5-plus", trace_id="existing-trace-id")
+async for item in runner.run(messages=[{"role": "user", "content": "继续"}], config=config):
+    ...
+```
+
+### Skill 指定注入(设计中)
+
+同一个长期续跑的 agent,不同调用可以注入不同 skill:
+
+```python
+# 不同调用场景注入不同策略
+async for item in runner.run(
+    messages=[...], config=config,
+    inject_skills=["ask_strategy"],     # 本次需要的 skill
+    skill_recency_threshold=10,         # 最近 N 条消息内有就不重复注入
+):
+    ...
+```
+
+详见 [Context 管理 § Skill 指定注入](./docs/context-management.md#3-skill-指定注入设计中未实现)
+
+### 自定义工具
+
+```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}")
+```
+
+### 完整示例
+
+参考 `examples/research/` — 一个完整的调研 agent 项目:
+
+```
+examples/research/
+├── run.py              # 入口(含交互控制、续跑、暂停)
+├── config.py           # RunConfig + KnowledgeConfig 配置
+├── presets.json        # Agent 预设(工具权限、skills 过滤)
+├── requirement.prompt  # 任务 prompt($system$ + $user$ 段)
+├── skills/             # 项目自定义 skills
+└── tools/              # 项目自定义工具
+```
+
+---
+
+## 文档
+
+| 文档 | 内容 |
+|------|------|
+| [架构设计](./docs/architecture.md) | 框架完整架构 |
+| [Context 管理](./docs/context-management.md) | 注入机制、压缩策略、Skill 指定注入 |
+| [工具系统](./docs/tools.md) | 工具定义、注册、参数注入 |
+| [Skills 指南](./docs/skills.md) | Skill 分类、编写、加载 |
+| [Prompt 规范](./docs/prompt-guidelines.md) | Prompt 撰写原则 |
+| [Trace API](./docs/trace-api.md) | REST API 和 WebSocket 接口 |
+| [设计决策](./docs/decisions.md) | 架构决策记录 |
+
+---
+
+## 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`
+
+---
+
+## Claude Code 集成
+
+本仓库自带项目级 Claude Code skill:[`.claude/skills/agent/`](../.claude/skills/agent/)
+
+- `SKILL.md` — skill 元数据(description 给 Claude Code 路由用)
+- `invoke.py` — 20 行薄脚本,`from agent import invoke_agent` 然后透传命令行参数
+
+### 在本仓库使用(自动激活)
+
+Claude Code 在本仓库目录下启动时自动加载 `.claude/skills/` 下的 skill。前提是 `cyber-agent` 包可 import:
+
+```bash
+pip install -e .
+```
+
+### 在其他项目使用
+
+把 skill symlink 或复制到用户级目录:
+
+```bash
+ln -s "$(pwd)/.claude/skills/agent" ~/.claude/skills/agent
+```
+
+之后任何 Claude Code session(无论 cwd 在哪)都能调用。
+
+### SDK 入口
+
+Claude Code skill 脚本最终调用 `agent/client.py::invoke_agent`,该函数也是公开 SDK 供任何 Python 代码复用:
+
+```python
+from agent import invoke_agent
+
+# 远端:查询知识库
+result = await invoke_agent(
+    agent_type="remote_librarian",
+    task="ControlNet 相关的工具知识",
+    skills=["ask_strategy"],
+)
+
+# 本地:在项目目录下跑 agent(约定 project_root/config.py 定义 RUN_CONFIG)
+result = await invoke_agent(
+    agent_type="main",
+    task="...",
+    project_root="./examples/research",
+)
+```
+
+路由规则:`agent_type.startswith("remote_")` → HTTP 调用 KnowHub `/api/agent`;否则在当前进程起 `AgentRunner`。
+
+**实现**:`agent/client.py:invoke_agent` / `.claude/skills/agent/invoke.py`
+
+---
+
+## 附录:工具分组
+
+通过 `RunConfig(tool_groups=[...])` 控制 Agent 可用的工具范围。默认仅 `["core"]`。每个工具在 `@tool(groups=[...])` 中声明分组,支持多标签。
+
+机制详见 [docs/tools.md § 工具分组](./docs/tools.md#工具分组)。
+
+### core — 基础能力(13)
+
+| 工具 | 说明 |
+|------|------|
+| `read_file` | 读取单个文件(文本/图片/PDF) |
+| `read_images` | 批量读图 + 网格拼图 |
+| `edit_file` | 编辑文件 |
+| `write_file` | 写入文件 |
+| `glob_files` | 文件模式匹配 |
+| `grep_content` | 内容搜索(正则) |
+| `bash_command` | 执行 shell 命令 |
+| `skill` | 调用 skill |
+| `list_skills` | 列出可用 skill |
+| `agent` | 创建子 Agent |
+| `evaluate` | 评估执行结果 |
+| `goal` | 目标/计划管理 |
+| `get_current_context` | 获取当前执行上下文 |
+
+### browser — 浏览器自动化(14)
+
+| 工具 | 说明 |
+|------|------|
+| `browser_navigate` | 导航到 URL |
+| `browser_search` | 搜索引擎搜索 |
+| `browser_back` | 返回上一页 |
+| `browser_interact` | 元素交互(click/type/send_keys/upload/dropdown) |
+| `browser_scroll` | 滚动页面 |
+| `browser_screenshot` | 截图(可带元素编号标注) |
+| `browser_elements` | 获取可交互元素列表 |
+| `browser_read` | 读取页面内容(html/find/long) |
+| `browser_extract` | LLM 驱动的结构化数据提取 |
+| `browser_tabs` | 标签页管理(switch/close) |
+| `browser_cookies` | Cookie/登录态(load/export/ensure_login) |
+| `browser_wait` | 等待(定时/等用户操作) |
+| `browser_js` | 执行 JavaScript |
+| `browser_download` | 下载文件 |
+
+### content — 内容搜索(6)
+
+| 工具 | 说明 |
+|------|------|
+| `content_platforms` | 列出/查询平台及参数(支持模糊匹配) |
+| `content_search` | 跨 11 平台搜索(小红书/B站/知乎/GitHub/YouTube/X 等) |
+| `content_detail` | 查看内容详情(从搜索缓存按索引取) |
+| `content_suggest` | 搜索关键词补全建议 |
+| `extract_video_clip` | YouTube 视频片段截取 |
+| `import_content` | 批量导入文章到 CMS |
+
+### 远端 Agent — 统一通过 `agent` 工具
+
+知识查询、知识上传、深度调研等远端服务**不再作为独立工具**暴露,全部通过统一的 `agent` 工具调用。Librarian 一个 Agent 多种模式,通过 `skills` 参数切换:
+
+| 用法 | 说明 |
+|------|------|
+| `agent(agent_type="remote_librarian", task=..., skills=["ask_strategy"])` | 知识查询,返回带引用的整合回答 |
+| `agent(agent_type="remote_librarian", task=json.dumps({...}), skills=["upload_strategy"])` | 知识上传(task 为 JSON 字符串) |
+| `agent(agent_type="remote_research", task=...)` | 深度调研 |
+
+详见 [tools.md § Agent 工具](./docs/tools.md#agent-工具)。
+
+### toolhub — 远程工具库(3)
+
+| 工具 | 说明 |
+|------|------|
+| `toolhub_health` | 检查远程工具库状态 |
+| `toolhub_search` | 搜索远程 AI 工具 |
+| `toolhub_call` | 调用远程工具(图片参数支持本地路径) |
+
+### feishu — 飞书(4)
+
+| 工具 | 说明 |
+|------|------|
+| `feishu_get_contact_list` | 获取联系人列表 |
+| `feishu_send_message_to_contact` | 给联系人发消息 |
+| `feishu_get_contact_replies` | 获取联系人回复 |
+| `feishu_get_chat_history` | 获取聊天历史 |
+
+### im — IM 通信(8)
+
+| 工具 | 说明 |
+|------|------|
+| `im_setup` | 初始化 IM 连接 |
+| `im_send_message` | 发送消息 |
+| `im_receive_messages` | 接收消息 |
+| `im_check_notification` | 检查通知 |
+| `im_get_contacts` | 获取联系人 |
+| `im_get_chat_history` | 获取聊天历史 |
+| `im_open_window` | 打开 IM 窗口 |
+| `im_close_window` | 关闭 IM 窗口 |
+
+### resource — 资源查询(2)
+
+| 工具 | 说明 |
+|------|------|
+| `resource_list_tools` | 列出资源工具 |
+| `resource_get_tool` | 获取工具详情 |
+
+### knowledge_internal — 知识库内部操作(14)
+
+> 仅供 Librarian Agent 内部使用,普通 Agent 不可见。通过 `tools=[...]` 精确指定访问。
+
+| 工具 | 说明 |
+|------|------|
+| `knowledge_search` | 知识检索(语义 + 精排) |
+| `knowledge_save` | 保存知识条目 |
+| `knowledge_list` | 列出知识 |
+| `knowledge_update` | 更新知识反馈 |
+| `knowledge_batch_update` | 批量更新反馈 |
+| `knowledge_slim` | 知识瘦身 |
+| `resource_save` | 保存资源 |
+| `resource_get` | 获取资源 |
+| `tool_search` | 搜索工具记录 |
+| `tool_list` | 列出工具记录 |
+| `capability_search` | 搜索能力记录 |
+| `capability_list` | 列出能力记录 |
+| `requirement_search` | 搜索需求记录 |
+| `requirement_list` | 列出需求记录 |

+ 67 - 0
agent/agent/__init__.py

@@ -0,0 +1,67 @@
+"""
+Reson Agent - 模块化、可扩展的 Agent 框架
+
+核心导出:
+- AgentRunner: Agent 执行引擎
+- RunConfig: 运行配置
+- Trace, Message, Goal: 执行追踪
+- Skill: 技能模型
+- tool: 工具装饰器
+- TraceStore: 存储接口
+"""
+
+# 核心引擎
+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.skill.models import Skill
+
+# 工具系统
+from agent.tools import tool, ToolRegistry, get_tool_registry
+from agent.tools.models import ToolResult, ToolContext
+
+# SDK 公开入口:统一调用 remote / 本地 Agent
+from agent.client import invoke_agent
+
+__version__ = "0.3.0"
+
+__all__ = [
+    # Core
+    "AgentRunner",
+    "CallResult",
+    "RunConfig",
+    "AgentPreset",
+    "AGENT_PRESETS",
+    "get_preset",
+    # Trace
+    "Trace",
+    "Message",
+    "ChatMessage",
+    "Messages",
+    "MessageContent",
+    "Step",
+    "StepType",
+    "StepStatus",
+    "Goal",
+    "GoalTree",
+    "GoalStatus",
+    "TraceStore",
+    "FileSystemTraceStore",
+    # Skill
+    "Skill",
+    # Tools
+    "tool",
+    "ToolRegistry",
+    "get_tool_registry",
+    "ToolResult",
+    "ToolContext",
+    # SDK
+    "invoke_agent",
+]

+ 11 - 0
agent/agent/cli/__init__.py

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

+ 267 - 0
agent/agent/cli/extraction_review.py

@@ -0,0 +1,267 @@
+"""
+提取审核交互式 CLI
+
+用途
+----
+反思侧分支产出的知识条目默认写为 cognition_log: type="extraction_pending",
+不会直接上传到 KnowHub。本 CLI 提供人工审核 + 批量提交入口。
+
+两种入口(共享同一核心逻辑,见 agent/trace/extraction_review.py):
+- 独立脚本:python -m agent.cli.extraction_review --trace <TRACE_ID> [--list|--review|--commit]
+- interactive.py 菜单项 8/9(见 agent/cli/interactive.py)
+
+用法示例
+--------
+# 查看当前 trace 的所有未审核条目
+python -m agent.cli.extraction_review --trace abc-123 --list
+
+# 交互式逐条审核
+python -m agent.cli.extraction_review --trace abc-123 --review
+
+# 把已 approved 的条目批量提交到 KnowHub
+python -m agent.cli.extraction_review --trace abc-123 --commit
+
+# 一条龙:review 完直接 commit
+python -m agent.cli.extraction_review --trace abc-123
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import sys
+from pathlib import Path
+from typing import List, Optional
+
+from agent.trace.store import FileSystemTraceStore
+from agent.trace.extraction_review import (
+    PendingExtraction,
+    CommitReport,
+    list_pending,
+    review_one,
+    commit_approved,
+)
+
+
+# ===== 打印工具 =====
+
+_SEP = "─" * 60
+
+
+def _format_payload(payload: dict, max_content: int = 400) -> str:
+    task = payload.get("task", "")
+    content = payload.get("content", "")
+    types = payload.get("types", [])
+    tags = payload.get("tags", {})
+    score = payload.get("score", 0)
+    resource_ids = payload.get("resource_ids", [])
+
+    if len(content) > max_content:
+        content = content[:max_content] + "…(truncated)"
+
+    lines = [
+        f"task:  {task}",
+        f"types: {types}   score: {score}",
+    ]
+    if tags:
+        lines.append(f"tags:  {tags}")
+    if resource_ids:
+        lines.append(f"resources: {resource_ids}")
+    lines.append("")
+    lines.append(content)
+    return "\n".join(lines)
+
+
+def _print_pending(p: PendingExtraction, index: int, total: int) -> None:
+    state = ""
+    if p.committed:
+        state = " [已提交]"
+    elif p.reviewed:
+        state = f" [已审核: {p.decision}]"
+    print()
+    print(f"[{index}/{total}] {p.extraction_id}{state}")
+    print(_SEP)
+    print(_format_payload(p.payload))
+    print(_SEP)
+
+
+def _print_report(report: CommitReport) -> None:
+    print()
+    print("=" * 60)
+    print("提交结果")
+    print("=" * 60)
+    print(f"✅ 成功: {len(report.committed)}")
+    for eid, kid in zip(report.committed, report.knowledge_ids):
+        print(f"   - {eid} → knowledge_id={kid}")
+    if report.failed:
+        print(f"❌ 失败: {len(report.failed)}")
+        for item in report.failed:
+            print(f"   - {item['extraction_id']}: {item['error']}")
+    if report.skipped:
+        print(f"⏭  跳过: {len(report.skipped)}(未 approved 或已提交)")
+    print("=" * 60)
+
+
+# ===== 交互式编辑 =====
+
+def _prompt_edit(payload: dict) -> Optional[dict]:
+    """进入交互式文本编辑模式,返回修改后的 payload(None 表示取消)。
+
+    初版只支持改 task/content/score/tags(最常用字段)。
+    """
+    print("\n编辑模式(空行回车保留原值)")
+    task = input(f"task   [{payload.get('task', '')[:50]}]: ").strip()
+    content_default = payload.get("content", "")
+    print(f"content 当前:\n{content_default}\n")
+    print("输入新 content(单行回车保留原值;多行请在末尾输入 `.` 单独成行结束):")
+    content = _read_multiline_or_keep(content_default)
+    score_raw = input(f"score  [{payload.get('score', 3)}]: ").strip()
+    tags_raw = input(f"tags JSON  [{json.dumps(payload.get('tags', {}), ensure_ascii=False)}]: ").strip()
+
+    new_payload = dict(payload)
+    if task:
+        new_payload["task"] = task
+    if content is not None:
+        new_payload["content"] = content
+    if score_raw:
+        try:
+            new_payload["score"] = int(score_raw)
+        except ValueError:
+            print(f"⚠ score 不是整数,保留原值 {payload.get('score', 3)}")
+    if tags_raw:
+        try:
+            new_payload["tags"] = json.loads(tags_raw)
+        except json.JSONDecodeError as e:
+            print(f"⚠ tags 不是合法 JSON({e}),保留原值")
+
+    confirm = input("\n保存修改?[y/N]: ").strip().lower()
+    if confirm != "y":
+        return None
+    return new_payload
+
+
+def _read_multiline_or_keep(default: str) -> Optional[str]:
+    """单行输入则直接返回(空行表示保留默认);
+    如果输入 `<<` 则进入多行模式,直到 `.` 单独成行结束。"""
+    first = input("> ")
+    if not first.strip():
+        return None
+    if first.strip() != "<<":
+        return first
+    lines = []
+    while True:
+        line = input()
+        if line.strip() == ".":
+            break
+        lines.append(line)
+    return "\n".join(lines)
+
+
+# ===== 三种命令 =====
+
+async def cmd_list(store: FileSystemTraceStore, trace_id: str, show_all: bool) -> int:
+    pendings = await list_pending(store, trace_id, include_reviewed=show_all)
+    if not pendings:
+        msg = "没有" + ("任何提取记录" if show_all else "待审核的提取条目")
+        print(f"trace {trace_id}: {msg}")
+        return 0
+    print(f"trace {trace_id}: 共 {len(pendings)} 条{'' if show_all else '待审核'}")
+    for i, p in enumerate(pendings, 1):
+        _print_pending(p, i, len(pendings))
+    return 0
+
+
+async def cmd_review(store: FileSystemTraceStore, trace_id: str) -> int:
+    pendings = await list_pending(store, trace_id, include_reviewed=False)
+    if not pendings:
+        print(f"trace {trace_id}: 没有待审核的提取条目")
+        return 0
+
+    print(f"trace {trace_id}: 开始审核 {len(pendings)} 条")
+    for i, p in enumerate(pendings, 1):
+        _print_pending(p, i, len(pendings))
+        while True:
+            choice = input("[a]pprove / [e]dit / [d]iscard / [s]kip / [q]uit: ").strip().lower()
+            if choice in ("a", "approve"):
+                await review_one(store, trace_id, p.extraction_id, "approve")
+                print(f"✓ {p.extraction_id} approved")
+                break
+            elif choice in ("d", "discard"):
+                await review_one(store, trace_id, p.extraction_id, "discard")
+                print(f"✗ {p.extraction_id} discarded")
+                break
+            elif choice in ("s", "skip"):
+                print(f"⏭ {p.extraction_id} skipped(保留为 pending)")
+                break
+            elif choice in ("q", "quit"):
+                print("退出审核")
+                return 0
+            elif choice in ("e", "edit"):
+                edited = _prompt_edit(p.payload)
+                if edited is None:
+                    print("取消编辑,请重选")
+                    continue
+                await review_one(store, trace_id, p.extraction_id, "edit", edited_payload=edited)
+                print(f"✎ {p.extraction_id} edited & approved")
+                break
+            else:
+                print("无效选项,请输入 a/e/d/s/q")
+    return 0
+
+
+async def cmd_commit(store: FileSystemTraceStore, trace_id: str) -> int:
+    report = await commit_approved(store, trace_id)
+    _print_report(report)
+    return 0 if not report.failed else 1
+
+
+# ===== argparse 入口 =====
+
+def build_parser() -> argparse.ArgumentParser:
+    p = argparse.ArgumentParser(
+        prog="python -m agent.cli.extraction_review",
+        description="审核并提交反思侧分支暂存的待审核知识条目。",
+    )
+    p.add_argument("--trace", required=True, help="Trace ID")
+    p.add_argument("--base-path", default=".trace", help="TraceStore 根目录(默认 .trace)")
+    group = p.add_mutually_exclusive_group()
+    group.add_argument("--list", action="store_true", help="仅列出未审核条目")
+    group.add_argument("--list-all", action="store_true", help="列出全部条目(含已审核/已提交)")
+    group.add_argument("--review", action="store_true", help="进入交互式审核(不自动 commit)")
+    group.add_argument("--commit", action="store_true", help="仅批量提交已 approved 的条目")
+    return p
+
+
+async def _main_async(args: argparse.Namespace) -> int:
+    if not Path(args.base_path).exists():
+        print(f"❌ TraceStore 根目录不存在: {args.base_path}", file=sys.stderr)
+        return 2
+    store = FileSystemTraceStore(base_path=args.base_path)
+
+    if args.list or args.list_all:
+        return await cmd_list(store, args.trace, show_all=args.list_all)
+    if args.review:
+        return await cmd_review(store, args.trace)
+    if args.commit:
+        return await cmd_commit(store, args.trace)
+
+    # 默认:review 完紧接着 commit
+    rc = await cmd_review(store, args.trace)
+    if rc != 0:
+        return rc
+    print()
+    confirm = input("现在把已 approved 的条目提交到 KnowHub?[Y/n]: ").strip().lower()
+    if confirm in ("", "y", "yes"):
+        return await cmd_commit(store, args.trace)
+    print("未提交。需要时运行 `--commit` 子命令。")
+    return 0
+
+
+def main() -> int:
+    args = build_parser().parse_args()
+    return asyncio.run(_main_async(args))
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 504 - 0
agent/agent/cli/interactive.py

@@ -0,0 +1,504 @@
+"""
+交互式控制器
+
+提供暂停/继续、交互式菜单、经验总结等功能。
+"""
+
+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
+from agent.trace.models import Message, Trace
+
+
+# ===== 非阻塞 stdin 检测 =====
+
+if sys.platform == 'win32':
+    import msvcrt
+
+
+def check_stdin() -> Optional[str]:
+    """
+    跨平台非阻塞检查 stdin 输入。
+
+    支持终端输入和控制文件(用于后台进程控制)。
+
+    优先级:
+    1. 检查控制文件 .agent_control(用于后台进程)
+    2. 检查终端/管道输入
+
+    Returns:
+        'pause' | 'quit' | None
+    """
+    # 1. 优先检查控制文件(用于后台进程控制)
+    control_file = Path.cwd() / ".agent_control"
+    if control_file.exists():
+        try:
+            cmd = control_file.read_text(encoding='utf-8').strip().lower()
+            control_file.unlink()  # 读取后立即删除
+            if cmd in ('p', 'pause'):
+                return 'pause'
+            if cmd in ('q', 'quit'):
+                return 'quit'
+        except Exception:
+            pass
+
+    # 2. 检查终端/管道输入
+    if sys.platform == 'win32':
+        # Windows: 先检查是否是终端
+        if sys.stdin.isatty():
+            # 终端模式:使用 msvcrt
+            if msvcrt.kbhit():
+                ch = msvcrt.getwch().lower()
+                if ch == 'p':
+                    return 'pause'
+                if ch == 'q':
+                    return 'quit'
+        else:
+            # 管道模式:尝试非阻塞读取
+            try:
+                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'
+            except Exception:
+                pass
+        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("  7. 停止执行")
+        print("  8. 审核待提交知识(review pending extractions)")
+        print("  9. 提交已审核知识到 KnowHub(commit approved)")
+        print("=" * 60)
+
+        while True:
+            choice = input("请输入选项 (1-9): ").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":
+                # 从指定消息续跑
+                await self.resume_from_message(trace_id)
+                return {"action": "stop"}  # 返回 stop,让外层循环退出
+
+            elif choice == "6":
+                # 继续执行
+                print("\n继续执行...")
+                return {"action": "continue"}
+
+            elif choice == "7":
+                # 停止执行
+                print("\n停止执行...")
+                return {"action": "stop"}
+
+            elif choice == "8":
+                # 审核待提交知识(复用 agent/cli/extraction_review.py 的交互式 review)
+                from agent.cli.extraction_review import cmd_review
+                await cmd_review(self.store, trace_id)
+                continue
+
+            elif choice == "9":
+                # 提交已审核知识到 KnowHub
+                from agent.cli.extraction_review import cmd_commit
+                await cmd_commit(self.store, trace_id)
+                continue
+
+            else:
+                print("无效选项,请重新输入")
+
+    async def perform_reflection(
+        self,
+        trace_id: str,
+        focus: str = ""
+    ):
+        """
+        执行经验总结
+
+        通过调用 API 端点触发反思侧分支。
+
+        Args:
+            trace_id: Trace ID
+            focus: 反思重点(可选)
+        """
+        import httpx
+
+        print("正在启动反思任务...")
+
+        try:
+            # 调用 reflect API 端点
+            async with httpx.AsyncClient() as client:
+                payload = {}
+                if focus:
+                    payload["focus"] = focus
+
+                response = await client.post(
+                    f"http://localhost:8000/api/traces/{trace_id}/reflect",
+                    json=payload,
+                    timeout=10.0
+                )
+                response.raise_for_status()
+                result = response.json()
+
+            print(f"✅ 反思任务已启动: {result.get('message', '')}")
+            print("提示:可通过 WebSocket 监听实时进度")
+
+        except httpx.HTTPError as e:
+            print(f"❌ 反思任务启动失败: {e}")
+        except Exception as e:
+            print(f"❌ 发生错误: {e}")
+
+    async def manual_compact(self, trace_id: str):
+        """
+        手动压缩上下文
+
+        通过调用 API 端点触发压缩侧分支。
+
+        Args:
+            trace_id: Trace ID
+        """
+        import httpx
+
+        print("\n正在启动上下文压缩任务...")
+
+        try:
+            # 调用 compact API 端点
+            async with httpx.AsyncClient() as client:
+                response = await client.post(
+                    f"http://localhost:8000/api/traces/{trace_id}/compact",
+                    timeout=10.0
+                )
+                response.raise_for_status()
+                result = response.json()
+
+            print(f"✅ 压缩任务已启动: {result.get('message', '')}")
+            print("提示:可通过 WebSocket 监听实时进度")
+
+        except httpx.HTTPError as e:
+            print(f"❌ 压缩任务启动失败: {e}")
+        except Exception as e:
+            print(f"❌ 发生错误: {e}")
+
+    async def resume_from_message(self, trace_id: str):
+        """
+        从指定消息续跑
+
+        让用户选择一条消息,然后从该消息之后重新执行。
+
+        Args:
+            trace_id: Trace ID
+        """
+        print("\n正在加载消息列表...")
+
+        # 1. 获取所有消息
+        messages = await self.store.get_trace_messages(trace_id)
+        if not messages:
+            print("❌ 没有找到任何消息")
+            return
+
+        # 2. 显示消息列表(只显示 user 和 assistant 消息)
+        display_messages = [
+            msg for msg in messages
+            if msg.role in ("user", "assistant")
+        ]
+
+        if not display_messages:
+            print("❌ 没有可选择的消息")
+            return
+
+        print("\n" + "=" * 60)
+        print("  消息列表")
+        print("=" * 60)
+
+        for i, msg in enumerate(display_messages, 1):
+            role_label = "👤 User" if msg.role == "user" else "🤖 Assistant"
+            content_preview = self._get_content_preview(msg.content)
+            print(f"{i}. [{msg.sequence:04d}] {role_label}: {content_preview}")
+
+        print("=" * 60)
+
+        # 3. 让用户选择
+        while True:
+            choice = input(f"\n请选择消息编号 (1-{len(display_messages)}),或输入 'c' 取消: ").strip()
+
+            if choice.lower() == 'c':
+                print("已取消")
+                return
+
+            try:
+                idx = int(choice) - 1
+                if 0 <= idx < len(display_messages):
+                    selected_msg = display_messages[idx]
+                    break
+                else:
+                    print(f"无效编号,请输入 1-{len(display_messages)}")
+            except ValueError:
+                print("无效输入,请输入数字或 'c'")
+
+        # 4. 确认是否重新生成最后一条消息
+        regenerate_last = False
+        if selected_msg.role == "assistant":
+            confirm = input("\n是否重新生成这条 Assistant 消息?(y/n): ").strip().lower()
+            regenerate_last = (confirm == 'y')
+
+        # 5. 询问是否插入干预消息
+        insert_message = input("\n是否插入一条干预消息?(y/n): ").strip().lower()
+        intervention_msg = None
+        if insert_message == 'y':
+            intervention_msg = read_multiline()
+            if not intervention_msg.strip():
+                print("⚠️  干预消息为空,将不插入")
+                intervention_msg = None
+
+        # 6. 调用 runner.run() 续跑
+        print(f"\n从消息 {selected_msg.sequence:04d} 之后续跑...")
+        if regenerate_last:
+            print("将重新生成最后一条 Assistant 消息")
+
+        try:
+            # 加载 trace
+            trace = await self.store.get_trace(trace_id)
+            if not trace:
+                print("❌ Trace 不存在")
+                return
+
+            # 计算 after_sequence:如果需要重新生成,回退到上一条消息
+            after_seq = selected_msg.sequence
+            if regenerate_last and selected_msg.role == "assistant":
+                # 找到这条 assistant 消息的 parent_sequence
+                after_seq = selected_msg.parent_sequence or (selected_msg.sequence - 1)
+
+            # 导入 RunConfig
+            from agent.core.runner import RunConfig
+
+            # 构建运行配置
+            config = RunConfig(
+                trace_id=trace_id,
+                after_sequence=after_seq,
+                model=trace.model,
+                temperature=trace.llm_params.get("temperature", 0.3),
+                max_iterations=200,
+            )
+
+            # 准备消息列表(如果有干预消息则插入)
+            messages_to_add = []
+            if intervention_msg:
+                messages_to_add.append({"role": "user", "content": intervention_msg})
+                print(f"\n✓ 将插入干预消息")
+
+            # 调用 runner.run() 续跑
+            print("\n开始执行...")
+            async for event in self.runner.run(
+                messages=messages_to_add,
+                config=config,
+            ):
+                # 处理事件输出
+                if isinstance(event, Message):
+                    if event.role == "assistant":
+                        content = event.content
+                        if isinstance(content, dict):
+                            text = content.get("text", "")
+                        else:
+                            text = str(content)
+                        if text:
+                            print(f"\n🤖 Assistant: {text[:200]}...")
+                elif isinstance(event, Trace):
+                    # Trace 状态更新
+                    if event.status in ("completed", "failed", "stopped"):
+                        print(f"\n📊 Trace 状态: {event.status}")
+
+            print("\n✅ 执行完成")
+
+        except Exception as e:
+            print(f"❌ 执行失败: {e}")
+            import traceback
+            traceback.print_exc()
+
+    def _get_content_preview(self, content: Any, max_length: int = 60) -> str:
+        """
+        获取消息内容预览
+
+        Args:
+            content: 消息内容
+            max_length: 最大长度
+
+        Returns:
+            内容预览字符串
+        """
+        if isinstance(content, dict):
+            text = content.get("text", "")
+            tool_calls = content.get("tool_calls", [])
+
+            # 处理 text 字段(可能是字符串、list、dict 或其他类型)
+            if isinstance(text, str) and text.strip():
+                preview = text.strip()
+            elif isinstance(text, list):
+                # 多模态内容:提取文本部分
+                text_parts = [
+                    part.get("text", "") for part in text
+                    if isinstance(part, dict) and part.get("type") == "text"
+                ]
+                preview = " ".join(text_parts).strip() if text_parts else "[多模态内容]"
+            elif isinstance(text, dict):
+                # text 本身是 dict,尝试提取有用信息
+                preview = str(text.get("value", text.get("text", "[复杂内容]")))
+            elif tool_calls:
+                preview = f"[调用工具: {', '.join(tc.get('function', {}).get('name', '?') for tc in tool_calls)}]"
+            else:
+                preview = "[空消息]"
+        elif isinstance(content, str):
+            preview = content.strip()
+        else:
+            preview = str(content)
+
+        if len(preview) > max_length:
+            preview = preview[:max_length] + "..."
+
+        return preview

+ 234 - 0
agent/agent/client.py

@@ -0,0 +1,234 @@
+"""
+Agent SDK 入口 —— 统一调用 remote / 本地 Agent 的公开 API。
+
+使用方式(任何进程,只要装了 cyber-agent 包):
+
+    import asyncio
+    from agent import invoke_agent
+
+    # 远端(HTTP 调用 KnowHub 服务器)
+    result = asyncio.run(invoke_agent(
+        agent_type="remote_librarian",
+        task="ControlNet 相关的工具知识",
+        skills=["ask_strategy"],
+    ))
+
+    # 本地(在当前进程起 AgentRunner)
+    result = asyncio.run(invoke_agent(
+        agent_type="deconstruct",
+        task="...",
+        project_root="./examples/production_plan",
+    ))
+
+skill 脚本只需要 `from agent import invoke_agent` 然后透传命令行参数即可——
+不依赖仓库相对路径,也不会触发 `agent.tools.builtin` 的 eager tool registry 加载。
+"""
+
+import importlib
+import importlib.util
+import logging
+import os
+import sys
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+
+# 模块加载时就 load .env,保证后续任何 module-level `os.getenv(...)` 读取都能拿到值。
+# 查找顺序:cwd / cyber-agent 仓库根(editable install 定位)。
+def _load_default_env() -> None:
+    try:
+        from dotenv import load_dotenv
+    except ImportError:
+        return
+    # 先试 cwd 向上查找
+    load_dotenv()
+    # 再试 cyber-agent 仓库根(编辑安装时 agent/__init__.py 所在仓库的 .env)
+    repo_env = Path(__file__).resolve().parent.parent / ".env"
+    if repo_env.exists():
+        load_dotenv(repo_env, override=False)
+
+
+_load_default_env()
+
+
+async def invoke_agent(
+    agent_type: str,
+    task: str,
+    skills: Optional[List[str]] = None,
+    continue_from: Optional[str] = None,
+    messages: Optional[List[Dict[str, Any]]] = None,
+    project_root: Optional[str] = None,
+) -> Dict[str, Any]:
+    """
+    统一调用远端或本地 Agent。
+
+    Args:
+        agent_type: Agent 类型。以 "remote_" 开头 → HTTP 调用 KnowHub;否则本地执行。
+        task: 任务描述
+        skills: 指定 skill 列表(远端由服务器白名单过滤;本地覆盖项目 RUN_CONFIG.skills)
+        continue_from: 已有 sub_trace_id,传入则续跑
+        messages: 预置 OpenAI 格式消息(远端 1D、本地 1D)
+        project_root: 本地 agent 必填——项目目录(含 config.py / presets.json / tools/)
+
+    Returns:
+        {"mode", "agent_type", "sub_trace_id", "status", "summary", "stats", "error"?}
+    """
+    if agent_type.startswith("remote_"):
+        # 懒 import,避免加载整个 tool registry(远端调用只需要 httpx)
+        from agent.tools.builtin.subagent import _run_remote_agent
+        return await _run_remote_agent(
+            agent_type=agent_type,
+            task=task,
+            messages=messages,
+            continue_from=continue_from,
+            skills=skills,
+        )
+
+    if not project_root:
+        return {
+            "mode": "local",
+            "agent_type": agent_type,
+            "status": "failed",
+            "error": "本地 agent 需要 project_root 指定项目目录(含 config.py)",
+        }
+
+    return await _run_local_agent(
+        agent_type=agent_type,
+        task=task,
+        skills=skills,
+        continue_from=continue_from,
+        messages=messages,
+        project_root=project_root,
+    )
+
+
+async def _run_local_agent(
+    agent_type: str,
+    task: str,
+    skills: Optional[List[str]],
+    continue_from: Optional[str],
+    messages: Optional[List[Dict[str, Any]]],
+    project_root: str,
+) -> Dict[str, Any]:
+    """
+    在当前进程中起 AgentRunner 跑本地 agent。
+
+    项目目录约定:
+      project_root/
+      ├── config.py          # 必需:定义 RUN_CONFIG(RunConfig 实例),可选 SKILLS_DIR / TRACE_STORE_PATH
+      ├── presets.json       # 可选:agent_type preset
+      └── tools/             # 可选:项目自定义工具(有 __init__.py 则 import 触发 @tool 注册)
+
+    .env 会自动从 project_root 或上两级目录查找并加载。
+    """
+    root = Path(project_root).resolve()
+    if not root.is_dir():
+        return {"mode": "local", "agent_type": agent_type, "status": "failed",
+                "error": f"project_root 不存在: {root}"}
+
+    # 1. 把项目根加入 sys.path(让 `import config` / `import tools` 能找到)
+    if str(root) not in sys.path:
+        sys.path.insert(0, str(root))
+
+    # 2. 加载 .env:依次查项目根、两级父目录(兼容 monorepo)、cyber-agent 仓库根
+    try:
+        from dotenv import load_dotenv
+        import agent as _agent_pkg
+        agent_repo_root = Path(_agent_pkg.__file__).parent.parent
+        for candidate in (
+            root / ".env",
+            root.parent / ".env",
+            root.parent.parent / ".env",
+            agent_repo_root / ".env",
+        ):
+            if candidate.exists():
+                load_dotenv(candidate)
+                break
+    except ImportError:
+        pass
+
+    # 3. 加载项目 config
+    config_path = root / "config.py"
+    if not config_path.exists():
+        return {"mode": "local", "agent_type": agent_type, "status": "failed",
+                "error": f"缺少 {config_path}"}
+
+    try:
+        spec = importlib.util.spec_from_file_location("_project_config", config_path)
+        cfg_mod = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(cfg_mod)
+    except Exception as e:
+        return {"mode": "local", "agent_type": agent_type, "status": "failed",
+                "error": f"加载 {config_path} 失败: {e}"}
+
+    run_config = getattr(cfg_mod, "RUN_CONFIG", None)
+    if run_config is None:
+        return {"mode": "local", "agent_type": agent_type, "status": "failed",
+                "error": f"{config_path} 未定义 RUN_CONFIG"}
+
+    # 覆盖 agent_type / skills / continue_from
+    run_config.agent_type = agent_type
+    if skills is not None:
+        run_config.skills = skills
+    if continue_from:
+        run_config.trace_id = continue_from
+
+    # 4. 加载项目 presets.json(如果有)
+    presets_path = root / "presets.json"
+    if presets_path.exists():
+        try:
+            from agent.core.presets import load_presets_from_json
+            load_presets_from_json(str(presets_path))
+        except Exception as e:
+            logger.warning(f"加载 presets.json 失败: {e}")
+
+    # 5. 触发项目自定义工具注册(约定:project_root/tools/__init__.py)
+    if (root / "tools" / "__init__.py").exists():
+        try:
+            importlib.import_module("tools")
+        except Exception as e:
+            logger.warning(f"加载 tools 包失败: {e}")
+
+    # 6. 创建 AgentRunner
+    from agent.core.runner import AgentRunner
+    from agent.trace import FileSystemTraceStore
+    from agent.llm import create_qwen_llm_call
+
+    trace_store_path = getattr(cfg_mod, "TRACE_STORE_PATH", ".trace")
+    skills_dir = getattr(cfg_mod, "SKILLS_DIR", "./skills")
+
+    # 相对路径以 project_root 为基准
+    if not os.path.isabs(trace_store_path):
+        trace_store_path = str(root / trace_store_path)
+    if not os.path.isabs(skills_dir):
+        skills_dir = str(root / skills_dir)
+
+    runner = AgentRunner(
+        trace_store=FileSystemTraceStore(base_path=trace_store_path),
+        llm_call=create_qwen_llm_call(model=run_config.model),
+        skills_dir=skills_dir,
+    )
+
+    # 7. 构建消息
+    msgs = list(messages) if messages else []
+    msgs.append({"role": "user", "content": task})
+
+    # 8. 运行
+    try:
+        result = await runner.run_result(messages=msgs, config=run_config)
+    except Exception as e:
+        logger.exception("本地 agent 运行失败")
+        return {"mode": "local", "agent_type": agent_type, "status": "failed",
+                "error": f"{type(e).__name__}: {e}"}
+
+    return {
+        "mode": "local",
+        "agent_type": agent_type,
+        "sub_trace_id": result.get("trace_id"),
+        "status": result.get("status", "unknown"),
+        "summary": result.get("summary", ""),
+        "stats": result.get("stats", {}),
+        "error": result.get("error"),
+    }

+ 30 - 0
agent/agent/core/__init__.py

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

+ 393 - 0
agent/agent/core/dream.py

@@ -0,0 +1,393 @@
+"""
+Dream:记忆反思操作(Phase 3)
+
+两阶段执行:
+    per_trace_reflect    → 为每个有新消息的 trace 生成反思摘要,写 cognition_log
+    cross_trace_integrate → 汇总各 trace 的反思摘要 + 当前记忆文件,
+                             用 dream_prompt 指导 LLM 更新记忆文件
+
+对外入口:
+    run_dream(store, llm_call, memory_config, trace_filter=None, model=...)
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
+
+from agent.core.memory import MemoryConfig, load_memory_files, format_memory_injection
+from agent.trace.models import Trace
+from agent.trace.store import FileSystemTraceStore
+
+logger = logging.getLogger(__name__)
+
+
+# ===== 默认 prompts =====
+
+DEFAULT_REFLECT_PROMPT = """你正在回顾一次 Agent 执行中发生的事情,为你自己(作为长期身份)的记忆做反思。
+
+请综合下面的执行过程和知识使用情况,回答:
+1. 这次执行中有什么值得记住的经验?(品味、判断、策略)
+2. 哪些知识的评估反映了我的判断需要调整?
+3. 用户的反馈(如果有)说明了什么?
+
+用简洁的第一人称段落写,不要逐条列点,不要重复执行细节 —— 你在沉淀"这对未来的我意味着什么"。
+只输出反思内容本身,不要任何其它前缀或 markdown 标题。"""
+
+
+DEFAULT_DREAM_PROMPT = """你正在整理自己的长期记忆。下面是你最近的反思摘要、以及当前各记忆文件的内容。
+
+请决定哪些文件应该更新、内容怎么改。原则:
+- 只更新真正有新见解的文件,没有变化的就不要动
+- 在原有内容基础上演进,不是重写;保留仍然有效的旧内容
+- 简洁、人类可读的 markdown 格式
+- 新增文件必须是 MemoryConfig.files 已声明的路径(否则不会被下次加载)
+
+**严格按以下 JSON 格式输出,不要任何其它文字**:
+
+```json
+{
+  "updates": [
+    {"path": "taste.md", "new_content": "完整的新文件内容"},
+    {"path": "strategy.md", "new_content": "..."}
+  ],
+  "reasoning": "你为什么做这些更新(简短)"
+}
+```
+
+如果没有任何文件需要更新,输出 `{"updates": [], "reasoning": "..."}`。"""
+
+
+# ===== 数据结构 =====
+
+@dataclass
+class DreamReport:
+    per_trace_summaries: Dict[str, str] = field(default_factory=dict)  # {trace_id: summary}
+    updated_files: List[str] = field(default_factory=list)             # 实际写入的文件路径
+    consumed_reflection_count: int = 0                                  # 本次消化了多少条 reflection
+    reasoning: str = ""
+    skipped_traces: List[str] = field(default_factory=list)
+
+
+LLMCall = Callable[..., Awaitable[Dict[str, Any]]]
+
+
+# ===== Per-trace 反思 =====
+
+async def per_trace_reflect(
+    store: FileSystemTraceStore,
+    llm_call: LLMCall,
+    trace_id: str,
+    memory_config: MemoryConfig,
+    model: str = "gpt-4o-mini",
+) -> Optional[str]:
+    """为单个 trace 生成反思摘要,写入 cognition_log,更新 reflected_at_sequence。
+
+    Returns:
+        反思摘要字符串;若 trace 没有新消息或 LLM 返回空,返回 None。
+    """
+    trace = await store.get_trace(trace_id)
+    if not trace:
+        logger.debug(f"[Dream] trace 不存在: {trace_id}")
+        return None
+
+    start_seq = (trace.reflected_at_sequence or 0) + 1
+    end_seq = trace.last_sequence
+    if start_seq > end_seq:
+        logger.debug(f"[Dream] trace {trace_id} 没有新消息({start_seq} > {end_seq})")
+        return None
+
+    all_msgs = await store.get_trace_messages(trace_id)
+    new_msgs = [m for m in all_msgs if start_seq <= m.sequence <= end_seq]
+    if not new_msgs:
+        logger.debug(f"[Dream] trace {trace_id} 范围内无消息")
+        return None
+
+    log = await store.get_cognition_log(trace_id)
+    events = log.get("events", log.get("entries", []))
+    relevant_events = [
+        e for e in events
+        if e.get("sequence") is not None
+        and start_seq <= e["sequence"] <= end_seq
+        and e.get("type") in ("query", "evaluation", "extraction_pending", "extraction_committed")
+    ]
+
+    user_content = _build_reflect_input(new_msgs, relevant_events)
+    prompt = memory_config.reflect_prompt or DEFAULT_REFLECT_PROMPT
+
+    try:
+        result = await llm_call(
+            messages=[
+                {"role": "system", "content": prompt},
+                {"role": "user", "content": user_content},
+            ],
+            model=model,
+            tools=None,
+            temperature=0.5,
+        )
+    except Exception as e:
+        logger.error(f"[Dream] per_trace_reflect LLM 调用失败 {trace_id}: {e}")
+        return None
+
+    summary = (result.get("content") or "").strip()
+    if not summary:
+        logger.info(f"[Dream] trace {trace_id} 反思 LLM 返回空,视为无值得记录的内容")
+        # 仍然更新 reflected_at_sequence,避免下次重复扫描
+        await store.update_trace(trace_id, reflected_at_sequence=end_seq)
+        return None
+
+    await store.append_cognition_event(
+        trace_id=trace_id,
+        event={
+            "type": "reflection",
+            "sequence_range": [start_seq, end_seq],
+            "summary": summary,
+        },
+    )
+    await store.update_trace(trace_id, reflected_at_sequence=end_seq)
+    logger.info(f"[Dream] trace {trace_id} 反思完成,覆盖 sequence {start_seq}-{end_seq}")
+    return summary
+
+
+def _build_reflect_input(messages: List[Any], events: List[Dict[str, Any]]) -> str:
+    """把消息和事件组织为 LLM 可读的反思输入。"""
+    parts: List[str] = ["## 执行过程"]
+    for m in messages:
+        role = getattr(m, "role", "?")
+        desc = getattr(m, "description", "") or ""
+        seq = getattr(m, "sequence", "?")
+        # 截断,防止单条过长
+        parts.append(f"[{seq}] {role}: {desc[:500]}")
+
+    if events:
+        parts.append("\n## 知识使用与提取情况(来自 cognition_log)")
+        for e in events:
+            etype = e.get("type")
+            if etype == "query":
+                parts.append(
+                    f"- [{e.get('sequence')}] query: {e.get('query', '')[:100]} → "
+                    f"source_ids={e.get('source_ids', [])}"
+                )
+            elif etype == "evaluation":
+                parts.append(
+                    f"- evaluation: knowledge_id={e.get('knowledge_id')} "
+                    f"result={e.get('eval_result')}"
+                )
+            elif etype == "extraction_pending":
+                payload = e.get("payload", {})
+                parts.append(
+                    f"- extraction_pending ({e.get('extraction_id')}): "
+                    f"{payload.get('task', '')[:80]}"
+                )
+            elif etype == "extraction_committed":
+                parts.append(
+                    f"- extraction_committed: extraction={e.get('extraction_id')} "
+                    f"→ knowledge_id={e.get('knowledge_id')}"
+                )
+    return "\n".join(parts)
+
+
+# ===== 跨 trace 整合 =====
+
+async def cross_trace_integrate(
+    store: FileSystemTraceStore,
+    llm_call: LLMCall,
+    memory_config: MemoryConfig,
+    trace_filter: Optional[Callable[[Trace], bool]] = None,
+    model: str = "gpt-4o",
+) -> Tuple[int, List[str], str]:
+    """汇总各 trace 未消化的 reflection 事件,用 LLM 更新记忆文件。
+
+    Args:
+        trace_filter: 可选的 trace 过滤函数(例如按 agent_type / owner);
+                      None 表示扫描 TraceStore 下所有 trace。
+
+    Returns:
+        (consumed_reflection_count, updated_file_paths, reasoning)
+    """
+    all_traces = await store.list_traces(limit=1000)
+    if trace_filter:
+        all_traces = [t for t in all_traces if trace_filter(t)]
+
+    # 收集所有未消化的 reflection 事件
+    reflections: List[Tuple[str, Dict[str, Any]]] = []  # [(trace_id, event)]
+    for t in all_traces:
+        log = await store.get_cognition_log(t.trace_id)
+        events = log.get("events", log.get("entries", []))
+        for e in events:
+            if e.get("type") == "reflection" and not e.get("consumed_at"):
+                reflections.append((t.trace_id, e))
+
+    if not reflections:
+        logger.info("[Dream] 没有未消化的 reflection 事件")
+        return 0, [], ""
+
+    # 读当前记忆文件
+    existing_files = load_memory_files(memory_config)
+    existing_by_path = {rel: (purpose, content) for rel, purpose, content in existing_files}
+
+    user_content = _build_dream_input(reflections, existing_files, memory_config)
+    prompt = memory_config.dream_prompt or DEFAULT_DREAM_PROMPT
+
+    try:
+        result = await llm_call(
+            messages=[
+                {"role": "system", "content": prompt},
+                {"role": "user", "content": user_content},
+            ],
+            model=model,
+            tools=None,
+            temperature=0.3,
+        )
+    except Exception as e:
+        logger.error(f"[Dream] cross_trace_integrate LLM 调用失败: {e}")
+        return 0, [], ""
+
+    raw = (result.get("content") or "").strip()
+    plan = _parse_dream_output(raw)
+    if plan is None:
+        logger.error(f"[Dream] LLM 输出无法解析为 JSON 计划,原文: {raw[:500]}")
+        return 0, [], ""
+
+    updated_paths: List[str] = []
+    base = Path(memory_config.base_path)
+
+    for update in plan.get("updates", []):
+        rel_path = update.get("path", "")
+        new_content = update.get("new_content", "")
+        if not rel_path:
+            continue
+        # 安全检查:禁止路径穿越
+        target = (base / rel_path).resolve()
+        if not str(target).startswith(str(base.resolve())):
+            logger.warning(f"[Dream] 拒绝写入 base_path 之外的路径: {rel_path}")
+            continue
+        target.parent.mkdir(parents=True, exist_ok=True)
+        target.write_text(new_content, encoding="utf-8")
+        updated_paths.append(rel_path)
+        logger.info(f"[Dream] 已更新记忆文件: {rel_path} ({len(new_content)} chars)")
+
+    # 标记所有参与的 reflection 为已消化
+    consumed_at = datetime.now().isoformat()
+    for trace_id, event in reflections:
+        log = await store.get_cognition_log(trace_id)
+        events = log.get("events", log.get("entries", []))
+        target_ts = event.get("timestamp")
+        for e in events:
+            if (
+                e.get("type") == "reflection"
+                and not e.get("consumed_at")
+                and e.get("timestamp") == target_ts
+            ):
+                e["consumed_at"] = consumed_at
+        log_file = store._get_cognition_log_file(trace_id)
+        log_file.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
+
+    reasoning = plan.get("reasoning", "")
+    return len(reflections), updated_paths, reasoning
+
+
+def _build_dream_input(
+    reflections: List[Tuple[str, Dict[str, Any]]],
+    existing_files: List[Tuple[str, str, str]],
+    memory_config: MemoryConfig,
+) -> str:
+    """为 dream prompt 准备输入:反思摘要汇总 + 当前记忆文件 + 允许的文件路径。"""
+    parts: List[str] = ["## 最近的反思摘要\n"]
+    for trace_id, e in reflections:
+        seq_range = e.get("sequence_range", [None, None])
+        parts.append(
+            f"### trace {trace_id} (messages {seq_range[0]}-{seq_range[1]})\n"
+            f"{e.get('summary', '')}\n"
+        )
+
+    parts.append("\n## 当前记忆文件\n")
+    if existing_files:
+        parts.append(format_memory_injection(existing_files))
+    else:
+        parts.append("(暂无记忆文件)")
+
+    if memory_config.files:
+        parts.append("\n## 允许更新/新增的文件路径\n")
+        for key, purpose in memory_config.files.items():
+            parts.append(f"- `{key}`" + (f" — {purpose}" if purpose else ""))
+
+    return "\n".join(parts)
+
+
+def _parse_dream_output(raw: str) -> Optional[Dict[str, Any]]:
+    """解析 LLM 的 JSON 计划输出。容忍 ```json ... ``` 包裹。"""
+    stripped = raw.strip()
+    # 去除 markdown 代码块包裹
+    m = re.match(r"^```(?:json)?\s*(.*?)\s*```$", stripped, re.DOTALL)
+    if m:
+        stripped = m.group(1).strip()
+    try:
+        data = json.loads(stripped)
+    except json.JSONDecodeError:
+        return None
+    if not isinstance(data, dict) or "updates" not in data:
+        return None
+    return data
+
+
+# ===== 顶层入口 =====
+
+async def run_dream(
+    store: FileSystemTraceStore,
+    llm_call: LLMCall,
+    memory_config: MemoryConfig,
+    trace_filter: Optional[Callable[[Trace], bool]] = None,
+    reflect_model: str = "gpt-4o-mini",
+    dream_model: str = "gpt-4o",
+) -> DreamReport:
+    """执行完整的 dream 流程:per_trace_reflect → cross_trace_integrate。
+
+    Args:
+        trace_filter: 筛选需要反思的 trace(例如按 agent_type 或 owner);
+                      None 表示扫描所有 trace
+        reflect_model: per-trace 反思用的模型(轻量模型即可)
+        dream_model:   跨 trace 整合用的模型(需要更强推理能力)
+    """
+    report = DreamReport()
+
+    if not memory_config.base_path:
+        logger.warning("[Dream] memory_config.base_path 未配置,跳过")
+        return report
+
+    # Phase 1: per-trace reflect
+    all_traces = await store.list_traces(limit=1000)
+    if trace_filter:
+        all_traces = [t for t in all_traces if trace_filter(t)]
+
+    for t in all_traces:
+        if (t.reflected_at_sequence or 0) >= t.last_sequence:
+            continue
+        try:
+            summary = await per_trace_reflect(
+                store, llm_call, t.trace_id, memory_config, model=reflect_model,
+            )
+            if summary:
+                report.per_trace_summaries[t.trace_id] = summary
+        except Exception as e:
+            logger.error(f"[Dream] per_trace_reflect 异常 {t.trace_id}: {e}")
+            report.skipped_traces.append(t.trace_id)
+
+    # Phase 2: cross-trace integrate
+    try:
+        consumed, updated, reasoning = await cross_trace_integrate(
+            store, llm_call, memory_config,
+            trace_filter=trace_filter, model=dream_model,
+        )
+        report.consumed_reflection_count = consumed
+        report.updated_files = updated
+        report.reasoning = reasoning
+    except Exception as e:
+        logger.error(f"[Dream] cross_trace_integrate 异常: {e}")
+
+    return report

+ 100 - 0
agent/agent/core/memory.py

@@ -0,0 +1,100 @@
+"""
+Memory 系统(Phase 2+)
+
+详见 agent/docs/memory.md。核心概念:
+- Memory:Agent 身份私有的主观记忆,Markdown 文件,人类可读写
+- Dream:记忆反思操作(回顾多个 trace 的执行历史,更新记忆文件)
+
+本模块只提供 MemoryConfig 数据类和记忆文件加载逻辑。
+Dream 操作在 agent/core/dream.py(Phase 3)。
+"""
+
+from __future__ import annotations
+
+import glob as _glob
+import logging
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class MemoryConfig:
+    """持久化记忆配置(见 agent/docs/memory.md 第五节)"""
+
+    base_path: str = ""
+    # 记忆文件根目录。所有文件路径相对此目录解析。
+
+    files: Optional[Dict[str, str]] = None
+    # {路径模式: 用途说明}
+    # key 支持两种形式:
+    #   - 直接路径:"core/identity.md"
+    #   - glob 模式:"relationships/*.md"、"journals/2026/**.md"
+    # value 是人类可读的用途说明(注入时作为文件分隔标题的一部分)。
+    # 框架只负责按 key 解析文件内容;组织结构由配置者决定。
+
+    dream_prompt: str = ""
+    # Dream 跨 trace 整合 prompt;空则使用默认(Phase 3 定义)
+
+    reflect_prompt: str = ""
+    # Per-trace 记忆反思 prompt;空则使用默认(Phase 3 定义)
+
+
+def load_memory_files(config: MemoryConfig) -> List[Tuple[str, str, str]]:
+    """按 MemoryConfig.files 的 key 解析磁盘上的记忆文件。
+
+    Returns:
+        List[(relative_path, purpose, content)],按 files 声明顺序扁平化,
+        文件不存在则跳过(记 debug 日志),内容为空也保留(方便人类看到占位)。
+    """
+    if not config.base_path or not config.files:
+        return []
+
+    base = Path(config.base_path)
+    if not base.exists():
+        logger.debug(f"[Memory] base_path 不存在: {base}")
+        return []
+
+    results: List[Tuple[str, str, str]] = []
+    seen: set[str] = set()  # 去重(多个 glob 可能命中同一个文件)
+
+    for key, purpose in config.files.items():
+        # 展开 glob;直接路径也走 glob(无通配符时返回单条或空)
+        pattern = str(base / key)
+        matched_paths = sorted(_glob.glob(pattern, recursive=True))
+
+        if not matched_paths:
+            # 直接路径没命中时给个 debug(可能还没写第一版)
+            logger.debug(f"[Memory] {key} 没有匹配文件(尚未创建)")
+            continue
+
+        for fs_path in matched_paths:
+            rel = str(Path(fs_path).relative_to(base))
+            if rel in seen:
+                continue
+            seen.add(rel)
+            try:
+                content = Path(fs_path).read_text(encoding="utf-8")
+            except Exception as e:
+                logger.warning(f"[Memory] 读取失败 {fs_path}: {e}")
+                continue
+            results.append((rel, purpose, content))
+
+    return results
+
+
+def format_memory_injection(files: List[Tuple[str, str, str]]) -> str:
+    """把加载结果格式化为可注入到上下文的 markdown 段。"""
+    if not files:
+        return ""
+    parts = ["## 你的长期记忆\n\n以下是你作为此 Agent 身份积累的记忆(人类可直接编辑):\n"]
+    for rel, purpose, content in files:
+        header = f"### `{rel}`"
+        if purpose:
+            header += f" — {purpose}"
+        parts.append(header)
+        parts.append(content.rstrip() or "_(空文件,尚未积累内容)_")
+        parts.append("")  # 空行分隔
+    return "\n".join(parts).rstrip() + "\n"

+ 149 - 0
agent/agent/core/presets.py

@@ -0,0 +1,149 @@
+"""
+Agent Presets - Agent 类型预设配置
+
+定义不同类型 Agent 的工具权限和运行参数。
+用户可通过 .agent/presets.json 覆盖或添加预设。
+"""
+
+from dataclasses import dataclass, field
+from typing import Optional, List
+from pathlib import Path
+
+
+@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
+
+    # System Prompt(完全自定义 system prompt;None = 使用默认模板 + skills)
+    system_prompt: Optional[str] = None
+
+    # Skills(注入 system prompt 的 skill 名称列表;None = 加载全部)
+    skills: Optional[List[str]] = None
+
+    # 描述
+    description: Optional[str] = None
+
+
+# 内置预设
+# 默认不预置 skills,项目按需在 presets.json 或 RunConfig.skills 中指定
+AGENT_PRESETS = {
+    "default": AgentPreset(
+        allowed_tools=None,
+        max_iterations=30,
+        skills=[],
+        description="默认 Agent,拥有全部工具权限",
+    ),
+    "delegate": AgentPreset(
+        allowed_tools=None,
+        max_iterations=30,
+        skills=[],
+        description="委托子 Agent,拥有全部工具权限(由 agent 工具创建)",
+    ),
+    "explore": AgentPreset(
+        allowed_tools=["read", "glob", "grep", "list_files"],
+        denied_tools=["write", "edit", "bash", "task"],
+        max_iterations=15,
+        skills=[],
+        description="探索型 Agent,只读权限,用于代码分析",
+    ),
+    "evaluate": AgentPreset(
+        allowed_tools=["read_file", "grep_content", "glob_files", "goal"],
+        max_iterations=10,
+        skills=[],
+        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
+
+
+def load_system_prompt_from_file(path: str) -> str:
+    """
+    从 .prompt 文件加载 system prompt
+
+    Args:
+        path: .prompt 文件路径(相对或绝对)
+
+    Returns:
+        system prompt 文本
+
+    Raises:
+        FileNotFoundError: 文件不存在
+        ValueError: 文件格式错误或缺少 $system$ 分节
+    """
+    from agent.llm.prompts import load_prompt
+
+    prompt_path = Path(path)
+    if not prompt_path.is_absolute():
+        # 相对路径:相对于当前工作目录
+        prompt_path = Path.cwd() / prompt_path
+
+    config, messages = load_prompt(prompt_path)
+
+    if "system" not in messages:
+        raise ValueError(f".prompt 文件缺少 $system$ 分节: {path}")
+
+    return messages["system"]
+
+
+def load_presets_from_json(json_path: str) -> None:
+    """
+    从 JSON 文件加载预设配置
+
+    支持特殊字段:
+    - system_prompt_file: 从 .prompt 文件加载 system prompt(相对于 JSON 文件所在目录)
+    - prompt_vars: 变量字典,用于替换 prompt 中的 %variable% 占位符
+
+    Args:
+        json_path: presets.json 文件路径
+    """
+    import json
+
+    json_path = Path(json_path)
+    if not json_path.exists():
+        raise FileNotFoundError(f"presets.json 不存在: {json_path}")
+
+    with open(json_path, "r", encoding="utf-8") as f:
+        presets_data = json.load(f)
+
+    base_dir = json_path.parent
+
+    for name, cfg in presets_data.items():
+        # 提取 prompt_vars(不传给 AgentPreset)
+        prompt_vars = cfg.pop("prompt_vars", None)
+
+        # 处理 system_prompt_file
+        if "system_prompt_file" in cfg:
+            prompt_file = cfg.pop("system_prompt_file")
+            prompt_path = base_dir / prompt_file
+            system_prompt = load_system_prompt_from_file(str(prompt_path))
+
+            # 应用变量替换
+            if prompt_vars and isinstance(prompt_vars, dict):
+                for var_name, var_value in prompt_vars.items():
+                    placeholder = f"%{var_name}%"
+                    if placeholder in system_prompt:
+                        system_prompt = system_prompt.replace(placeholder, str(var_value))
+
+            cfg["system_prompt"] = system_prompt
+
+        preset = AgentPreset(**cfg)
+        register_preset(name, preset)

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

@@ -0,0 +1,59 @@
+"""
+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_single_turn_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",
+]

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

@@ -0,0 +1,73 @@
+"""
+压缩相关 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
+
+SINGLE_TURN_PROMPT = """请对以上对话历史进行压缩总结。
+
+### 摘要要求
+1. 保留关键决策、结论和产出(如创建的文件、修改的代码、得出的分析结论)
+2. 保留重要的上下文(如用户的要求、约束条件、之前的讨论结果)
+3. 省略中间探索过程、重复的工具调用细节
+4. 使用结构化格式(标题 + 要点 + 相关资源引用,若有)
+5. 控制在 2000 字以内
+
+当前 GoalTree 状态:
+{goal_tree_prompt}
+
+格式要求:
+[[SUMMARY]]
+(此处填写结构化的摘要内容)
+"""
+
+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_single_turn_prompt(goal_tree_prompt: str) -> str:
+    return SINGLE_TURN_PROMPT.format(goal_tree_prompt=goal_tree_prompt)
+
+
+def build_summary_header(summary_text: str) -> str:
+    return SUMMARY_HEADER_TEMPLATE.format(summary_text=summary_text)

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

@@ -0,0 +1,116 @@
+"""
+知识提取相关 Prompt
+
+两个场景,各自独立配置:
+- REFLECT_PROMPT:            压缩时阶段性反思(消息量超阈值,对当前批历史提炼)
+- COMPLETION_REFLECT_PROMPT: 任务完成后全局复盘(对整个任务的全局视角)
+
+两个 prompt 都要求 LLM 直接调用 `knowledge_save_pending` 工具暂存为待审核条目,
+每条知识一次调用,不需要输出结构化文本。
+
+"pending" 语义:条目落到 cognition_log 的 extraction_pending 事件,
+等待人工(或 reflect_auto_commit=True 时由框架自动)review + commit 才进入 KnowHub。
+详见 agent/docs/memory.md 第三节"提取-审核-提交两阶段"。
+"""
+
+# ===== 压缩时阶段性反思 =====
+
+REFLECT_PROMPT = """请回顾以上执行过程,将值得沉淀的内容通过 `knowledge_save_pending` 工具逐条暂存(每条知识一次调用)。
+
+暂存的条目会进入审核队列(不立即入库),等待人工 review 后才会上传到 KnowHub。
+
+## 两种保存模式
+
+### 模式 1:经验反思(types=["experience"])
+总结执行过程中的经验教训,关注:
+1. 人工干预:用户中途的指令说明了哪里出了问题
+2. 弯路:哪些尝试是不必要的,有没有更直接的方法
+3. 好的决策:哪些判断和选择是正确的,值得记住
+4. 工具使用:哪些工具用法是高效的,哪些可以改进
+
+**参数格式**:
+- `task`: 「在[什么情境]下,[要完成什么]」
+- `content`: 「当[条件]时,应该[动作](原因:[一句话])。案例:[具体案例]」
+- `types`: `["experience"]`
+- `tags`: `{"intent": "任务意图", "state": "环境状态/工具名"}`
+- `score`: 1-5(只保存最有价值的,宁少勿滥)
+
+### 模式 2:原始知识(types=["tool"] / ["strategy"] / ["case"])
+如果执行过程中**调研或发现了新知识**(如工具用法、工作流程、案例),原汁原味暂存:
+
+- `["tool"]`:工具知识(单个工具的功能、参数、用法、限制)
+- `["strategy"]`:工序知识(多步骤流程、方案、最佳实践)
+- `["case"]`:用例知识(真实案例、应用场景、效果数据)
+
+**参数格式**:
+- `task`: 知识的标题(如「Midjourney 的 --ar 参数用法」)
+- `content`: 原始知识内容(完整、详细、保留结构,不要过度总结)
+- `types`: 二选一
+- `tags`: `{"source": "来源网站/文档", "domain": "领域", ...}`
+- `resource_ids`: 关联的资源 ID(如果保存了原始文档)
+- `score`: 1-5(根据知识的价值和可靠性)
+
+## 其他注意事项
+
+- **一条知识一次 `knowledge_save_pending` 调用**,不要把多条合并
+- 只保存最有价值的经验,宁少勿滥;一次就成功或比较简单的经验就不要记录了,记录反复尝试或被用户指导后才成功的经验、或者是调研之后的收获
+- 不需要输出任何文字,直接调用工具即可
+- 如果没有值得保存的经验,不调用任何工具
+- **完成经验暂存后立即停止,不要继续执行原有任务**
+"""
+
+
+# ===== 任务完成后全局复盘 =====
+
+COMPLETION_REFLECT_PROMPT = """请对整个任务进行复盘,将值得沉淀的内容通过 `knowledge_save_pending` 工具逐条暂存(每条知识一次调用)。
+
+暂存的条目会进入审核队列(不立即入库),等待人工 review 后才会上传到 KnowHub。
+
+## 两种保存模式
+
+### 模式 1:经验反思(types=["experience"])
+任务结束后的全局视角,关注:
+1. 任务整体路径:实际走的路径与最初计划的偏差
+2. 关键决策点:哪些决策显著影响了最终结果
+3. 可复用的模式:哪些做法在类似任务中可以直接复用
+4. 踩过的坑:哪些问题本可提前规避
+
+**参数格式**:
+- `task`: 「在[什么情境]下,[要完成什么]」
+- `content`: 「当[条件]时,应该[动作](原因:[一句话])。案例:[具体案例]」
+- `types`: `["experience"]`
+- `tags`: `{"intent": "任务意图", "state": "环境状态/工具名"}`
+- `score`: 1-5(只保存最有价值的,宁少勿滥)
+
+### 模式 2:原始知识(types=["tool"] / ["strategy"] / ["case"])
+如果任务过程中**调研或发现了新知识**,完整保留结构和细节:
+
+- `["tool"]`:工具知识(工具的功能、参数、用法、限制、版本信息)
+- `["strategy"]`:工序知识(完整的多步骤流程、方案、最佳实践)
+- `["case"]`:用例知识(真实案例、应用场景、效果数据、对比结果)
+
+**参数格式**:
+- `task`: 知识的标题
+- `content`: 原始知识内容(完整详细,不要过度压缩)
+- `types`: 三选一
+- `tags`: `{"source": "来源", "domain": "领域", ...}`
+- `resource_ids`: 关联的资源 ID
+- `score`: 1-5
+
+## 关于资源(resource)
+
+如果过程中产出了可复用的代码/凭证/Cookie 等资源,先用 `resource_save` 工具保存,
+再在 `knowledge_save_pending` 的 `resource_ids` 字段中关联资源 ID。
+
+## 其他注意事项
+
+- **一条知识一次 `knowledge_save_pending` 调用**,不要把多条合并
+- 只保存最有价值的经验,宁少勿滥
+- 不需要输出任何文字,直接调用工具即可
+- 如果没有值得保存的经验,不调用任何工具
+- **完成经验暂存后立即停止,不要继续执行原有任务**
+"""
+
+
+def build_reflect_prompt() -> str:
+    return REFLECT_PROMPT

+ 39 - 0
agent/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)
+

+ 3121 - 0
agent/agent/core/runner.py

@@ -0,0 +1,3121 @@
+"""
+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,
+    compress_completed_goals,
+    estimate_tokens,
+    needs_level2_compression,
+    build_compression_prompt,
+)
+from agent.skill.models import Skill
+from agent.skill.skill_loader import load_skills_from_dir
+from agent.tools import ToolRegistry, get_tool_registry
+from agent.tools.builtin.knowledge import KnowledgeConfig
+from agent.core.memory import MemoryConfig
+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,
+    build_summary_header,
+    build_tool_interrupted_message,
+    build_agent_continue_hint,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@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 SideBranchContext:
+    """侧分支上下文(压缩/反思/知识评估)"""
+    type: Literal["compression", "reflection", "knowledge_eval"]
+    branch_id: str
+    start_head_seq: int          # 侧分支起点的 head_seq
+    start_sequence: int          # 侧分支第一条消息的 sequence
+    start_history_length: int    # 侧分支起点的 history 长度
+    start_iteration: int         # 侧分支开始时的 iteration
+    max_turns: int = 5           # 最大轮次
+
+    def to_dict(self) -> Dict[str, Any]:
+        """转换为字典(用于持久化和传递给工具)"""
+        return {
+            "type": self.type,
+            "branch_id": self.branch_id,
+            "start_head_seq": self.start_head_seq,
+            "start_sequence": self.start_sequence,
+            "start_iteration": self.start_iteration,
+            "max_turns": self.max_turns,
+            "is_side_branch": True,
+            "started_at": datetime.now().isoformat(),
+        }
+
+
+# ===== 运行配置 =====
+
+@dataclass
+class RunConfig:
+    """
+    运行参数 — 控制 Agent 如何执行
+
+    分为模型层参数(由上游 agent 或用户决定)和框架层参数(由系统注入)。
+    """
+    # --- 模型层参数 ---
+    model: str = "gpt-4o"
+    temperature: float = 0.3
+    max_iterations: int = 200
+    tools: Optional[List[str]] = None          # None = 按 tool_groups 过滤;显式列表 = 精确指定
+    tool_groups: Optional[List[str]] = field(default_factory=lambda: ["core"])  # 工具分组白名单;默认仅 core,项目按需追加
+    exclude_tools: List[str] = field(default_factory=list)  # 从 tools / tool_groups 结果中再排除的工具名(如远程 agent 禁用 agent/evaluate)
+    side_branch_max_turns: int = 5             # 侧分支最大轮次(压缩/反思)
+    goal_compression: Literal["none", "on_complete", "on_overflow"] = "on_overflow"  # Goal 压缩模式
+
+    # --- 强制侧分支(用于 API 手动触发或自动压缩流程)---
+    # 使用列表作为侧分支队列,每次完成一个侧分支后 pop(0) 取下一个
+    force_side_branch: Optional[List[Literal["compression", "reflection"]]] = 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 模型有效)
+    parallel_tool_execution: bool = False      # 是否启用并发 Tool Call 执行(慎用,需确保无资源冲突)
+
+    # --- 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)
+
+    # --- 自定义元数据上下文 ---
+    context: Dict[str, Any] = field(default_factory=dict)
+
+    # --- 研究流程控制 ---
+    enable_research_flow: bool = True  # 是否启用自动研究流程(知识检索→经验检索→调研→计划)
+    # --- 知识管理配置 ---
+    knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
+    # --- Memory 配置(见 agent/docs/memory.md) ---
+    # None = 默认 Agent(无长期记忆);赋值 MemoryConfig 使该 Agent 成为 memory-bearing Agent
+    memory: Optional["MemoryConfig"] = None
+
+
+    # BUILTIN_TOOLS 硬编码列表已移除(2026-04)。
+    # 工具可用性现在由 @tool(groups=[...]) 声明 + RunConfig.tool_groups 过滤控制。
+
+
+@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 = 5  # 每 N 轮注入一次 GoalTree + Collaborators + IM 通知
+
+
+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,
+        logger_name: Optional[str] = None,
+    ):
+        """
+        初始化 AgentRunner
+
+        Args:
+            trace_store: Trace 存储
+            tool_registry: 工具注册表(默认使用全局注册表)
+            llm_call: 主 LLM 调用函数
+            utility_llm_call: 轻量 LLM(用于生成任务标题等),可选
+            skills_dir: Skills 目录路径
+            goal_tree: 初始 GoalTree(可选)
+            debug: 保留参数(已废弃)
+            logger_name: 自定义日志名称(如 "agents.knowledge_manager"),默认用模块名
+        """
+        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.log = logging.getLogger(logger_name) if logger_name else logger
+        self.stdin_check: Optional[Callable] = None  # 由外部设置,用于子 agent 执行期间检查 stdin
+        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 → 当前用量快照
+
+        # 图片优化缓存(避免重复处理)
+        # key: 图片内容的 hash, value: {"downscaled": ..., "description": ...}
+        self._image_opt_cache: Dict[str, Dict[str, Any]] = {}
+
+        # 当前 run 的 MemoryConfig(由 run() 根据 RunConfig.memory 设置)
+        # dream 工具从 context.runner 读取此字段,判断是否 memory-bearing
+        self._current_memory_config: Optional[MemoryConfig] = None
+
+    # ===== 核心公开方法 =====
+
+    def get_context_usage(self, trace_id: str) -> Optional[ContextUsage]:
+        """获取指定 trace 的 context 使用情况"""
+        return self._context_usage.get(trace_id)
+
+    async def dream(
+        self,
+        memory_config: MemoryConfig,
+        trace_filter: Optional[Callable[["Trace"], bool]] = None,
+        reflect_model: str = "gpt-4o-mini",
+        dream_model: str = "gpt-4o",
+    ) -> "DreamReport":
+        """执行 dream(整理长期记忆)——外部调度入口。
+
+        Agent 主动调用走 dream 工具;外部调度(定时器、CLI)走这个方法。
+
+        Args:
+            memory_config: 记忆配置
+            trace_filter: 可选 trace 过滤(按 agent_type/owner 等)
+            reflect_model: per-trace 反思模型
+            dream_model: 跨 trace 整合模型
+        """
+        from agent.core.dream import run_dream
+        if not self.trace_store or not self.llm_call:
+            raise RuntimeError("dream 需要 trace_store 和 llm_call 均已配置")
+        return await run_dream(
+            store=self.trace_store,
+            llm_call=self.llm_call,
+            memory_config=memory_config,
+            trace_filter=trace_filter,
+            reflect_model=reflect_model,
+            dream_model=dream_model,
+        )
+
+    async def run(
+        self,
+        messages: List[Dict],
+        config: Optional[RunConfig] = None,
+        inject_skills: Optional[List[str]] = None,
+        skill_recency_threshold: int = 10,
+    ) -> AsyncIterator[Union[Trace, Message]]:
+        """
+        Agent 模式执行(核心方法)
+
+        Args:
+            messages: OpenAI SDK 格式的输入消息
+                新建: 初始任务消息 [{"role": "user", "content": "..."}]
+                续跑: 追加的新消息
+                回溯: 在插入点之后追加的消息
+            config: 运行配置
+            inject_skills: 本次调用需要指定注入的 skill 列表(skill 名称)
+            skill_recency_threshold: 最近 N 条消息内有该 skill 就不重复注入
+
+        Yields:
+            Union[Trace, Message]: Trace 对象(状态变化)或 Message 对象(执行过程)
+        """
+        if not self.llm_call:
+            raise ValueError("llm_call function not provided")
+
+        config = config or RunConfig()
+        trace = None
+
+        # Memory 模式开关(dream 工具会读取此字段)
+        self._current_memory_config = config.memory
+
+        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
+
+            # 检查是否有未完成的侧分支(用于用户追加消息场景)
+            side_branch_ctx_for_build: Optional[SideBranchContext] = None
+            if trace.context.get("active_side_branch") and messages:
+                side_branch_data = trace.context["active_side_branch"]
+
+                # 创建侧分支上下文(用于标记用户追加的消息)
+                side_branch_ctx_for_build = SideBranchContext(
+                    type=side_branch_data["type"],
+                    branch_id=side_branch_data["branch_id"],
+                    start_head_seq=side_branch_data["start_head_seq"],
+                    start_sequence=side_branch_data["start_sequence"],
+                    start_history_length=0,
+                    start_iteration=side_branch_data.get("start_iteration", 0),
+                    max_turns=side_branch_data.get("max_turns", config.side_branch_max_turns),
+                )
+
+            # Phase 2: BUILD HISTORY
+            history, sequence, created_messages, head_seq = await self._build_history(
+                trace.trace_id, messages, goal_tree, config, sequence, side_branch_ctx_for_build
+            )
+            # 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,
+                inject_skills=inject_skills,
+                skill_recency_threshold=skill_recency_threshold,
+            ):
+                yield event
+
+        except Exception as e:
+            self.log.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,
+        inject_skills: Optional[List[str]] = None,
+    ) -> Dict[str, Any]:
+        """
+        结果模式 — 消费 run(),返回结构化结果。
+
+        主要用于 agent/evaluate 工具内部。
+
+        Args:
+            on_event: 可选回调,每个 Trace/Message 事件触发一次,用于实时输出子 Agent 执行过程。
+            inject_skills: 本次调用需要指定注入的 skill 列表(透传给 run())。
+        """
+        last_assistant_text = ""
+        final_trace: Optional[Trace] = None
+
+        async for item in self.run(messages=messages, config=config, inject_skills=inject_skills):
+            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, config.tool_groups, config.exclude_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},
+            context=config.context,
+            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"
+        # 广播状态变化给前端
+        try:
+            from agent.trace.websocket import broadcast_trace_status_changed
+            await broadcast_trace_status_changed(config.trace_id, "running")
+        except Exception:
+            pass
+
+        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,
+        side_branch_ctx: Optional[SideBranchContext] = None,
+    ) -> Tuple[List[Dict], int, List[Message], int]:
+        """
+        构建完整的 LLM 消息历史
+
+        1. 从 head_sequence 沿 parent chain 加载主路径消息(续跑/回溯场景)
+        2. 构建 system prompt(新建时注入 skills)
+        3. 新建时:在第一条 user message 末尾注入当前经验
+        4. 追加 input messages(设置 parent_sequence 链接到当前 head)
+        5. 如果在侧分支中,追加的消息自动标记为侧分支消息
+
+        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:
+                # 如果在侧分支中,标记为侧分支消息
+                if side_branch_ctx:
+                    stored_msg = Message.create(
+                        trace_id=trace_id,
+                        role=msg_dict["role"],
+                        sequence=sequence,
+                        goal_id=goal_tree.current_id if goal_tree else None,
+                        parent_sequence=head_seq,
+                        branch_type=side_branch_ctx.type,
+                        branch_id=side_branch_ctx.branch_id,
+                        content=msg_dict.get("content"),
+                    )
+                    self.log.info(f"用户在侧分支 {side_branch_ctx.type} 中追加消息")
+                else:
+                    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 _manage_context_usage(
+        self,
+        trace_id: str,
+        history: List[Dict],
+        goal_tree: Optional[GoalTree],
+        config: RunConfig,
+        sequence: int,
+        head_seq: int,
+    ) -> Tuple[List[Dict], int, int, bool]:
+        """
+        管理 context 用量:检查、预警、压缩
+
+        Returns:
+            (updated_history, new_head_seq, next_sequence, needs_enter_compression_branch)
+        """
+        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)
+                self.log.warning(
+                    f"Context 使用率达到 {threshold}%: {token_count:,} / {max_tokens:,} tokens ({msg_count} 条消息)"
+                )
+
+        # 检查是否需要压缩(仅基于 token 数量)
+        needs_compression = token_count > max_tokens
+
+        if not needs_compression:
+            return history, head_seq, sequence, False
+
+        # 检查是否有待评估知识(压缩前必须先评估)
+        if self.trace_store and not config.force_side_branch:
+            pending = await self.trace_store.get_pending_knowledge_entries(trace_id)
+            if pending:
+                # 设置侧分支队列:反思 → 知识评估 → 压缩
+                # 反思放在前面,确保反思期间完成的 goal 产生的新知识也能在压缩前被评估
+                if config.knowledge.enable_extraction:
+                    config.force_side_branch = ["reflection", "knowledge_eval", "compression"]
+                else:
+                    config.force_side_branch = ["knowledge_eval", "compression"]
+
+                # 在 trace.context 中设置触发事件
+                trace = await self.trace_store.get_trace(trace_id)
+                if trace:
+                    if not trace.context:
+                        trace.context = {}
+                    trace.context["knowledge_eval_trigger"] = "compression"
+                    await self.trace_store.update_trace(trace_id, context=trace.context)
+
+                self.log.info(f"[Knowledge Eval] 压缩前触发知识评估,待评估: {len(pending)} 条")
+                return history, head_seq, sequence, True
+
+        # 知识提取:在任何压缩发生前,用完整 history 做反思(进入反思侧分支)
+        if config.knowledge.enable_extraction and not config.force_side_branch:
+            # 设置侧分支队列:先反思,再压缩
+            config.force_side_branch = ["reflection", "compression"]
+            return history, head_seq, sequence, True
+
+        # 以下为未启用反思、需要压缩的情况,直接进行level 1压缩,并检查是否需要进行level 2压缩(进入侧分支)
+        # Level 1 压缩:Goal 完成压缩
+        if config.goal_compression != "none" 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
+                )
+                compressed_msgs = compress_completed_goals(main_path_msgs, goal_tree)
+                if len(compressed_msgs) < len(main_path_msgs):
+                    self.log.info(
+                        "Level 1 压缩: %d -> %d 条消息",
+                        len(main_path_msgs), len(compressed_msgs),
+                    )
+                    history = [msg.to_llm_dict() for msg in compressed_msgs]
+                else:
+                    self.log.info(
+                        "Level 1 压缩: 无可过滤消息 (%d 条全部保留)",
+                        len(main_path_msgs),
+                    )
+        elif needs_compression:
+            self.log.warning(
+                "Token 数 (%d) 超过阈值,但无法执行 Level 1 压缩(缺少 store 或 goal_tree,或 goal_compression=none)",
+                token_count,
+            )
+
+        # Level 2 压缩:检查 Level 1 后是否仍超阈值
+        # 注意:Level 1 压缩后需要重新优化图片并计算 token
+        optimized_history_after = await self._optimize_images(history, config.model)
+        token_count_after = estimate_tokens(optimized_history_after)
+        needs_level2 = token_count_after > max_tokens
+
+        if needs_level2:
+            self.log.info(
+                "Level 1 后仍超阈值 (token=%d/%d),需要进入压缩侧分支",
+                token_count_after, max_tokens,
+            )
+            # 如果还没有设置侧分支(说明没有启用知识提取),直接进入压缩
+            if not config.force_side_branch:
+                config.force_side_branch = ["compression"]
+            # 返回标志,让主循环进入侧分支
+            return history, head_seq, sequence, True
+
+        # 压缩完成后,输出最终发给模型的消息列表
+        self.log.info("Level 1 压缩完成,发送给模型的消息列表:")
+        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]
+            self.log.info(f"  [{idx}] {role}: {preview}")
+
+        return history, head_seq, sequence, False
+
+    async def _build_knowledge_eval_prompt(
+        self,
+        trace_id: str,
+        goal_tree: Optional[GoalTree]
+    ) -> str:
+        """构建知识评估 prompt"""
+        if not self.trace_store:
+            return ""
+
+        pending = await self.trace_store.get_pending_knowledge_entries(trace_id)
+        if not pending:
+            return ""
+
+        # 获取mission
+        trace = await self.trace_store.get_trace(trace_id)
+        mission = trace.task if trace else "未知任务"
+
+        # 获取当前Goal
+        current_goal = goal_tree.find(goal_tree.current_id) if goal_tree and goal_tree.current_id else None
+        goal_desc = current_goal.description if current_goal else "无当前目标"
+
+        # 构建知识列表
+        knowledge_list = []
+        for idx, entry in enumerate(pending, 1):
+            knowledge_list.append(
+                f"### 知识 {idx}: {entry['knowledge_id']}\n"
+                f"- task: {entry['task']}\n"
+                f"- content: {entry['content']}\n"
+                f"- 注入于: sequence {entry['injected_at_sequence']}, goal {entry['goal_id']}"
+            )
+
+        prompt = f"""你是知识评估助手。请评估以下知识在本次任务执行中的实际效果。
+
+## 当前任务(Mission)
+{mission}
+
+## 当前 Goal
+{goal_desc}
+
+## 待评估知识列表
+{chr(10).join(knowledge_list)}
+
+## 评估维度
+1. **helpfulness**: 知识内容是否对完成任务有实质帮助?
+2. **relevance**: 执行过程中是否体现了该知识的内容?
+
+## 评估分类
+- irrelevant: task与当前任务无关
+- unused: 相关但未使用
+- helpful: 有帮助
+- harmful: 有负面作用
+- neutral: 无明显作用
+
+## 输出格式
+请直接输出评估结果,使用JSON格式:
+
+{{
+  "evaluations": [
+    {{
+      "knowledge_id": "knowledge-xxx",
+      "eval_status": "helpful",
+      "reason": "1-2句评估理由"
+    }}
+  ]
+}}
+"""
+        return prompt
+
+    async def _single_turn_compress(
+        self,
+        trace_id: str,
+        history: List[Dict],
+        goal_tree: Optional[GoalTree],
+        config: RunConfig,
+    ) -> str:
+        """单次 LLM 调用生成压缩摘要,返回 summary 文本"""
+
+        self.log.info("执行单次 LLM 压缩")
+
+        # 构建压缩 prompt(使用 SINGLE_TURN_PROMPT)
+        from agent.core.prompts import build_single_turn_prompt
+        goal_prompt = goal_tree.to_prompt(include_summary=True) if goal_tree else ""
+        compress_prompt = build_single_turn_prompt(goal_prompt)
+        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
+        )
+
+        # 单次 LLM 调用(无工具)
+        result = await self.llm_call(
+            messages=compress_messages,
+            model=config.model,
+            tools=[],  # 不提供工具
+            temperature=config.temperature,
+            **config.extra_llm_params,
+        )
+
+        summary_text = result.get("content", "").strip()
+
+        # 提取 [[SUMMARY]] 块
+        if "[[SUMMARY]]" in summary_text:
+            summary_text = summary_text[
+                summary_text.index("[[SUMMARY]]") + len("[[SUMMARY]]"):
+            ].strip()
+
+        return summary_text
+
+    @staticmethod
+    def _try_fix_json(s: str) -> Optional[dict]:
+        """尝试修复常见的 JSON 截断/格式问题,返回 dict 或 None"""
+        import re
+
+        fixed = s.strip()
+
+        # 1. 修复值中未转义的引号(如 "key": "he said "hello" to me")
+        # 策略:找到 key-value 模式中值字符串内部的裸引号并转义
+        def _fix_inner_quotes(text: str) -> str:
+            # 匹配 ": "..." 模式,修复值内部的未转义引号
+            result = []
+            i = 0
+            while i < len(text):
+                # 找到 ": " 后面的值字符串开头
+                if text[i] == '"':
+                    # 找到这个引号对应的字符串结束位置
+                    j = i + 1
+                    while j < len(text):
+                        if text[j] == '\\':
+                            j += 2  # 跳过转义字符
+                            continue
+                        if text[j] == '"':
+                            break
+                        j += 1
+                    # 检查引号后面是否是合法的 JSON 分隔符
+                    if j < len(text):
+                        after = j + 1
+                        # 跳过空白
+                        while after < len(text) and text[after] in ' \t\n\r':
+                            after += 1
+                        if after < len(text) and text[after] not in ':,}]\n\r':
+                            # 这个引号不是真正的结束引号,继续往后找
+                            # 找到下一个后面跟合法分隔符的引号
+                            k = j + 1
+                            found_end = False
+                            while k < len(text):
+                                if text[k] == '"':
+                                    peek = k + 1
+                                    while peek < len(text) and text[peek] in ' \t\n\r':
+                                        peek += 1
+                                    if peek >= len(text) or text[peek] in ':,}]':
+                                        # 这才是真正的结束引号,转义中间的引号
+                                        inner = text[i+1:k].replace('"', '\\"')
+                                        result.append('"' + inner + '"')
+                                        i = k + 1
+                                        found_end = True
+                                        break
+                                k += 1
+                            if found_end:
+                                continue
+                result.append(text[i])
+                i += 1
+            return ''.join(result)
+
+        fixed = _fix_inner_quotes(fixed)
+
+        # 2. 去掉尾部多余逗号
+        fixed = re.sub(r',\s*([}\]])', r'\1', fixed)
+
+        # 3. 尝试补全截断的字符串和括号
+        for suffix in ['', '"', '"}', '"]', '"}]', '"}}']:
+            try:
+                attempt = fixed + suffix
+                open_braces = attempt.count('{') - attempt.count('}')
+                open_brackets = attempt.count('[') - attempt.count(']')
+                attempt += '}' * max(0, open_braces) + ']' * max(0, open_brackets)
+                result = json.loads(attempt)
+                if isinstance(result, dict):
+                    self.log.info(f"[JSON Fix] 成功修复 JSON (suffix={repr(suffix)})")
+                    return result
+            except json.JSONDecodeError:
+                continue
+
+        return None
+
+    async def _agent_loop(
+        self,
+        trace: Trace,
+        history: List[Dict],
+        goal_tree: Optional[GoalTree],
+        config: RunConfig,
+        sequence: int,
+        inject_skills: Optional[List[str]] = None,
+        skill_recency_threshold: int = 10,
+    ) -> AsyncIterator[Union[Trace, Message]]:
+        """ReAct 循环"""
+        trace_id = trace.trace_id
+        tool_schemas = self._get_tool_schemas(config.tools, config.tool_groups, config.exclude_tools)
+
+        # 当前主路径头节点的 sequence(用于设置 parent_sequence)
+        head_seq = trace.head_sequence
+
+        # 侧分支状态(None = 主路径)
+        side_branch_ctx: Optional[SideBranchContext] = None
+
+        # 检查是否有未完成的侧分支需要恢复
+        if trace.context.get("active_side_branch"):
+            side_branch_data = trace.context["active_side_branch"]
+            branch_id = side_branch_data["branch_id"]
+            start_sequence = side_branch_data["start_sequence"]
+
+            # 从数据库查询侧分支消息(按 sequence 范围)
+            if self.trace_store:
+                all_messages = await self.trace_store.get_trace_messages(trace_id)
+                side_messages = [
+                    m for m in all_messages
+                    if m.sequence >= start_sequence
+                ]
+
+                # 恢复侧分支上下文
+                side_branch_ctx = SideBranchContext(
+                    type=side_branch_data["type"],
+                    branch_id=branch_id,
+                    start_head_seq=side_branch_data["start_head_seq"],
+                    start_sequence=side_branch_data["start_sequence"],
+                    start_history_length=0,  # 稍后重新计算
+                    start_iteration=side_branch_data.get("start_iteration", 0),
+                    max_turns=side_branch_data.get("max_turns", config.side_branch_max_turns),
+                )
+
+                self.log.info(
+                    f"恢复未完成的侧分支: {side_branch_ctx.type}, "
+                    f"max_turns={side_branch_ctx.max_turns}"
+                )
+
+                # 将侧分支消息追加到 history
+                for m in side_messages:
+                    history.append(m.to_llm_dict())
+
+                # 重新计算 start_history_length
+                side_branch_ctx.start_history_length = len(history) - len(side_messages)
+
+        break_after_side_branch = False  # 侧分支退出后是否 break 主循环
+
+        for iteration in range(config.max_iterations):
+            # 更新活动时间(表明trace正在活跃运行)
+            if self.trace_store:
+                await self.trace_store.update_trace(
+                    trace_id,
+                    last_activity_at=datetime.now()
+                )
+
+            # 检查取消信号
+            cancel_event = self._cancel_events.get(trace_id)
+            if cancel_event and cancel_event.is_set():
+                self.log.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(),
+                    )
+                    # 广播状态变化给前端
+                    try:
+                        from agent.trace.websocket import broadcast_trace_status_changed
+                        await broadcast_trace_status_changed(trace_id, "stopped")
+                    except Exception:
+                        pass
+                    trace_obj = await self.trace_store.get_trace(trace_id)
+                    if trace_obj:
+                        yield trace_obj
+                return
+
+            # 检查Goal完成触发的知识评估
+            if not side_branch_ctx and self.trace_store:
+                trace = await self.trace_store.get_trace(trace_id)
+                if trace and trace.context and trace.context.get("pending_knowledge_eval"):
+                    # 清除标志
+                    trace.context.pop("pending_knowledge_eval", None)
+                    await self.trace_store.update_trace(trace_id, context=trace.context)
+                    # 设置侧分支队列
+                    config.force_side_branch = ["knowledge_eval"]
+                    self.log.info("[Knowledge Eval] 检测到Goal完成触发,进入知识评估侧分支")
+
+            # Context 管理(仅主路径)
+            needs_enter_side_branch = False
+            if not side_branch_ctx:
+                # 侧分支退出后需要 break 主循环
+                if break_after_side_branch and not config.force_side_branch:
+                    break
+
+                # 检查是否强制进入侧分支(API 手动触发或自动压缩流程)
+                if config.force_side_branch:
+                    needs_enter_side_branch = True
+                    self.log.info(f"强制进入侧分支: {config.force_side_branch}")
+                else:
+                    # 正常的 context 管理逻辑
+                    history, head_seq, sequence, needs_enter_side_branch = await self._manage_context_usage(
+                        trace_id, history, goal_tree, config, sequence, head_seq
+                    )
+
+            # 进入侧分支
+            if needs_enter_side_branch and not side_branch_ctx:
+                # 刷新 trace,获取 _manage_context_usage 可能写入 DB 的 knowledge_eval_trigger
+                if self.trace_store:
+                    fresh = await self.trace_store.get_trace(trace_id)
+                    if fresh:
+                        trace = fresh
+                # 从队列中取出第一个侧分支类型
+                branch_type: Literal["compression", "reflection", "knowledge_eval"]
+                if config.force_side_branch and isinstance(config.force_side_branch, list) and len(config.force_side_branch) > 0:
+                    branch_type = config.force_side_branch.pop(0)  # type: ignore
+                    self.log.info(f"从队列取出侧分支: {branch_type}, 剩余队列: {config.force_side_branch}")
+                elif config.knowledge.enable_extraction:
+                    # 兼容旧的单值模式(如果 force_side_branch 是字符串)
+                    branch_type = "reflection"
+                else:
+                    # 自动触发:压缩
+                    branch_type = "compression"
+
+                branch_id = f"{branch_type}_{uuid.uuid4().hex[:8]}"
+
+                side_branch_ctx = SideBranchContext(
+                    type=branch_type,
+                    branch_id=branch_id,
+                    start_head_seq=head_seq,
+                    start_sequence=sequence,
+                    start_history_length=len(history),
+                    start_iteration=iteration,
+                    max_turns=config.side_branch_max_turns,
+                )
+
+                # 持久化侧分支状态
+                if self.trace_store:
+                    # 获取触发事件(如果是 knowledge_eval 分支)
+                    trigger_event = trace.context.get("knowledge_eval_trigger", "unknown") if branch_type == "knowledge_eval" else None
+
+                    trace.context["active_side_branch"] = {
+                        "type": side_branch_ctx.type,
+                        "branch_id": side_branch_ctx.branch_id,
+                        "start_head_seq": side_branch_ctx.start_head_seq,
+                        "start_sequence": side_branch_ctx.start_sequence,
+                        "start_iteration": side_branch_ctx.start_iteration,
+                        "max_turns": side_branch_ctx.max_turns,
+                        "started_at": datetime.now().isoformat(),
+                    }
+
+                    # 如果是 knowledge_eval 分支,添加 trigger_event
+                    if trigger_event:
+                        trace.context["active_side_branch"]["trigger_event"] = trigger_event
+                        # 清除触发事件标记
+                        trace.context.pop("knowledge_eval_trigger", None)
+
+                    await self.trace_store.update_trace(
+                        trace_id,
+                        context=trace.context
+                    )
+
+                # 追加侧分支 prompt
+                if branch_type == "reflection":
+                    # 完成场景用全局复盘 prompt,压缩场景用阶段性反思 prompt
+                    if break_after_side_branch:
+                        prompt = config.knowledge.get_completion_reflect_prompt()
+                    else:
+                        prompt = config.knowledge.get_reflect_prompt()
+                elif branch_type == "knowledge_eval":
+                    prompt = await self._build_knowledge_eval_prompt(trace_id, goal_tree)
+                else:  # compression
+                    from agent.trace.compaction import build_compression_prompt
+                    prompt = build_compression_prompt(goal_tree)
+
+                branch_user_msg = Message.create(
+                    trace_id=trace_id,
+                    role="user",
+                    sequence=sequence,
+                    parent_sequence=head_seq,
+                    goal_id=goal_tree.current_id if goal_tree else None,
+                    branch_type=branch_type,
+                    branch_id=branch_id,
+                    content=prompt,
+                )
+
+                if self.trace_store:
+                    await self.trace_store.add_message(branch_user_msg)
+
+                history.append(branch_user_msg.to_llm_dict())
+                head_seq = sequence
+                sequence += 1
+
+                self.log.info(f"进入侧分支: {branch_type}, branch_id={branch_id}")
+                continue  # 跳过本轮,下一轮开始侧分支
+
+            # 构建 LLM messages(注入上下文,移除内部字段)
+            llm_messages = [{k: v for k, v in msg.items() if not k.startswith("_")} for msg in history]
+
+            # 优化已处理的图片(分级处理:保留/压缩/描述)
+            llm_messages = await self._optimize_images(llm_messages, config.model)
+
+            # 对历史消息应用 Prompt Caching
+            llm_messages = self._add_cache_control(
+                llm_messages,
+                config.model,
+                config.enable_prompt_caching
+            )
+
+            # 调用 LLM(等待完成后再检查 cancel 信号,不中断正在进行的调用)
+            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", "")
+            reasoning_content = result.get("reasoning_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")
+
+            # 周期性自动注入上下文(仅主路径)
+            if not side_branch_ctx and iteration % CONTEXT_INJECTION_INTERVAL == 0:
+                # 检查是否已经调用了 get_current_context
+                if tool_calls:
+                    has_context_call = any(
+                        tc.get("function", {}).get("name") == "get_current_context"
+                        for tc in tool_calls
+                    )
+                else:
+                    has_context_call = False
+                    tool_calls = []
+
+                if not has_context_call:
+                    # 手动添加 get_current_context 工具调用
+                    context_call_id = f"call_context_{uuid.uuid4().hex[:8]}"
+                    tool_calls.append({
+                        "id": context_call_id,
+                        "type": "function",
+                        "function": {"name": "get_current_context", "arguments": "{}"}
+                    })
+                    self.log.info(f"[周期性注入] 自动添加 get_current_context 工具调用 (iteration={iteration})")
+
+            # Skill 指定注入(仅主路径,首轮 iteration==0 时执行)
+            if not side_branch_ctx and inject_skills and iteration == 0:
+                skills_to_inject = self._check_skills_need_injection(
+                    trace, inject_skills, history, skill_recency_threshold
+                )
+                if skills_to_inject:
+                    if not tool_calls:
+                        tool_calls = []
+                    for skill_name in skills_to_inject:
+                        skill_call_id = f"call_skill_{skill_name}_{uuid.uuid4().hex[:8]}"
+                        tool_calls.append({
+                            "id": skill_call_id,
+                            "type": "function",
+                            "function": {
+                                "name": "skill",
+                                "arguments": json.dumps({"skill_name": skill_name})
+                            }
+                        })
+                        self.log.info(f"[Skill 指定注入] 自动添加 skill(\"{skill_name}\") 工具调用")
+
+            # 按需自动创建 root goal(仅主路径)
+            if not side_branch_ctx and 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
+                )
+                self.log.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
+                    )
+                    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)
+                    self.log.info(f"自动创建 root goal: {goal_tree.goals[0].id}(未自动 focus,等待模型决定)")
+                else:
+                    self.log.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,
+                branch_type=side_branch_ctx.type if side_branch_ctx else None,
+                branch_id=side_branch_ctx.branch_id if side_branch_ctx else None,
+                content={"text": response_content, "tool_calls": tool_calls, "reasoning_content": reasoning_content or None},
+                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,
+                    role="assistant",
+                    model=config.model,
+                    prompt_tokens=prompt_tokens,
+                    completion_tokens=completion_tokens,
+                    cache_read_tokens=cache_read_tokens or 0,
+                )
+
+            # 知识评估侧分支:即时检测并写入评估结果
+            if side_branch_ctx and side_branch_ctx.type == "knowledge_eval":
+                text = response_content if isinstance(response_content, str) else ""
+                eval_results = None
+
+                try:
+                    eval_results = json.loads(text.strip())
+                    if "evaluations" not in eval_results:
+                        eval_results = None
+                except json.JSONDecodeError:
+                    import re
+                    json_match = re.search(r'```json\s*(\{.*?\})\s*```', text, re.DOTALL)
+                    if json_match:
+                        try:
+                            eval_results = json.loads(json_match.group(1))
+                        except json.JSONDecodeError:
+                            pass
+
+                    if not eval_results:
+                        json_match = re.search(r'\{[^{]*"evaluations"[^}]*\[[^\]]*\][^}]*\}', text, re.DOTALL)
+                        if json_match:
+                            try:
+                                eval_results = json.loads(json_match.group(0))
+                            except json.JSONDecodeError:
+                                pass
+
+                if eval_results and self.trace_store:
+                    current_trace = await self.trace_store.get_trace(trace_id)
+                    trigger_event = current_trace.context.get("active_side_branch", {}).get("trigger_event", "unknown")
+
+                    for eval_item in eval_results.get("evaluations", []):
+                        await self.trace_store.update_knowledge_evaluation(
+                            trace_id=trace_id,
+                            knowledge_id=eval_item["knowledge_id"],
+                            eval_result={
+                                "eval_status": eval_item["eval_status"],
+                                "reason": eval_item.get("reason", "")
+                            },
+                            trigger_event=trigger_event
+                        )
+                    self.log.info(f"[Knowledge Eval] 已写入 {len(eval_results.get('evaluations', []))} 条评估结果")
+
+            # 如果在侧分支,记录到 assistant_msg(已持久化,不需要额外维护)
+
+            yield assistant_msg
+            head_seq = sequence
+            sequence += 1
+
+            # 检查侧分支是否应该退出
+            if side_branch_ctx:
+                # 计算侧分支已执行的轮次
+                turns_in_branch = iteration - side_branch_ctx.start_iteration
+                should_exit = turns_in_branch >= side_branch_ctx.max_turns or not tool_calls
+
+                if turns_in_branch >= side_branch_ctx.max_turns:
+                    self.log.warning(
+                        f"侧分支 {side_branch_ctx.type} 达到最大轮次 "
+                        f"{side_branch_ctx.max_turns},强制退出"
+                    )
+
+                if should_exit and side_branch_ctx.type == "compression":
+                    # === 压缩侧分支退出(超时 + 正常完成统一处理)===
+                    summary_text = ""
+
+                    # 1. 从当前回复提取
+                    if response_content:
+                        if "[[SUMMARY]]" in response_content:
+                            summary_text = response_content[
+                                response_content.index("[[SUMMARY]]") + len("[[SUMMARY]]"):
+                            ].strip()
+                        elif response_content.strip():
+                            summary_text = response_content.strip()
+
+                    # 2. 从持久化存储按 sequence 范围查询
+                    if not summary_text and self.trace_store:
+                        all_messages = await self.trace_store.get_trace_messages(trace_id)
+                        side_messages = [
+                            m for m in all_messages
+                            if m.sequence >= side_branch_ctx.start_sequence
+                        ]
+                        for msg in reversed(side_messages):
+                            if msg.role == "assistant" and isinstance(msg.content, dict):
+                                text = msg.content.get("text", "")
+                                if "[[SUMMARY]]" in text:
+                                    summary_text = text[text.index("[[SUMMARY]]") + len("[[SUMMARY]]"):].strip()
+                                    break
+                                elif text:
+                                    summary_text = text
+                                    break
+
+                    # 3. 单次 LLM 调用
+                    if not summary_text:
+                        self.log.warning("侧分支未生成有效 summary,fallback 到单次 LLM 压缩")
+                        pre_branch_history = history[:side_branch_ctx.start_history_length]
+                        summary_text = await self._single_turn_compress(
+                            trace_id, pre_branch_history, goal_tree, config,
+                        )
+
+                    # 创建主路径 summary 消息并重建 history
+                    if summary_text:
+                        # 清理侧分支指令,防止泄露到主分支
+                        summary_text = summary_text.replace(
+                            "**生成摘要后立即停止,不要继续执行原有任务。**", ""
+                        ).strip()
+                        from agent.core.prompts import build_summary_header
+                        summary_content = build_summary_header(summary_text)
+
+                        if goal_tree and goal_tree.goals:
+                            goal_tree_detail = goal_tree.to_prompt(include_summary=True)
+                            summary_content += f"\n\n## Current Plan\n\n{goal_tree_detail}"
+
+                        # 找第一条 user message 的 sequence 作为 parent
+                        # 续跑时 get_main_path_messages 沿 parent 链回溯,
+                        # 指向 first_user 可以跳过所有被压缩的中间消息
+                        first_user_seq = None
+                        if self.trace_store:
+                            all_msgs = await self.trace_store.get_trace_messages(trace_id)
+                            for m in all_msgs:
+                                if m.role == "user":
+                                    first_user_seq = m.sequence
+                                    break
+
+                        summary_msg = Message.create(
+                            trace_id=trace_id,
+                            role="user",
+                            sequence=sequence,
+                            parent_sequence=first_user_seq,
+                            branch_type=None,
+                            content=summary_content,
+                        )
+
+                        if self.trace_store:
+                            await self.trace_store.add_message(summary_msg)
+
+                        history = self._rebuild_history_after_compression(
+                            history, summary_msg.to_llm_dict(), label="压缩侧分支"
+                        )
+                        head_seq = sequence
+                        sequence += 1
+                    else:
+                        self.log.error("所有压缩方案均未生成有效 summary,跳过压缩")
+                        # 回退 history 到侧分支开始前,防止侧分支指令泄露到主分支
+                        history = history[:side_branch_ctx.start_history_length]
+                        head_seq = side_branch_ctx.start_head_seq
+
+                    # 清理
+                    trace.context.pop("active_side_branch", None)
+                    config.force_side_branch = None
+                    if self.trace_store:
+                        await self.trace_store.update_trace(
+                            trace_id, context=trace.context, head_sequence=head_seq,
+                        )
+                    side_branch_ctx = None
+                    continue
+
+                elif should_exit and side_branch_ctx.type == "reflection":
+                    # === 反思侧分支退出(超时 + 正常完成统一处理)===
+                    self.log.info("反思侧分支退出")
+
+                    # auto-commit hook:默认 pending 要等人工 review,
+                    # 但 reflect_auto_commit=True 时视作全部 approved,直接批量 upload。
+                    if (
+                        self.trace_store
+                        and getattr(config.knowledge, "reflect_auto_commit", False)
+                    ):
+                        try:
+                            from agent.trace.extraction_review import auto_commit_branch
+                            report = await auto_commit_branch(
+                                self.trace_store,
+                                trace_id,
+                                side_branch_ctx.branch_id,
+                            )
+                            if report.committed or report.failed:
+                                self.log.info(
+                                    f"[auto-commit] committed={len(report.committed)} "
+                                    f"failed={len(report.failed)} skipped={len(report.skipped)}"
+                                )
+                        except Exception as e:
+                            self.log.error(f"[auto-commit] 反思分支自动提交失败: {e}")
+
+                    # 恢复主路径
+                    if self.trace_store:
+                        main_path_messages = await self.trace_store.get_main_path_messages(
+                            trace_id, side_branch_ctx.start_head_seq
+                        )
+                        history = [m.to_llm_dict() for m in main_path_messages]
+                        head_seq = side_branch_ctx.start_head_seq
+
+                    # 清理
+                    trace.context.pop("active_side_branch", None)
+                    if not config.force_side_branch or len(config.force_side_branch) == 0:
+                        config.force_side_branch = None
+                        self.log.info("反思完成,队列为空")
+                    if self.trace_store:
+                        await self.trace_store.update_trace(
+                            trace_id, context=trace.context, head_sequence=head_seq,
+                        )
+                    side_branch_ctx = None
+                    continue
+
+                elif should_exit and side_branch_ctx.type == "knowledge_eval":
+                    # === 知识评估侧分支退出 ===
+                    self.log.info("知识评估侧分支退出")
+
+                    # 恢复主路径
+                    if self.trace_store:
+                        main_path_messages = await self.trace_store.get_main_path_messages(
+                            trace_id, side_branch_ctx.start_head_seq
+                        )
+                        history = [m.to_llm_dict() for m in main_path_messages]
+                        head_seq = side_branch_ctx.start_head_seq
+
+                    # 清理
+                    trace.context.pop("active_side_branch", None)
+                    if not config.force_side_branch or len(config.force_side_branch) == 0:
+                        config.force_side_branch = None
+                        self.log.info("知识评估完成,队列为空")
+                    if self.trace_store:
+                        await self.trace_store.update_trace(
+                            trace_id, context=trace.context, head_sequence=head_seq,
+                        )
+                    side_branch_ctx = None
+                    continue
+
+            # 处理工具调用
+            # 截断兜底:finish_reason == "length" 说明响应被 max_tokens 截断,
+            # tool call 参数很可能不完整,不应执行,改为提示模型分批操作
+            if tool_calls and finish_reason == "length":
+                self.log.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,
+                })
+
+                if config.parallel_tool_execution:
+                    # === 并发执行 ===
+                    current_goal_id = goal_tree.current_id if (goal_tree and goal_tree.current_id) else None
+                    async def _execute_single_tool(tc: dict) -> tuple:
+                        tool_name = tc["function"]["name"]
+                        tool_args = tc["function"]["arguments"]
+                        if isinstance(tool_args, str):
+                            if not tool_args.strip():
+                                tool_args = {}
+                            else:
+                                try:
+                                    tool_args = json.loads(tool_args)
+                                except json.JSONDecodeError:
+                                    tool_args = self._try_fix_json(tool_args)
+                                    if tool_args is None:
+                                        self.log.warning(f"[Tool Call] JSON 解析失败: {tc['function']['arguments'][:200]}")
+                                        tc["function"]["arguments"] = json.dumps({"_error": "JSON parse failed", "_raw": tc["function"]["arguments"][:200]}, ensure_ascii=False)
+                                        return (tc, None, f"Error: 工具参数 JSON 格式错误,无法解析。原始参数: {tc['function']['arguments'][:200]}")
+                        elif tool_args is None:
+                            tool_args = {}
+                        args_str = json.dumps(tool_args, ensure_ascii=False)
+                        args_display = args_str[:100] + "..." if len(args_str) > 100 else args_str
+                        self.log.info(f"[Tool Call] {tool_name}({args_display})")
+                        trigger_event_for_tool = None
+                        if side_branch_ctx and side_branch_ctx.type == "knowledge_eval" and self.trace_store:
+                            current_trace = await self.trace_store.get_trace(trace_id)
+                            if current_trace:
+                                trigger_event_for_tool = current_trace.context.get("active_side_branch", {}).get("trigger_event", "unknown")
+                        if tool_name in ("toolhub_call", "toolhub_search", "toolhub_health"):
+                            try:
+                                from agent.tools.builtin.toolhub import set_trace_context
+                                set_trace_context(trace_id)
+                            except ImportError:
+                                pass
+                        try:
+                            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, "knowledge_config": config.knowledge, "sequence": sequence, "side_branch": {"type": side_branch_ctx.type, "branch_id": side_branch_ctx.branch_id, "is_side_branch": True, "max_turns": side_branch_ctx.max_turns, "trigger_event": trigger_event_for_tool} if side_branch_ctx else None, **(config.context or {})}
+                            )
+                            return (tc, tool_args, tool_result)
+                        except Exception as e:
+                            import traceback
+                            return (tc, tool_args, f"Error executing tool {tool_name}: {str(e)}\n{traceback.format_exc()}")
+                    tasks = [_execute_single_tool(tc) for tc in tool_calls]
+                    results = await asyncio.gather(*tasks)
+                    for res in results:
+                        tc, tool_args, tool_result = res
+                        tool_name = tc["function"]["name"]
+                        if tool_args is None:
+                            history.append({"role": "tool", "tool_call_id": tc["id"], "name": tool_name, "content": tool_result})
+                            yield Message.create(trace_id=trace_id, role="tool", sequence=sequence, parent_sequence=head_seq, tool_call_id=tc["id"], content=tool_result)
+                            head_seq = sequence
+                            sequence += 1
+                            continue
+                        if tool_name == "goal" and goal_tree:
+                            self.log.debug(f"[Goal Tool] After execution: goal_tree.goals={len(goal_tree.goals)}, current_id={goal_tree.current_id}")
+                        if tool_name == "upload_knowledge" and isinstance(tool_result, dict):
+                            self.log.info(f"[Knowledge Tracking] 知识已上传")
+                        if isinstance(tool_result, str):
+                            tool_result = {"text": tool_result}
+                        elif not isinstance(tool_result, dict):
+                            tool_result = {"text": str(tool_result)}
+                        tool_text = tool_result.get("text", str(tool_result))
+                        tool_images = tool_result.get("images", [])
+                        tool_usage = tool_result.get("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']}"}})
+                                elif img.get("type") == "url" and img.get("url"):
+                                    tool_content_for_llm.append({"type": "image_url", "image_url": {"url": img["url"]}})
+                        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"], branch_type=side_branch_ctx.type if side_branch_ctx else None, branch_id=side_branch_ctx.branch_id if side_branch_ctx else None, 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))
+                            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"]))
+                                        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, "_message_id": tool_msg.message_id})
+                        if tool_name == "skill" and tc["id"].startswith("call_skill_"):
+                            try:
+                                skill_args = json.loads(tc["function"]["arguments"]) if isinstance(tc["function"]["arguments"], str) else tc["function"]["arguments"]
+                                injected_skill_name = skill_args.get("skill_name", "")
+                                if injected_skill_name:
+                                    await self._update_skill_injection_record(trace_id, trace, injected_skill_name, tool_msg.message_id, tool_msg.sequence)
+                                    self.log.info(f"[Skill 指定注入] 已记录 {injected_skill_name} → msg={tool_msg.message_id}")
+                            except Exception as e:
+                                self.log.warning(f"[Skill 指定注入] 记录追踪失败: {e}")
+                else:
+                    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):
+                            if not tool_args.strip():
+                                tool_args = {}
+                            else:
+                                try:
+                                    tool_args = json.loads(tool_args)
+                                except json.JSONDecodeError:
+                                    # 尝试修复常见的截断/格式问题
+                                    tool_args = self._try_fix_json(tool_args)
+                                    if tool_args is None:
+                                        self.log.warning(f"[Tool Call] JSON 解析失败,跳过工具调用 {tool_name}: {tc['function']['arguments'][:200]}")
+                                        # 修复 history 中 assistant message 里的残缺 JSON,
+                                        # 避免 Qwen API 拒绝 "function.arguments must be in JSON format"
+                                        tc["function"]["arguments"] = json.dumps(
+                                            {"_error": "JSON parse failed", "_raw": tc["function"]["arguments"][:200]},
+                                            ensure_ascii=False,
+                                        )
+                                        history.append({
+                                            "role": "tool",
+                                            "tool_call_id": tc["id"],
+                                            "content": f"Error: 工具参数 JSON 格式错误,无法解析。请重新生成正确的 JSON 参数调用此工具。原始参数: {tc['function']['arguments'][:200]}",
+                                        })
+                                        # 注意:这里不 yield Message,因为缺少必需参数会导致错误
+                                        # yield Message 应该由 trace_store 统一管理
+                                        continue
+                        elif tool_args is None:
+                            tool_args = {}
+    
+                        # 记录工具调用(INFO 级别,显示参数)
+                        args_str = json.dumps(tool_args, ensure_ascii=False)
+                        args_display = args_str[:100] + "..." if len(args_str) > 100 else args_str
+                        self.log.info(f"[Tool Call] {tool_name}({args_display})")
+    
+                        # 获取trigger_event(如果在knowledge_eval侧分支中)
+                        trigger_event_for_tool = None
+                        if side_branch_ctx and side_branch_ctx.type == "knowledge_eval" and self.trace_store:
+                            current_trace = await self.trace_store.get_trace(trace_id)
+                            if current_trace:
+                                trigger_event_for_tool = current_trace.context.get("active_side_branch", {}).get("trigger_event", "unknown")
+    
+                        # 设置 trace_id 上下文供 toolhub 使用(图片保存到 outputs/{trace_id}/)
+                        if tool_name in ("toolhub_call", "toolhub_search", "toolhub_health"):
+                            try:
+                                from agent.tools.builtin.toolhub import set_trace_context
+                                set_trace_context(trace_id)
+                            except ImportError:
+                                pass
+    
+                        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,
+                                "knowledge_config": config.knowledge,
+                                "sequence": sequence,  # 添加sequence用于知识注入记录
+                                # 新增:侧分支信息
+                                "side_branch": {
+                                    "type": side_branch_ctx.type,
+                                    "branch_id": side_branch_ctx.branch_id,
+                                    "is_side_branch": True,
+                                    "max_turns": side_branch_ctx.max_turns,
+                                    "trigger_event": trigger_event_for_tool,
+                                } if side_branch_ctx else None,
+                                # 合并用户自定义 context(RunConfig.context)
+                                **(config.context or {}),
+                            },
+                        )
+    
+                        # 如果是 goal 工具,记录执行后的状态
+                        if tool_name == "goal" and goal_tree:
+                            self.log.debug(f"[Goal Tool] After execution: goal_tree.goals={len(goal_tree.goals)}, current_id={goal_tree.current_id}")
+    
+                        # 跟踪上传的知识(通过 upload_knowledge)
+                        if tool_name == "upload_knowledge" and isinstance(tool_result, dict):
+                            metadata = tool_result.get("metadata", {})
+                            # upload_knowledge 返回的是统计信息,不是单个 knowledge_id
+                            # 这里只记录上传动作,不跟踪具体 ID
+                            self.log.info(f"[Knowledge Tracking] 知识已上传到 Knowledge Manager")
+    
+                        # --- 支持多模态工具反馈 ---
+                        # 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']}"
+                                        }
+                                    })
+                                elif img.get("type") == "url" and img.get("url"):
+                                    tool_content_for_llm.append({
+                                        "type": "image_url",
+                                        "image_url": {
+                                            "url": img["url"]
+                                        }
+                                    })
+                            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"],
+                            branch_type=side_branch_ctx.type if side_branch_ctx else None,
+                            branch_id=side_branch_ctx.branch_id if side_branch_ctx else None,
+                            # 存储完整内容:有图片时保留 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  # 只存第一张
+    
+                        # 如果在侧分支,tool_msg 已持久化(不需要额外维护)
+    
+                        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,
+                            "_message_id": tool_msg.message_id,
+                        })
+    
+                        # 更新 skill 注入追踪记录
+                        if tool_name == "skill" and tc["id"].startswith("call_skill_"):
+                            try:
+                                skill_args = json.loads(tc["function"]["arguments"]) if isinstance(tc["function"]["arguments"], str) else tc["function"]["arguments"]
+                                injected_skill_name = skill_args.get("skill_name", "")
+                                if injected_skill_name:
+                                    await self._update_skill_injection_record(
+                                        trace_id, trace, injected_skill_name,
+                                        tool_msg.message_id, tool_msg.sequence,
+                                    )
+                                    self.log.info(f"[Skill 指定注入] 已记录 {injected_skill_name} → msg={tool_msg.message_id}")
+                            except Exception as e:
+                                self.log.warning(f"[Skill 指定注入] 记录追踪失败: {e}")
+
+                # on_complete 模式:goal(done=...) 后立即压缩该 goal 的消息
+                if (
+                    not side_branch_ctx
+                    and config.goal_compression == "on_complete"
+                    and self.trace_store
+                    and goal_tree
+                ):
+                    has_goal_done = False
+                    for tc in tool_calls:
+                        if tc["function"]["name"] != "goal":
+                            continue
+                        try:
+                            raw = tc["function"]["arguments"]
+                            args = json.loads(raw) if isinstance(raw, str) and raw.strip() else {}
+                        except (json.JSONDecodeError, TypeError):
+                            args = {}
+                        if args.get("done") is not None:
+                            has_goal_done = True
+                            break
+
+                    if has_goal_done:
+                        main_path_msgs = await self.trace_store.get_main_path_messages(
+                            trace_id, head_seq
+                        )
+                        compressed_msgs = compress_completed_goals(main_path_msgs, goal_tree)
+                        if len(compressed_msgs) < len(main_path_msgs):
+                            self.log.info(
+                                "on_complete 压缩: %d -> %d 条消息",
+                                len(main_path_msgs), len(compressed_msgs),
+                            )
+                            history = [msg.to_llm_dict() for msg in compressed_msgs]
+
+                continue  # 继续循环
+
+            # 无工具调用
+            # 如果在侧分支中,已经在上面处理过了(不会走到这里)
+            # 主路径无工具调用 → 任务完成,检查是否需要完成后反思或知识评估
+
+            # 检查是否有待评估的知识
+            if not side_branch_ctx and self.trace_store:
+                pending = await self.trace_store.get_pending_knowledge_entries(trace_id)
+                if pending:
+                    self.log.info(f"任务即将结束,但仍有 {len(pending)} 条知识未评估,强制触发评估")
+                    config.force_side_branch = ["knowledge_eval"]
+                    trace = await self.trace_store.get_trace(trace_id)
+                    if trace:
+                        trace.context["knowledge_eval_trigger"] = "task_completion"
+                        await self.trace_store.update_trace(trace_id, context=trace.context)
+                    continue
+
+            if not side_branch_ctx and config.knowledge.enable_completion_extraction and not break_after_side_branch:
+                config.force_side_branch = ["reflection"]
+                break_after_side_branch = True
+                self.log.info("任务完成,进入完成后反思侧分支")
+                continue
+
+            break
+
+        # 清理 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
+
+    # ===== 压缩辅助方法 =====
+
+    def _rebuild_history_after_compression(
+        self,
+        history: List[Dict],
+        summary_msg_dict: Dict,
+        label: str = "压缩",
+    ) -> List[Dict]:
+        """
+        压缩后重建 history:system prompt + 第一条 user message + summary
+
+        Args:
+            history: 压缩前的 history
+            summary_msg_dict: summary 消息的 LLM dict
+            label: 日志标签
+
+        Returns:
+            新的 history
+        """
+        system_msg = None
+        first_user_msg = None
+        for msg in history:
+            if msg.get("role") == "system" and not system_msg:
+                system_msg = msg
+            elif msg.get("role") == "user" and not first_user_msg:
+                first_user_msg = msg
+            if system_msg and first_user_msg:
+                break
+
+        new_history = []
+        if system_msg:
+            new_history.append(system_msg)
+        if first_user_msg:
+            new_history.append(first_user_msg)
+        new_history.append(summary_msg_dict)
+
+        self.log.info(f"{label}完成: {len(history)} → {len(new_history)} 条消息")
+        for idx, msg in enumerate(new_history):
+            role = msg.get("role", "unknown")
+            content = msg.get("content", "")
+            if isinstance(content, str):
+                preview = content
+            elif isinstance(content, list):
+                preview = f"[{len(content)} blocks]"
+            else:
+                preview = str(content)
+            self.log.info(f"  {label}后[{idx}] {role}: {preview}")
+
+        return new_history
+
+    # ===== 回溯(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
+
+        self.log.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)
+
+    # ===== 上下文注入 =====
+
+    # ===== Skill 指定注入 =====
+
+    def _check_skills_need_injection(
+        self,
+        trace: Trace,
+        inject_skills: List[str],
+        history: List[Dict],
+        recency_threshold: int,
+    ) -> List[str]:
+        """
+        检查哪些 skill 需要注入。
+
+        通过 trace.context["injected_skills"] 中记录的 message_id
+        检查是否仍在当前 history 的最近 recency_threshold 条消息中。
+
+        Returns:
+            需要注入的 skill 名称列表
+        """
+        injected = (trace.context or {}).get("injected_skills", {})
+        # 收集 history 中最近 recency_threshold 条消息的 message_id
+        recent_msgs = history[-recency_threshold:] if recency_threshold > 0 else []
+        recent_ids = set()
+        for msg in recent_msgs:
+            mid = msg.get("message_id") or msg.get("_message_id")
+            if mid:
+                recent_ids.add(mid)
+
+        needs_inject = []
+        for skill_name in inject_skills:
+            record = injected.get(skill_name)
+            if not record:
+                needs_inject.append(skill_name)
+                continue
+            if record.get("message_id") not in recent_ids:
+                needs_inject.append(skill_name)
+
+        return needs_inject
+
+    async def _update_skill_injection_record(
+        self,
+        trace_id: str,
+        trace: Trace,
+        skill_name: str,
+        message_id: str,
+        sequence: int,
+    ):
+        """更新 trace.context 中的 skill 注入记录"""
+        if not trace.context:
+            trace.context = {}
+        if "injected_skills" not in trace.context:
+            trace.context["injected_skills"] = {}
+        trace.context["injected_skills"][skill_name] = {
+            "message_id": message_id,
+            "sequence": sequence,
+        }
+        if self.trace_store:
+            await self.trace_store.update_trace(trace_id, context=trace.context)
+
+    # ===== 上下文注入 =====
+
+    def _build_context_injection(
+        self,
+        trace: Trace,
+        goal_tree: Optional[GoalTree],
+    ) -> str:
+        """构建周期性注入的上下文(GoalTree + Active Collaborators + Focus 提醒 + IM 消息通知)"""
+        from datetime import datetime
+        parts = [f"## Current Time\n\n{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"]
+
+        # GoalTree
+        if goal_tree and goal_tree.goals:
+            parts.append(f"## Current Plan\n\n{goal_tree.to_prompt()}")
+
+            if goal_tree.current_id:
+                # 检测 focus 在有子节点的父目标上:提醒模型 focus 到具体子目标
+                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})再执行。"
+                    )
+            else:
+                # 无焦点:提醒模型 focus
+                parts.append(
+                    "**提醒**:当前没有焦点目标。请用 `goal(focus=\"...\")` 选择一个目标开始执行。"
+                )
+
+        # 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))
+
+        # IM 消息通知(Research Agent)
+        im_config = trace.context.get("im_config")
+        if im_config:
+            contact_id = im_config.get("contact_id")
+            chat_id = im_config.get("chat_id")
+            if contact_id and chat_id:
+                # 尝试导入 IM 模块并检查通知
+                try:
+                    from agent.tools.builtin.im import chat as im_chat
+                    notification = im_chat._notifications.get((contact_id, chat_id))
+                    if notification:
+                        count = notification.get("count", 0)
+                        senders = notification.get("from", [])
+                        senders_str = ", ".join(senders)
+                        parts.append(
+                            f"## IM 消息通知\n\n"
+                            f"你有 {count} 条新消息,来自: {senders_str}\n"
+                            f"使用 `im_receive_messages(contact_id=\"{contact_id}\", chat_id=\"{chat_id}\")` 查看消息内容。"
+                        )
+                    else:
+                        parts.append("## IM 消息通知\n\n暂无新消息")
+                except (ImportError, AttributeError):
+                    # IM 模块未加载或不可用
+                    pass
+
+        # Knowledge Manager 队列状态
+        km_queue_size = trace.context.get("km_queue_size")
+        if km_queue_size is not None:
+            current_sender = trace.context.get("current_sender", "unknown")
+            if km_queue_size > 0:
+                parts.append(
+                    f"## 消息队列状态\n\n"
+                    f"当前处理: {current_sender} 的消息\n"
+                    f"队列中还有 {km_queue_size} 条待处理消息"
+                )
+            else:
+                parts.append(
+                    f"## 消息队列状态\n\n"
+                    f"当前处理: {current_sender} 的消息\n"
+                    f"队列为空,处理完本条消息后将进入休眠"
+                )
+
+        return "\n\n".join(parts)
+
+    # ===== 辅助方法 =====
+
+    async def _optimize_images(self, messages: List[Dict], model: str) -> List[Dict]:
+        """
+        分级优化已处理的图片,节省 token
+
+        策略(基于图片距离最后一条 assistant 的"轮次"):
+        1. 最近 1-2 轮:保留原图
+        2. 3-5 轮:降低分辨率和压缩(节省 token 但保留视觉信息)
+        3. 5 轮以上:调用小模型生成文本描述 + 保留 URL
+
+        处理结果会缓存,避免重复的 PIL 解码/编码和 LLM 调用。
+
+        Args:
+            messages: 原始消息列表
+            model: 当前使用的模型(用于选择描述生成模型)
+
+        Returns:
+            优化后的消息列表(深拷贝)
+        """
+        if not messages:
+            return messages
+
+        # 找到最后一条 assistant message 的位置
+        last_assistant_idx = -1
+        for i in range(len(messages) - 1, -1, -1):
+            if messages[i].get("role") == "assistant":
+                last_assistant_idx = i
+                break
+
+        # 如果没有 assistant message,说明还没开始对话,不优化
+        if last_assistant_idx == -1:
+            return messages
+
+        # 统计从每个位置到最后一条 assistant 之间的 assistant 数量(作为"轮次")
+        assistant_count_after = [0] * len(messages)
+        count = 0
+        for i in range(len(messages) - 1, -1, -1):
+            assistant_count_after[i] = count
+            if messages[i].get("role") == "assistant":
+                count += 1
+
+        # 深拷贝避免修改原始数据
+        import copy
+        import hashlib
+        import asyncio
+        import base64 as b64mod
+        import httpx
+        import mimetypes
+        messages = copy.deepcopy(messages)
+
+        # 预处理:将所有 HTTP(S) URL 图片下载并转为 base64 data URL
+        # Qwen API 无法访问外部签名 URL(如 BFL、火山引擎 TOS),必须在本地转换
+        url_download_jobs = []  # [(msg_idx, block_idx, url)]
+        for i, msg in enumerate(messages):
+            if msg.get("role") != "tool":
+                continue
+            content = msg.get("content")
+            if not isinstance(content, list):
+                continue
+            for block_idx, block in enumerate(content):
+                if isinstance(block, dict) and block.get("type") == "image_url":
+                    url = block.get("image_url", {}).get("url", "")
+                    if url.startswith(("http://", "https://")):
+                        url_download_jobs.append((i, block_idx, url))
+
+        if url_download_jobs:
+            async def _download_image_to_data_url(url: str) -> str | None:
+                try:
+                    async with httpx.AsyncClient(timeout=60, trust_env=False) as client:
+                        resp = await client.get(url)
+                        resp.raise_for_status()
+                        ct = resp.headers.get("content-type", "").split(";")[0].strip()
+                        if not ct.startswith("image/"):
+                            ct = mimetypes.guess_type(url.split("?")[0])[0] or "image/png"
+                        b64 = b64mod.b64encode(resp.content).decode()
+                        return f"data:{ct};base64,{b64}"
+                except Exception:
+                    return None
+
+            results = await asyncio.gather(
+                *[_download_image_to_data_url(url) for _, _, url in url_download_jobs],
+                return_exceptions=True
+            )
+            converted = 0
+            for (msg_idx, block_idx, original_url), result in zip(url_download_jobs, results):
+                if isinstance(result, str) and result.startswith("data:"):
+                    messages[msg_idx]["content"][block_idx]["image_url"]["url"] = result
+                    converted += 1
+            if converted:
+                self.log.info(f"[Image Optimization] URL→base64 预转换: {converted}/{len(url_download_jobs)} 张")
+
+        # 统计优化情况
+        stats = {"kept": 0, "downscaled": 0, "described": 0, "cache_hit": 0}
+
+        # 收集需要降分辨率或尺寸补齐的图片(用于并发处理)
+        process_jobs = []  # [(msg_idx, block_idx, image_url, cache_key, max_size, cache_field)]
+
+        # 第一遍:扫描并收集需要处理的图片
+        for i in range(last_assistant_idx):
+            msg = messages[i]
+            if msg.get("role") != "tool":
+                continue
+
+            content = msg.get("content")
+            if not isinstance(content, list):
+                continue
+
+            rounds_ago = assistant_count_after[i]
+
+            for block_idx, block in enumerate(content):
+                if isinstance(block, dict) and block.get("type") == "image_url":
+                    image_url_obj = block.get("image_url", {})
+                    image_url = image_url_obj.get("url", "")
+
+                    if image_url.startswith("data:"):
+                        cache_key = hashlib.md5(image_url[:200].encode()).hexdigest()
+                    else:
+                        cache_key = hashlib.md5(image_url.encode()).hexdigest()
+
+                    # 1-5 轮都需要检查尺寸
+                    if rounds_ago <= 5:
+                        cached = self._image_opt_cache.get(cache_key, {})
+                        cache_field = "pad_only" if rounds_ago <= 2 else "downscaled"
+                        
+                        if cache_field not in cached and image_url.startswith("data:"):
+                            max_size = None if rounds_ago <= 2 else 512
+                            process_jobs.append((i, block_idx, image_url, cache_key, max_size, cache_field))
+
+        # 并发处理所有尺寸任务
+        if process_jobs:
+            process_results = await asyncio.gather(
+                *[self._process_image_size(url, max_size=ms) for _, _, url, _, ms, _ in process_jobs],
+                return_exceptions=True
+            )
+            for (_, _, _, cache_key, _, cache_field), result in zip(process_jobs, process_results):
+                if not isinstance(result, Exception) and result is not None:
+                    self._image_opt_cache.setdefault(cache_key, {})[cache_field] = result
+
+        # 第二遍:应用处理结果
+        for i in range(last_assistant_idx):
+            msg = messages[i]
+            if msg.get("role") != "tool":
+                continue
+
+            content = msg.get("content")
+            if not isinstance(content, list):
+                continue
+
+            # 计算这条消息距离最后一条 assistant 的"轮次"
+            rounds_ago = assistant_count_after[i]
+
+            # 处理每个 content block
+            new_content = []
+            for block in content:
+                if isinstance(block, dict) and block.get("type") == "image_url":
+                    image_url_obj = block.get("image_url", {})
+                    image_url = image_url_obj.get("url", "")
+
+                    # 生成缓存 key(URL 图片用 URL 本身,base64 用前 64 字符 hash)
+                    if image_url.startswith("data:"):
+                        cache_key = hashlib.md5(image_url[:200].encode()).hexdigest()
+                    else:
+                        cache_key = hashlib.md5(image_url.encode()).hexdigest()
+
+                    # 根据距离决定处理策略
+                    if rounds_ago <= 2:
+                        # 最近 1-2 轮:只补齐过小图片,保留原分辨率
+                        cached = self._image_opt_cache.get(cache_key, {})
+                        if "pad_only" in cached:
+                            new_content.append({
+                                "type": "image_url",
+                                "image_url": {"url": cached["pad_only"]}
+                            })
+                            stats["kept"] += 1
+                            stats["cache_hit"] += 1
+                        elif image_url.startswith("data:"):
+                            processed = await self._process_image_size(image_url, max_size=None)
+                            if processed:
+                                self._image_opt_cache.setdefault(cache_key, {})["pad_only"] = processed
+                                new_content.append({
+                                    "type": "image_url",
+                                    "image_url": {"url": processed}
+                                })
+                            else:
+                                new_content.append(block)
+                            stats["kept"] += 1
+                        else:
+                            new_content.append(block)
+                            stats["kept"] += 1
+
+                    elif rounds_ago <= 5:
+                        # 3-5 轮:降低分辨率(优先从缓存取)
+                        cached = self._image_opt_cache.get(cache_key, {})
+                        if "downscaled" in cached:
+                            new_content.append({
+                                "type": "image_url",
+                                "image_url": {"url": cached["downscaled"]}
+                            })
+                            stats["downscaled"] += 1
+                            stats["cache_hit"] += 1
+                        elif image_url.startswith("data:"):
+                            processed = await self._process_image_size(image_url, max_size=512)
+                            if processed:
+                                # 缓存结果
+                                self._image_opt_cache.setdefault(cache_key, {})["downscaled"] = processed
+                                new_content.append({
+                                    "type": "image_url",
+                                    "image_url": {"url": processed}
+                                })
+                                stats["downscaled"] += 1
+                            else:
+                                new_content.append(block)
+                                stats["kept"] += 1
+                        else:
+                            # URL 图片:无法直接处理,保留原图
+                            new_content.append(block)
+                            stats["kept"] += 1
+
+                    else:
+                        # 5 轮以上:生成文本描述(优先从缓存取)
+                        cached = self._image_opt_cache.get(cache_key, {})
+                        if "description" in cached:
+                            new_content.append(cached["description"])
+                            stats["described"] += 1
+                            stats["cache_hit"] += 1
+                        else:
+                            description = await self._generate_image_description(image_url, model)
+                            url_info = f" (URL: {image_url[:100]}...)" if not image_url.startswith("data:") else ""
+                            desc_block = {
+                                "type": "text",
+                                "text": f"[Image description: {description}]{url_info}"
+                            }
+                            # 缓存结果
+                            self._image_opt_cache.setdefault(cache_key, {})["description"] = desc_block
+                            new_content.append(desc_block)
+                            stats["described"] += 1
+                else:
+                    new_content.append(block)
+
+            msg["content"] = new_content
+        # print(f"[Image Opt Check] 扫描到 {stats['kept'] + stats['downscaled'] + stats['described']} 张图片上下文")
+        if stats["downscaled"] > 0 or stats["described"] > 0:
+            self.log.info(
+                f"[Image Optimization] 保留 {stats['kept']} 张,"
+                f"降分辨率 {stats['downscaled']} 张,"
+                f"文本描述 {stats['described']} 张,"
+                f"缓存命中 {stats['cache_hit']} 次"
+            )
+
+        return messages
+
+    async def _process_image_size(self, base64_url: str, max_size: Optional[int] = 512, min_size: int = 11) -> Optional[str]:
+        """
+        处理 base64 图片的尺寸:
+        - 若 max_size 不为 None 且大于该值,则等比例缩放
+        - 若任意一边小于 min_size,则补充白边 (Padding)
+        """
+        try:
+            from PIL import Image
+            import io
+            import base64
+
+            # 解析 base64 数据
+            if not base64_url.startswith("data:"):
+                return None
+
+            header, data = base64_url.split(",", 1)
+            media_type = header.split(";")[0].split(":")[1]  # image/png
+
+            # 解码图片
+            img_data = base64.b64decode(data)
+            img = Image.open(io.BytesIO(img_data))
+
+            width, height = img.size
+
+            needs_downscale = max_size is not None and (width > max_size or height > max_size)
+            needs_pad = width < min_size or height < min_size
+
+            # 尺寸正常,无需处理
+            if not needs_downscale and not needs_pad:
+                return base64_url
+
+            new_width, new_height = width, height
+
+            # 1. 降分辨率
+            if needs_downscale:
+                if width > height:
+                    new_width = max_size
+                    new_height = int(height * max_size / width)
+                else:
+                    new_height = max_size
+                    new_width = int(width * max_size / height)
+            
+            if (new_width, new_height) != (width, height):
+                img_resized = img.resize((new_width, new_height), Image.Resampling.BILINEAR)
+            else:
+                img_resized = img
+
+            # 2. 补齐白边 (Padding)
+            pad_width = max(new_width, min_size)
+            pad_height = max(new_height, min_size)
+
+            if pad_width > new_width or pad_height > new_height:
+                # 创建白色背景
+                padded_img = Image.new("RGBA" if img_resized.mode in ("RGBA", "P") else "RGB", (pad_width, pad_height), (255, 255, 255, 255))
+                offset_x = (pad_width - new_width) // 2
+                offset_y = (pad_height - new_height) // 2
+                padded_img.paste(img_resized, (offset_x, offset_y))
+                img_resized = padded_img
+
+            # 转换为 RGB(JPEG不支持 RGBA, P 等具有透明度或索引的模式)
+            if img_resized.mode != "RGB":
+                if img_resized.mode == "RGBA" or img_resized.mode == "P":
+                    # Create a white background for transparent images
+                    background = Image.new("RGB", img_resized.size, (255, 255, 255))
+                    if img_resized.mode == "P" and "transparency" in img_resized.info:
+                        img_resized = img_resized.convert("RGBA")
+                    if img_resized.mode == "RGBA":
+                        background.paste(img_resized, mask=img_resized.split()[3])
+                        img_resized = background
+                img_resized = img_resized.convert("RGB")
+
+            # 重新编码为 JPEG(如果只是补齐没有缩放,可以稍微保留高点质量)
+            buffer = io.BytesIO()
+            quality = 60 if needs_downscale else 85
+            img_resized.save(buffer, format="JPEG", quality=quality, optimize=False)
+            new_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
+
+            return f"data:image/jpeg;base64,{new_data}"
+
+        except Exception as e:
+            self.log.warning(f"[Image Process] 处理图片尺寸失败: {e}")
+            return None
+
+    async def _generate_image_description(self, image_url: str, current_model: str) -> str:
+        """
+        使用小模型生成图片的文本描述
+
+        Args:
+            image_url: 图片 URL(base64 或 http(s))
+            current_model: 当前使用的模型
+
+        Returns:
+            图片描述文本
+        """
+        try:
+            # 使用 qwen-vl-max(通义千问视觉模型)生成描述
+            # 注意:qwen-vl 系列专门支持视觉输入
+            description_model = "qwen-vl-max"
+
+            # 构建描述请求
+            messages = [
+                {
+                    "role": "user",
+                    "content": [
+                        {
+                            "type": "image_url",
+                            "image_url": {"url": image_url}
+                        },
+                        {
+                            "type": "text",
+                            "text": "请用 1-2 句话简洁描述这张图片的主要内容。"
+                        }
+                    ]
+                }
+            ]
+
+            # 调用 LLM
+            result = await self.llm_call(
+                messages=messages,
+                model=description_model,
+                tools=None,
+                temperature=0.3,
+            )
+
+            description = result.get("content", "").strip()
+            return description if description else "图片内容"
+
+        except Exception as e:
+            self.log.warning(f"[Image Description] 生成描述失败: {e}")
+            return "图片内容"
+
+    def _add_cache_control(
+        self,
+        messages: List[Dict],
+        model: str,
+        enable: bool
+    ) -> List[Dict]:
+        """
+        为支持的模型添加 Prompt Caching 标记
+
+        策略:固定位置 + 延迟缓存
+        1. 如果有未处理的图片(最后一条 assistant 之后的 tool messages 中有图片),跳过缓存
+        2. system message 添加缓存(如果足够长)
+        3. 固定位置缓存点(20, 40, 60, 80),确保每个缓存点间隔 >= 1024 tokens
+        4. 最多使用 4 个缓存点(含 system)
+
+        Args:
+            messages: 原始消息列表
+            model: 模型名称
+            enable: 是否启用缓存
+
+        Returns:
+            添加了 cache_control 的消息列表(深拷贝)
+        """
+        if not enable:
+            return messages
+
+        # 只对 Claude 模型启用
+        if "claude" not in model.lower():
+            return messages
+
+        # 延迟缓存:检查是否有未处理的图片
+        last_assistant_idx = -1
+        for i in range(len(messages) - 1, -1, -1):
+            if messages[i].get("role") == "assistant":
+                last_assistant_idx = i
+                break
+
+        # 检查最后一条 assistant 之后是否有包含图片的 tool messages
+        has_unprocessed_images = False
+        if last_assistant_idx >= 0:
+            for i in range(last_assistant_idx + 1, len(messages)):
+                msg = messages[i]
+                if msg.get("role") == "tool":
+                    content = msg.get("content")
+                    if isinstance(content, list):
+                        has_unprocessed_images = any(
+                            isinstance(block, dict) and block.get("type") == "image_url"
+                            for block in content
+                        )
+                        if has_unprocessed_images:
+                            break
+
+        if has_unprocessed_images:
+            self.log.debug("[Cache] 检测到未处理的图片,延迟缓存建立")
+            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
+                    self.log.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
+                    self.log.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"}
+                }]
+                self.log.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"}
+                        self.log.debug(f"[Cache] 为 message[{idx}] ({msg.get('role')}) 添加缓存标记")
+                        break
+
+        self.log.debug(
+            f"[Cache] 总消息: {total_msgs}, "
+            f"缓存点: {len(cache_positions)} at {cache_positions}"
+        )
+        return messages
+
+    def _get_tool_schemas(
+        self,
+        tools: Optional[List[str]] = None,
+        tool_groups: Optional[List[str]] = None,
+        exclude_tools: Optional[List[str]] = None,
+    ) -> List[Dict]:
+        """
+        获取工具 Schema
+
+        合并策略(取并集):
+        - tool_groups 非空: 按分组白名单过滤得到基础工具集
+        - tools 非空: 追加指定的工具名(与 tool_groups 结果取并集)
+        - 两者都为 None: 返回所有已注册工具
+
+        最后再用 exclude_tools 减去禁用的工具(如远程 agent 禁止 agent/evaluate)。
+        """
+        if tool_groups is not None:
+            tool_names = set(self.tools.get_tool_names(groups=tool_groups))
+        else:
+            tool_names = set(self.tools.get_tool_names())
+        if tools is not None:
+            tool_names |= set(tools)
+        if exclude_tools:
+            tool_names -= set(exclude_tools)
+        return self.tools.get_schemas(list(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. base_prompt(来自消息)
+        2. config.system_prompt(显式指定)
+        3. preset.system_prompt(预设的完整 system prompt)
+        4. 默认模板 + skills
+
+        Skills 注入优先级:
+        1. config.skills 显式指定 → 按名称过滤
+        2. config.skills 为 None → 查 preset 的默认 skills 列表
+        3. preset 也无 skills(None)→ 加载全部(向后兼容)
+
+        Args:
+            base_prompt: 已有 system 内容(来自消息),
+                         None 时使用 config.system_prompt 或 preset.system_prompt
+        """
+        from agent.core.presets import AGENT_PRESETS
+
+        # 确定 system_prompt 来源
+        if base_prompt is not None:
+            system_prompt = base_prompt
+        elif config.system_prompt is not None:
+            system_prompt = config.system_prompt
+        else:
+            # 尝试从 preset 获取 system_prompt
+            preset = AGENT_PRESETS.get(config.agent_type)
+            system_prompt = preset.system_prompt if preset and preset.system_prompt else None
+
+        # 确定要加载哪些 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}"
+
+        if config.max_iterations and config.max_iterations > 0:
+            system_prompt += f"\n\n## Execution Constraint\n这是一项有严格步数限制的任务。你最多可以用 {config.max_iterations} 轮交互来解决问题。\n请务必【边查边写、随时存档】!每当你收集或得出一个有价值的独立结果(如收集到一个独立 Case),请立刻调用工具写入或追加到结果文件中,绝对不要等到所有任务都做完再最后一次性输出。这样即使触达步数上限被强制打断,你已经收集的成果也能安全保留!"
+        # Memory 注入(memory-bearing Agent)——在 system prompt 末尾追加
+        # 初版选择 system prompt 追加(见 agent/docs/memory.md 待定问题 1)。
+        # 好处:run 启动一次性注入、所有后续轮次都能看到、与 skills 注入方式一致。
+        # 代价:若记忆文件很大会持续占 prompt tokens —— 待观察后决定是否切换方案。
+        if config.memory:
+            try:
+                from agent.core.memory import load_memory_files, format_memory_injection
+                files = load_memory_files(config.memory)
+                memory_text = format_memory_injection(files)
+                if memory_text:
+                    system_prompt += f"\n\n{memory_text}"
+            except Exception as e:
+                self.log.warning(f"[Memory] 加载记忆失败,跳过注入: {e}")
+
+        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)

+ 1433 - 0
agent/agent/docs/architecture.md

@@ -0,0 +1,1433 @@
+# 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 创建与评估)
+│
+├── skill/                 # 技能系统
+│   ├── models.py          # Skill
+│   ├── 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/** | 与外部世界交互(文件、命令、网络、浏览器) |
+| **skill/** | 技能系统(Skills)                         |
+| **llm/**   | LLM Provider 适配                          |
+
+### 三层记忆模型
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 3: Skills(技能库)                                     │
+│ - Markdown 文件,存储领域知识和能力描述                        │
+│ - 通过 skill 工具按需加载到对话历史                            │
+└─────────────────────────────────────────────────────────────┘
+                              ▲
+                              │ 归纳
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 2: Knowledge(知识库)                                  │
+│ - 数据库存储,结构化知识 + 向量索引                           │
+│ - 语义检索,按需注入到对话历史                                │
+└─────────────────────────────────────────────────────────────┘
+                              ▲
+                              │ 提取
+┌─────────────────────────────────────────────────────────────┐
+│ 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;续跑时复用已有)
+    #   追加 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` | 触发反思,从执行历史中提取知识                |
+| POST | `/api/traces/{id}/compact` | 触发压缩,通过侧分支多轮 agent 模式压缩上下文 |
+
+```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
+
+# 反思:通过侧分支多轮 agent 模式提取知识
+curl -X POST http://localhost:8000/api/traces/{trace_id}/reflect \
+  -d '{"focus": "为什么第三步选择了错误的方案"}'
+
+# 压缩:通过侧分支多轮 agent 模式压缩上下文
+curl -X POST http://localhost:8000/api/traces/{trace_id}/compact
+```
+
+响应立即返回 `{"trace_id": "...", "status": "started"}`,通过 `WS /api/traces/{trace_id}/watch` 监听实时事件。
+
+**实现**:`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 → 6(branch_type="compression", parent=5) → 7(parent=6)
+            5 → 8(summary, parent=5, 主路径)
+            (侧分支消息 6-7 通过 parent_sequence 自然脱离主路径)
+```
+
+`build_llm_messages` = 从 `trace.head_sequence` 沿 parent_sequence 链回溯到 root,反转后返回。
+
+**关键设计**:只要 `trace.head_sequence` 管理正确(始终指向主路径),`get_main_path_messages()` 自然返回主路径消息,侧分支消息通过 parent_sequence 链自动被跳过,无需额外过滤。
+
+Message 提供格式转换方法:
+
+- `to_llm_dict()` → OpenAI 格式 Dict(用于 LLM 调用)
+- `from_llm_dict(d, trace_id, sequence, goal_id)` → 从 OpenAI 格式创建 Message
+
+**侧分支字段**:
+
+- `branch_type`: "compression" | "reflection" | None(主路径)
+- `branch_id`: 同一侧分支的消息共享 branch_id
+
+**实现**:`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
+    system_prompt: Optional[str] = None        # 完全自定义 system prompt;None = 使用默认模板 + skills
+    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`
+
+**System Prompt 构建优先级**(`agent/core/runner.py:_build_system_prompt`):
+
+1. 消息中已有 system → 使用消息中的
+2. `config.system_prompt` 显式指定 → 使用 config
+3. `preset.system_prompt` 存在 → 使用 preset
+4. 默认模板 + skills
+
+**用户自定义**:项目级配置文件(如 `examples/production/presets.json`)可通过 `load_presets_from_json()` 加载预设。支持 `system_prompt_file` 字段从 `.prompt` 文件加载完整 system prompt。
+
+**示例**(`examples/production/presets.json`):
+
+```json
+{
+  "tool_research": {
+    "system_prompt_file": "tool_research.prompt",
+    "max_iterations": 50,
+    "skills": ["planning", "research", "browser"],
+    "description": "工具调研 Agent"
+  }
+}
+```
+
+**加载方式**:
+
+```python
+from agent.core.presets import load_presets_from_json
+
+load_presets_from_json("examples/production/presets.json")
+```
+
+**使用方式**:LLM 调用 `agent` 工具时指定 `agent_type` 参数:
+
+```python
+agent(task="调研视频生成工具", agent_type="tool_research")
+```
+
+---
+
+## 子 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/skill/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/skill/skill_loader.py`
+
+**详细文档**:[Skills 使用指南](../agent/docs/skills.md)
+
+---
+
+## Knowledge 系统
+
+从执行历史中提取的结构化知识,通过 KnowHub 服务管理,用于指导未来任务。
+
+### 存储架构
+
+知识存储在独立的 KnowHub 服务中(数据库 + 向量索引),支持:
+- 结构化存储(类型、标签、元数据)
+- 向量检索(语义相似度匹配)
+- 多租户隔离(owner、scopes)
+- 版本管理(created_at、updated_at)
+
+**实现**:独立的 KnowHub Server(HTTP API)
+
+### 反思机制(Reflect)
+
+通过 `POST /api/traces/{id}/reflect` 触发,旨在将原始执行历史提炼为可复用的知识。
+
+**流程**:
+1. **分叉反思**:在 trace 末尾进入 `reflection` 侧分支
+2. **多轮推理**:LLM 可调用 `knowledge_search`、`knowledge_save`、`resource_save` 等工具
+3. **结构化保存**:通过 `knowledge_save` 工具将知识保存到 KnowHub
+
+**实现**:`agent/trace/run_api.py:reflect_trace`
+
+### 知识工具
+
+Agent 可通过以下工具与知识库交互:
+
+**`knowledge_search`**:语义检索相关知识
+```python
+@tool
+async def knowledge_search(
+    query: str,
+    top_k: int = 5,
+    min_score: int = 3,
+    types: Optional[List[str]] = None,
+    owner: Optional[str] = None,
+) -> ToolResult:
+    “””从知识库中检索相关知识”””
+```
+
+**`knowledge_save`**:保存新知识
+```python
+@tool
+async def knowledge_save(
+    content: str,
+    type: str,
+    tags: Optional[Dict[str, str]] = None,
+    scopes: Optional[List[str]] = None,
+) -> ToolResult:
+    “””将知识保存到知识库”””
+```
+
+**`resource_save`**:保存资源文件(代码片段、配置等)
+```python
+@tool
+async def resource_save(
+    content: str,
+    filename: str,
+    description: Optional[str] = None,
+) -> ToolResult:
+    “””保存资源文件到知识库”””
+```
+
+**实现**:`agent/tools/builtin/knowledge.py`
+
+---
+
+## Context 管理
+
+Context 管理涵盖注入(向模型上下文添加信息)和压缩(在 token 预算内去除冗余)。
+
+### 注入机制
+
+| 机制 | 时机 | 内容 |
+|------|------|------|
+| System Prompt | trace 创建时一次性 | 角色 prompt + 内置 skills |
+| Context 周期注入 | 每 N 轮自动 | GoalTree + Collaborators + IM + Hooks |
+| Skill 动态注入 | 按需(设计中) | 按任务类型注入的 skill 策略 |
+
+周期注入和 skill 注入形态统一:框架自动合成 tool_call + tool_result 消息对。
+
+### 压缩策略
+
+两级压缩,通过 `RunConfig.goal_compression` 控制:
+
+- **Level 1**:Goal 完成压缩(确定性,零 LLM 成本)— 保留 goal 工具消息,移除执行细节
+- **Level 2**:LLM 摘要(Level 1 后仍超限时触发)— 通过侧分支生成 summary 重建 history
+
+**实现**:`agent/trace/compaction.py`, `agent/core/runner.py:_manage_context_usage`
+
+**详细设计**:[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)                         | 注入机制、压缩策略、Skill 指定注入 |
+| [工具系统](../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 完整参考            |

+ 396 - 0
agent/agent/docs/cognition-log.md

@@ -0,0 +1,396 @@
+# Cognition Log 与知识反馈
+
+> 状态:已实现(2026-04)。本文档同时承担**设计理由**和**使用规范**。
+> 实现入口与代码位置见文末"七、实现与入口"。
+
+## 文档维护规范
+
+0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
+1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议、决策历史、修改日志、大量代码;决策依据或修改日志若有必要,可在 `knowhub/docs/decisions.md` 另行记录
+
+---
+
+## 概述
+
+每个 trace 维护一个 `cognition_log.json`,按时间顺序记录所有认知事件(知识查询、评估、提取三态、记忆反思),为知识质量反馈和 Memory 系统的 dream 操作(详见 `agent/docs/memory.md`)提供数据。
+
+> 此文件原名 `knowledge_log.json`,扩展为统一事件流后更名。读取时仍兼容旧文件名和 `entries[]` 字段。
+
+---
+
+## Cognition Log 数据结构
+
+**位置**:`.trace/{trace_id}/cognition_log.json`
+
+```json
+{
+  "trace_id": "trace-xxx",
+  "events": [
+    { "type": "query", "sequence": 42, ... },
+    { "type": "evaluation", "query_sequence": 42, ... },
+    { "type": "extraction_pending", "extraction_id": "pending-abc", ... },
+    { "type": "extraction_reviewed", "extraction_id": "pending-abc", "decision": "approve", ... },
+    { "type": "extraction_committed", "extraction_id": "pending-abc", "knowledge_id": "knowledge-xyz", ... },
+    { "type": "reflection", "sequence_range": [43, 120], ... }
+  ]
+}
+```
+
+所有事件按写入顺序追加(`query` 和 `reflection` 关联 trace 中 message 的 `sequence`;`evaluation` / `extraction_*` 不强依赖 sequence)。框架自动给每个事件写入 `timestamp`。
+
+---
+
+## 事件类型
+
+### `query`:知识查询
+
+Agent 通过 `POST /api/knowledge/ask` 查询知识时记录。一次查询返回 KM Agent 的整合回答及引用的各 source,作为一个整体记录。
+
+```json
+{
+  "type": "query",
+  "sequence": 42,
+  "goal_id": "1",
+  "query": "goal 的描述文本",
+  "response": "KM Agent 整合后的回答...",
+  "source_ids": ["knowledge-a1b2", "knowledge-c3d4", "knowledge-e5f6"],
+  "sources": [
+    {"id": "knowledge-a1b2", "task": "...", "content": "...(截断500字)"},
+    {"id": "knowledge-c3d4", "task": "...", "content": "..."}
+  ],
+  "timestamp": "2026-03-20T10:00:00"
+}
+```
+
+**写入时机**:`agent/trace/goal_tool.py:inject_knowledge_for_goal`,`POST /api/knowledge/ask` 返回后。
+
+### `evaluation`:知识评估
+
+对某次 query 中各 source 的使用效果评估。通过 `query_sequence` 关联到对应的 query 事件。实际按 knowledge_id 逐条写入(每条知识一个 evaluation 事件)。
+
+```json
+{
+  "type": "evaluation",
+  "query_sequence": 42,
+  "knowledge_id": "knowledge-a1b2",
+  "eval_result": {"status": "helpful", "reason": "准确定位了问题"},
+  "evaluated_at_trigger": "goal_completion",
+  "timestamp": "2026-03-20T10:05:00"
+}
+```
+
+LLM 在侧分支内按 `{evaluations: [{query_sequence, assessments: [{knowledge_id, eval_status, reason}]}]}` 输出;runner 解析后分条写入(一个 evaluation 事件对应一条 knowledge)。
+
+**`status` 可能的值**:
+
+| 状态 | 含义 |
+|---|---|
+| `irrelevant` | 知识与当前任务无关 |
+| `unused` | 知识相关但未被使用 |
+| `helpful` | 知识对任务有实质帮助 |
+| `harmful` | 知识对任务产生负面作用 |
+| `neutral` | 知识相关但无明显影响 |
+
+### `extraction_pending` / `extraction_reviewed` / `extraction_committed`:知识提取三态
+
+Agent 的反思侧分支不再直接 upload 到 KnowHub,而是暂存为 pending,经人工 review 后才 commit。详见 `agent/docs/memory.md` 第三节"提取-审核-提交两阶段"。
+
+#### `extraction_pending`
+
+反思 LLM 调用 `knowledge_save_pending` 工具时写入。payload 字段与 `knowledge_save` 参数一一对应(review 通过后字段透传)。
+
+```json
+{
+  "type": "extraction_pending",
+  "extraction_id": "pending-a1b2c3d4",
+  "sequence": 88,
+  "goal_id": "g1",
+  "branch_id": "reflection-branch-xxx",
+  "payload": {
+    "task": "...", "content": "...", "types": ["tool"],
+    "tags": {...}, "score": 4, "scopes": null, "owner": null,
+    "resource_ids": [], "source_name": "", "source_category": "exp",
+    "urls": [], "agent_id": "...", "submitted_by": "",
+    "capability_ids": [], "tool_ids": []
+  },
+  "timestamp": "2026-03-20T10:10:00"
+}
+```
+
+**写入时机**:`agent/tools/builtin/knowledge.py:knowledge_save_pending` 工具调用时。
+
+#### `extraction_reviewed`
+
+人工审核决策。同一 `extraction_id` 可能被多次 review(以最新一条为准),`edit` 决策携带 `edited_payload` 覆盖原 payload。
+
+```json
+{
+  "type": "extraction_reviewed",
+  "extraction_id": "pending-a1b2c3d4",
+  "decision": "approve",
+  "timestamp": "2026-04-15T14:00:00"
+}
+```
+
+`decision` 取值:`approve` / `edit` / `discard`。`edit` 时附加 `edited_payload` 字段。
+
+**写入时机**:
+- CLI:`python -m agent.cli.extraction_review --review`
+- Interactive 菜单第 8 项
+- HTTP:`POST /api/traces/{tid}/extractions/{eid}/review`
+- 共享核心:`agent/trace/extraction_review.py:review_one`
+
+#### `extraction_committed`
+
+将 approved/edited 的条目实际上传到 KnowHub 后写入。失败条目不写此事件,只记录在 CommitReport 里。
+
+```json
+{
+  "type": "extraction_committed",
+  "extraction_id": "pending-a1b2c3d4",
+  "knowledge_id": "knowledge-new-1",
+  "timestamp": "2026-04-15T14:05:00"
+}
+```
+
+**写入时机**:`agent/trace/extraction_review.py:commit_approved`(调用 `knowledge_save` 成功后)。`reflect_auto_commit=True` 时由反思侧分支退出 hook 自动触发 `auto_commit_branch`。
+
+### `reflection`:记忆反思
+
+仅 memory-bearing Agent 使用(详见 `agent/docs/memory.md`)。Dream 操作触发的 per-trace 记忆反思。
+
+```json
+{
+  "type": "reflection",
+  "sequence_range": [43, 120],
+  "summary": "这次执行中发现用户偏好XX方向...",
+  "consumed_at": "2026-04-07T20:05:00",
+  "timestamp": "2026-04-07T20:00:00"
+}
+```
+
+`sequence_range` 是本次反思覆盖的消息区间 `[start, end]`。`consumed_at` 在跨 trace 整合(dream 的第二阶段)消化了该反思后写入;未消化时此字段缺省。
+
+**写入时机**:`agent/core/dream.py:per_trace_reflect`(写入时 `consumed_at` 缺省);`agent/core/dream.py:cross_trace_integrate`(整合后补 `consumed_at`)。
+
+---
+
+## 评估触发机制
+
+评估针对的是未评估的 query 事件(即存在 query 事件但没有对应 evaluation 事件的)。
+
+**判断待评估条件**:查找 cognition_log 中所有 `type: "query"` 事件,检查是否存在 `query_sequence` 指向该 query 的 `type: "evaluation"` 事件。
+
+### 触发点 1:Goal 完成
+
+**时机**:Goal status 变为 `completed` 或 `abandoned`
+
+**触发逻辑**(`agent/trace/store.py:update_goal`):
+
+```
+Goal 完成
+  ↓
+查询 cognition_log 中未评估的 query 事件
+  ↓
+如果有待评估
+  → 设置 trace.context["pending_knowledge_eval"] = true
+  → 设置 trace.context["knowledge_eval_trigger"] = "goal_completion"
+  ↓
+Runner 主循环下一次迭代开头检测到标志(agent/core/runner.py:_agent_loop)
+  → 清除标志
+  → 将 "knowledge_eval" 加入 force_side_branch 队列
+```
+
+### 触发点 2:压缩
+
+**时机**:上下文 token 数超过阈值,即将执行压缩
+
+**触发逻辑**(`agent/core/runner.py:_manage_context_usage`):
+
+```
+压缩条件触发
+  ↓
+查询 cognition_log 中未评估的 query 事件
+  ↓
+如果有待评估
+  → 设置 trace.context["knowledge_eval_trigger"] = "compression"
+  → 侧分支队列:["reflection", "knowledge_eval", "compression"](启用知识提取时)
+  →            或 ["knowledge_eval", "compression"](未启用时)
+  → 返回"需要进入侧分支"信号,暂缓压缩
+```
+
+压缩会删除消息历史,必须在压缩前完成评估。
+
+### 触发点 3:任务结束(兜底)
+
+**时机**:主路径无工具调用,Agent 即将结束
+
+**触发逻辑**(`agent/core/runner.py:_agent_loop`):
+
+```
+任务即将结束
+  ↓
+查询 cognition_log 中未评估的 query 事件
+  ↓
+如果有待评估
+  → 设置 trace.context["knowledge_eval_trigger"] = "task_completion"
+  → 将 ["knowledge_eval"] 加入 force_side_branch 队列
+  → continue(不 break,下一轮执行评估)
+```
+
+---
+
+## 侧分支评估流程
+
+### 侧分支类型
+
+复用 `SideBranchContext` 机制,类型 `"knowledge_eval"`(`agent/trace/models.py:Message.branch_type`)。
+
+### 评估 Prompt 结构
+
+完整实现:`agent/core/runner.py:_build_knowledge_eval_prompt`
+
+```
+你是知识评估助手。请评估以下知识查询结果在本次任务执行中的实际效果。
+
+## 当前任务(Mission)       ← trace.task
+## 当前 Goal                ← goal_tree.current 的 description
+## 待评估知识查询            ← 未评估的 query 事件列表
+  对每个 query:展示 query 文本、整合回答、各 source 的 id/task/content
+## 评估要求                  ← 按 source_id 逐一评估
+## 评估分类                  ← 5 个 status 选项
+## 输出格式                  ← JSON
+```
+
+Prompt 中**不包含消息历史**。LLM 依据对话上下文中已有的执行过程作出判断。
+
+### 评估输出格式
+
+LLM 直接输出 JSON:
+
+```json
+{
+  "evaluations": [
+    {
+      "query_sequence": 42,
+      "assessments": [
+        {"source_id": "knowledge-a1b2", "status": "helpful", "reason": "..."},
+        {"source_id": "knowledge-c3d4", "status": "irrelevant", "reason": "..."}
+      ]
+    }
+  ]
+}
+```
+
+### 即时写入
+
+每次 LLM 回复后立即解析,三种策略降级:整体 JSON → ` ```json ` 代码块 → 正则裸对象。
+
+解析成功 → 为每个 query 写入对应的 `evaluation` 事件到 cognition_log。解析失败记日志,不中断。
+
+---
+
+## 数据流
+
+```
+知识查询(agent/trace/goal_tool.py:inject_knowledge_for_goal)
+  ↓
+POST /api/knowledge/ask → KM Agent 整合回答
+  ↓
+写入 cognition_log: type="query"(含 response + source_ids)
+  ↓
+  ┌─────────────────────────────────────────────┐
+  │  触发点 A:Goal 完成(goal_completion)       │
+  │  触发点 B:压缩执行前(compression)          │
+  │  触发点 C:任务自然结束(task_completion)    │
+  └─────────────────────────────────────────────┘
+  ↓
+Runner 进入 knowledge_eval 侧分支
+  ↓
+LLM 按 query 维度、逐 source 评估,输出 JSON
+  ↓
+写入 cognition_log: type="evaluation"(含 assessments per source)
+  ↓
+侧分支退出 → 恢复主路径
+
+                    ···
+
+知识提取(reflection 侧分支 LLM 调 knowledge_save_pending)
+  ↓
+写入 cognition_log: type="extraction_pending"
+  ↓
+  ┌─ reflect_auto_commit=True ─→ 侧分支退出 hook 自动 approve + commit
+  │                              写 extraction_reviewed + extraction_committed
+  │
+  └─ reflect_auto_commit=False(默认)
+     ↓
+     人工 CLI / Interactive 菜单 / HTTP API 触发
+     ↓
+     review → 写 extraction_reviewed(approve/edit/discard)
+     ↓
+     commit → 调 knowledge_save → 写 extraction_committed
+
+                    ···
+
+Dream 触发(memory-bearing Agent,详见 agent/docs/memory.md)
+  ↓
+Phase 1: per_trace_reflect(逐 trace,reflected_at_sequence < last_sequence)
+  ↓ 读取增量消息 + cognition_log 中 query/evaluation/extraction_* 事件
+  ↓ 写入 cognition_log: type="reflection"(consumed_at 暂缺)
+  ↓ 更新 Trace.reflected_at_sequence
+
+Phase 2: cross_trace_integrate
+  ↓ 汇总未消化的 reflection + 当前记忆文件
+  ↓ LLM 输出 {updates:[{path,new_content}]} JSON 计划
+  ↓ 写记忆文件 + 给参与的 reflection 补 consumed_at
+```
+
+---
+
+## 与现有系统的集成点
+
+| 集成位置 | 文件 | 说明 |
+|---|---|---|
+| 知识查询时写 log | `agent/trace/goal_tool.py:inject_knowledge_for_goal` | `goal(focus=...)` 触发 ask → 写入 `query` 事件 |
+| Goal 完成时设置标志 | `agent/trace/store.py:update_goal` | 设置 `trace.context["pending_knowledge_eval"]` |
+| 主循环检测标志 | `agent/core/runner.py:_agent_loop` | 每轮迭代开头检测,触发 `["knowledge_eval"]` |
+| 压缩前触发评估 | `agent/core/runner.py:_manage_context_usage` | 压缩前检查 pending,先评估再压缩 |
+| 任务结束兜底 | `agent/core/runner.py:_agent_loop` | 退出前检查 pending,强制触发评估 |
+| 侧分支类型 | `agent/trace/models.py:Message.branch_type` | Literal 中包含 `"knowledge_eval"` |
+| 即时写入评估 | `agent/core/runner.py:_agent_loop` | 解析 JSON 后调 `store.update_knowledge_evaluation` |
+| 知识提取暂存 | `agent/tools/builtin/knowledge.py:knowledge_save_pending` | LLM 工具,写 `extraction_pending` 事件 |
+| 提取审核 / 提交 | `agent/trace/extraction_review.py` | 写 `extraction_reviewed` / `extraction_committed` 事件 |
+| 反思侧分支 auto-commit | `agent/core/runner.py`(反思分支退出分支) | `reflect_auto_commit=True` 时调 `auto_commit_branch` |
+| 记忆反思写入 | `agent/core/dream.py:per_trace_reflect` | 写 `reflection` 事件(consumed_at 缺省) |
+| Reflection 消化标记 | `agent/core/dream.py:cross_trace_integrate` | 整合后补 `consumed_at` |
+| Log 文件格式 | `agent/trace/store.py` | ✅ 已从 entries[] 迁移到 events[];读写兼容旧文件名 |
+
+---
+
+## 七、实现与入口
+
+### 7.1 存储与访问
+
+| 职责 | 位置 |
+|---|---|
+| Cognition log 文件位置 | `.trace/{trace_id}/cognition_log.json`(新建时文件名);旧 trace 兼容读 `knowledge_log.json` |
+| 读取 | `store.py:get_cognition_log`(两种文件名都认;`entries[]` 自动迁移为 `events[]`) |
+| 追加 | `store.py:append_cognition_event`(接受任意 event type,自动补 `timestamp`) |
+| 事件 schema 权威清单 | `store.py:append_cognition_event` 的 docstring |
+
+### 7.2 各事件类型的写入与读取
+
+| 事件类型 | 主要写入者 | 主要读取者 |
+|---|---|---|
+| `query` | `goal_tool.py:inject_knowledge_for_goal` | `_build_knowledge_eval_prompt` 找待评估;`dream.py:_build_reflect_input` 组装反思上下文 |
+| `evaluation` | `store.py:update_knowledge_evaluation`(runner 解析 LLM JSON 后调用) | `dream.py:_build_reflect_input` |
+| `extraction_pending` | `knowledge_save_pending` 工具 | `extraction_review.py:list_pending`;CLI / HTTP API 显示 |
+| `extraction_reviewed` | `extraction_review.py:review_one` | `extraction_review.py:commit_approved`(决定要不要 commit) |
+| `extraction_committed` | `extraction_review.py:commit_approved` | `list_pending`(标记 committed 状态) |
+| `reflection` | `dream.py:per_trace_reflect` | `dream.py:cross_trace_integrate` |
+
+### 7.3 相关文档
+
+- **Memory 系统整体**:`agent/docs/memory.md` —— cognition_log 是其数据底座,Memory/Dream 设计与使用规范在那里
+- **KnowHub 决策历史**:`knowhub/docs/decisions.md` —— 如果需要 knowledge_log → cognition_log 重构等历史决策的背景

+ 467 - 0
agent/agent/docs/comparison-with-claude-code.md

@@ -0,0 +1,467 @@
+# Agent 架构对比:Cyber Agent vs Claude Code
+
+> 对比 **Cyber Agent**(本项目)与 **Claude Code v2.1.88**(非官方重建,仅供研究)的架构设计异同,目标是找到可借鉴的设计思路,以及两者各自的取舍。
+
+---
+
+## 总览
+
+| 维度 | Cyber Agent | Claude Code |
+|------|-------------|-------------|
+| **语言/运行时** | Python / asyncio | TypeScript / Bun + Node.js |
+| **定位** | 嵌入式 Agent 框架,供程序集成调用 | 面向开发者的交互式 CLI 工具 |
+| **LLM 接入** | 多 Provider(OpenRouter / Gemini / Anthropic 等) | 仅 Claude API |
+| **持久化** | 文件系统(JSONL + JSON),消息树结构 | 内存中维护会话,JSONL 转录为副产品 |
+| **多 Agent** | 递归子 Trace,独立持久化,可跨设备 | Coordinator-Worker,内存中,不持久化 |
+| **计划管理** | 原生 GoalTree(结构化、持久化、周期注入) | 无原生计划结构(TodoWriteTool 扁平备忘录) |
+| **记忆层次** | 三层(Trace / Knowledge / Skill),Agent 主动写入 | 二层(CLAUDE.md / 历史压缩),人工维护 |
+| **权限系统** | 预设白/黑名单(工具级) | 规则引擎(参数级 glob 匹配 + 交互审批) |
+| **可观测性** | REST + WebSocket,结构化事件流 | 终端 UI + OpenTelemetry,无外部 API |
+| **UI 层** | 无终端 UI | 自研 Ink 终端框架(251KB) |
+
+---
+
+## 核心结构类比
+
+```
+Cyber Agent              Claude Code
+───────────────────      ───────────────────────
+Trace                ↔   QueryEngine          (执行容器)
+AgentRunner.loop     ↔   queryLoop            (while true 循环)
+FileSystemTraceStore ↔   recordTranscript     (持久化)
+GoalTree             ↔   TodoWriteTool        (计划管理,差距显著)
+KnowHub + Reflect    ↔   CLAUDE.md            (跨任务记忆,差距显著)
+```
+
+---
+
+## 一、执行容器:Trace vs QueryEngine
+
+两者都是"单次执行会话"的容器,跨 turn 维护消息历史和统计数据。
+
+| 维度 | Trace | QueryEngine |
+|------|-------|-------------|
+| **生命周期** | 持久化到文件系统 | 内存中,session 结束消失 |
+| **消息结构** | 消息树(`parent_sequence`,支持 Rewind) | 线性数组 `Message[]` |
+| **显式状态** | `running / completed / failed / stopped` | 无 |
+| **计划结构** | GoalTree(持久化,周期注入) | 无 |
+| **唯一标识** | `trace_id`(UUID,可跨设备引用) | 无专用 ID |
+| **子执行单元** | 所有子 Agent 都是 Trace,结构统一 | AgentTool 各自独立,无统一模型 |
+| **记忆去重** | `head_sequence` 管理主路径,消息树自动过滤 | `loadedNestedMemoryPaths` 防止 CLAUDE.md 重复注入 |
+
+`loadedNestedMemoryPaths` 和 `head_sequence` 解决了相似的问题(防止重复处理),但层次完全不同:前者是内容级去重,后者是结构级的消息主路径管理。
+
+**核心差异**:Cyber Agent 把 Agent 执行视为持久化的、可被外部观测的**任务**;Claude Code 更偏向交互式**会话**,持久化是副产品而非一等公民。
+
+---
+
+## 二、Agent 循环:AgentRunner.loop vs queryLoop
+
+两者都是 `while true` → 调 LLM → 执行工具 → 继续 的循环,但在五个维度上有实质差异。
+
+### 2.1 循环状态的管理方式
+
+queryLoop 用一个**整体替换**的 `State` 对象管理跨迭代状态,所有 `continue` 点都是 `state = { ...newState }`:
+
+```typescript
+// queryLoop 的每个 continue 点——必须声明完整的下一轮状态
+state = {
+  messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
+  maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,
+  hasAttemptedReactiveCompact,   // 显式保留
+  maxOutputTokensOverride: undefined,  // 显式重置
+  pendingToolUseSummary: undefined,    // 显式重置
+  stopHookActive,
+  turnCount,
+  transition: { reason: 'max_output_tokens_recovery', attempt: 2 },
+}
+continue
+```
+
+这带来三个好处:
+
+1. **消灭"隐式携带"bug**:每个 continue 点必须声明每个字段,想沿用旧值也要显式写出来,不可能因为"忘了重置"而静默出错。
+2. **每个 continue 路径自描述**:不需要追踪前面的赋值历史,任意找一个 continue 块就能知道下一轮的完整状态。
+3. **`transition` 字段:零成本可观测性**:记录每次 continue 的原因(源码注释明说是专为测试设计的):
+
+```typescript
+transition: { reason: 'next_turn' }
+transition: { reason: 'max_output_tokens_recovery', attempt: 2 }
+transition: { reason: 'reactive_compact_retry' }
+transition: { reason: 'stop_hook_blocking' }
+transition: { reason: 'collapse_drain_retry' }
+```
+
+测试可以直接断言 `state.transition.reason === 'reactive_compact_retry'`,而不必解析消息内容推断"压缩是否发生了"。**这个字段也是天然的 debug 信息**:任何时候 dump 出 state 就能知道循环目前处于哪个恢复阶段。
+
+注意:`state.messages` **不是** API payload。实际发给 API 的是 `prependUserContext(messagesForQuery, userContext)`,其中 `messagesForQuery` 是 `state.messages` 经过多层变换(compact 边界过滤 → 工具结果截断 → microcompact → autocompact)后的结果。State 本身不落盘,只有 queryLoop yield 出来的 Message 才被 QueryEngine 记录到 transcript。
+
+AgentRunner 当前直接在迭代内修改变量,没有这个整体替换的抽象。
+
+### 2.2 工具执行时机(最大差异)
+
+queryLoop 有 `StreamingToolExecutor`(feature gate),工具在模型**还在流式输出期间**就并发开始执行:
+
+```
+模型流式输出:  [tool1 完整] ── [tool2 完整] ── [tool3 完整] ── 流结束
+StreamingExecutor: └→ 立即开始执行  └→ 立即开始执行  └→ 立即开始执行
+                        ↓ 执行完立即 yield 结果,不等流结束
+```
+
+工具执行时间被完全"藏"在模型输出时间里。AgentRunner 等 LLM response 完整后才执行工具。
+
+### 2.3 循环内多层恢复路径
+
+queryLoop 内置了多条恢复路径,全部通过 `state = next; continue` 在**循环内**动态触发:
+
+```
+prompt_too_long → 1. context_collapse drain(轻量,清空 staged collapses)
+               → 2. reactive compact(重新压缩,LLM 调用)
+               → 失败 → 暴露错误,return
+
+max_output_tokens → 1. 升级到 64k(maxOutputTokensOverride,一次机会)
+                 → 2. 多轮文本恢复(最多 3 次,注入 meta 消息)
+                 → 3. 耗尽 → 暴露错误,return
+
+模型降级(FallbackTriggeredError)→ 切换 fallbackModel,丢弃已有输出,重试
+
+stop hook blocking → 注入 hook 错误消息,continue
+```
+
+AgentRunner 的恢复(`_heal_orphaned_tool_calls`)发生在进入循环**之前**(`_build_history` 阶段),不是循环内的动态恢复。
+
+### 2.4 周期性上下文注入
+
+AgentRunner **主动、周期性**注入 GoalTree + Collaborators:
+```python
+if iteration % 10 == 0:
+    inject_context(goal_tree, collaborators)
+```
+
+queryLoop 的记忆是**被动消费**的:`startRelevantMemoryPrefetch` 在每轮开头触发异步预取,settled 且本轮尚未消费时才注入为 attachment。没有强制周期,也没有计划结构注入。
+
+### 2.5 Hooks:外部介入点
+
+这是两个系统差距最大的维度之一。
+
+**Cyber Agent `context_hooks`**:每 10 轮调用一次,返回 markdown 字符串注入为上下文。功能是**只读注入**,无法阻断或修改任何事物。
+
+**CC hooks**:覆盖 25+ 事件,三种执行模式(shell / HTTP / TypeScript 回调),每个事件有专用的双向契约:
+
+| 维度 | Cyber Agent `context_hooks` | CC hooks |
+|------|---------------------------|---------|
+| **事件数量** | 1(周期注入) | 25+(全生命周期) |
+| **工具生命周期** | 无 | PreToolUse / PostToolUse / PostToolUseFailure / PermissionDenied |
+| **loop 控制** | 无 | Stop hook block → `stopHookActive=true` → 强制 continue |
+| **修改输入/输出** | 无 | PreToolUse 改 toolInput;PostToolUse 改 MCP 输出 |
+| **阻断执行** | 无 | PreToolUse deny;PostToolUse preventContinuation |
+| **执行模式** | Python 函数 | shell / HTTP / TypeScript callback / postSampling |
+| **会话级注册** | 随 AgentRunner 传入 | `addFunctionHook()`,Map-based,O(1),高并发友好 |
+
+三个最重要的 hook 事件在 loop 上下文里:
+
+- **PreToolUse**:在权限检查之前调用,可 `allow`/`deny`/`ask` + 修改 toolInput。允许外部系统做"无人值守的审批代理",比交互弹窗更适合自主运行场景。
+- **Stop hook**:每轮结束时调用,若返回 block,则 `state.transition = 'stop_hook_blocking'`,loop 强制再 continue 一轮。这是"外部系统驱动 Agent 继续工作"的接入点。
+- **UserPromptSubmit**:用户消息进队列时调用,可注入 `additionalContext` 或改写消息本体。比 `context_hooks` 的定时注入更精确(每次用户输入时触发,而非每 10 轮)。
+
+### 2.6 工具摘要的异步隐藏
+
+queryLoop 在工具执行完毕后 fire-and-forget 一个 Haiku 摘要任务,**不等它**,Promise 存入 `nextPendingToolUseSummary`,下一轮迭代开头才 await:
+
+```typescript
+// 工具执行完毕后立即触发(不阻塞当前轮)
+nextPendingToolUseSummary = generateToolUseSummary({...})
+
+// 下一轮迭代开头取结果——此时 Haiku ~1s 早已完成
+const summary = await pendingToolUseSummary
+```
+
+Haiku 摘要的延迟被下一轮 5-30s 的 API 调用时间完全吸收。AgentRunner 没有对应机制。
+
+---
+
+## 三、持久化与消息结构
+
+### Cyber Agent:消息树(非破坏性)
+
+每条 Message 有 `sequence`(全局递增)和 `parent_sequence`,构成树结构:
+
+```
+正常执行:   1 → 2 → 3 → 4 → 5
+Rewind 到3:             3 → 6 → 7    (4,5 自动脱离主路径)
+压缩 1-3:   8(summary,parent=None) → 6 → 7
+侧分支:     5 → 6(reflection) → 7   (不在主路径)
+             5 → 8(summary, 主路径)
+```
+
+`trace.head_sequence` 始终指向主路径头,`build_llm_messages` 沿链回溯,侧分支自动被过滤。所有历史永久保留,Rewind 是非破坏性操作。
+
+### Claude Code:线性历史(有损压缩)
+
+消息以数组形式存在内存中,转录文件是 append-only JSONL。没有树结构,压缩是破坏性的(原始消息不再恢复)。QueryEngine 维护完整的 `mutableMessages`,queryLoop 内的 `state.messages` 在压缩后被替换为压缩后版本。
+
+**关键差异**:Cyber Agent 是非破坏性时间旅行;Claude Code 是有损折叠。代价是 Cyber Agent 存储开销更高,查询需要沿链回溯。
+
+---
+
+## 四、计划管理
+
+### Cyber Agent:原生 GoalTree
+
+GoalTree 是一等公民,有独立数据模型(`Goal`)、专用工具(`goal`)、持久化(`goal.json`)和周期注入机制:
+
+```
+1. [in_progress] 分析代码架构
+   1.1. [completed] 读取项目结构
+   1.2. [in_progress] 分析核心模块
+        1.2.1. [pending] 分析 runner.py
+        1.2.2. [pending] 分析 tool_registry.py
+```
+
+- Goal 与 Message 关联(`msg.goal_id`),压缩以 Goal 为粒度
+- 每 10 轮自动注入,模型始终知道自己在做什么
+- 支持 `focus` / `done`(附 summary)/ `abandon`(附原因)
+
+### Claude Code:TodoWriteTool(扁平备忘录)
+
+扁平的待办列表,markdown 文本,格式由模型自行决定。无层级结构,无与消息的关联,不跨 session 持久化,不参与压缩粒度决策。
+
+**结论**:这是两个系统差距最大的维度之一。GoalTree 是架构级方案,TodoWriteTool 是临时补丁。
+
+---
+
+## 五、记忆系统
+
+### Cyber Agent:三层记忆,Agent 主动写入
+
+```
+Layer 3: Skills(技能库)
+  Markdown 文件,领域知识和操作规范,启动时注入 system prompt
+        ▲ 归纳
+Layer 2: Knowledge(知识库)
+  数据库 + 向量索引(KnowHub),语义检索按需注入
+        ▲ 提取(Reflect 机制)
+Layer 1: Trace(任务状态)
+  当前任务的工作记忆:Messages + GoalTree
+```
+
+- **Reflect**:执行完成后触发侧分支 Agent,分析历史,调用 `knowledge_save` 结构化入库
+- **跨任务学习**:知识从一个 Trace 提取后,下一个 Trace 可语义检索复用
+
+### Claude Code:二层记忆,人工维护
+
+```
+Layer 2: CLAUDE.md(持久指令)
+  项目/用户级 Markdown,自动发现(目录树遍历),注入 system prompt
+
+Layer 1: Session 历史(工作记忆)
+  当前对话历史(内存),超长时自动压缩折叠
+```
+
+跨 session 保留的内容只有写入 CLAUDE.md 的部分,Agent 本身不会主动提炼知识。
+
+### 记忆组织轴的根本差异
+
+| | Claude Code | Cyber Agent |
+|--|-------------|-------------|
+| **组织轴** | 人为定义的实体(用户、项目) | 执行本身涌现的语义主题 |
+| **边界划定者** | 人(目录结构、项目边界) | Agent(`type` + `tags` + `scopes`) |
+| **谁来写记忆** | 人(或由 Claude 辅助人写 CLAUDE.md) | Agent 自身(Reflect 触发后主动入库) |
+
+Claude Code 的记忆是**静态附加的上下文**,边界由人预先划定。Cyber Agent 的记忆可以从**执行本身涌现**,围绕"自主执行的任务"或"跨任务的全局线索"(如某类 bug 的修复模式)自然积累。
+
+---
+
+## 六、上下文压缩
+
+### Cyber Agent:两级压缩 + 侧分支(非破坏性)
+
+**Level 1(Goal 完成压缩,零 LLM 成本)**:以 Goal 为粒度,completed goal 的消息折叠为 `long_term_memory` 摘要,确定性,无额外 API 调用。
+
+**Level 2(LLM 侧分支压缩)**:`POST /compact` 触发,在 Trace 末尾创建 `branch_type="compression"` 侧分支,压缩结果作为 summary 消息插入主路径。原始消息保留在侧分支,可审计。
+
+控制参数:`RunConfig(goal_compression="none"|"on_complete"|"on_overflow")`
+
+### Claude Code:多层被动触发(有损)
+
+每轮迭代前依次经过:`HISTORY_SNIP` → `microcompact` → `CONTEXT_COLLAPSE` → `autocompact`(token 接近阈值时触发)。触发后用摘要模型折叠历史,原始消息丢失。`prompt_too_long` 错误时进入 reactive compact(最后手段)。
+
+| 方面 | Cyber Agent | Claude Code |
+|------|-------------|-------------|
+| 压缩粒度 | Goal 级(语义边界) | 全量历史折叠 |
+| 压缩成本 | Level 1 零成本;Level 2 LLM 成本 | 始终有 LLM 成本 |
+| 压缩可逆性 | ✅ 侧分支,原始消息保留 | ❌ 有损折叠,原始丢失 |
+| 触发时机 | 主动(on_complete / on_overflow) | 被动(超限后)+ 手动 |
+
+---
+
+## 七、多 Agent 协作
+
+### Cyber Agent:递归 Trace + 跨设备协议
+
+```
+主 Agent(Trace A)
+    ├── agent(task="研究X")           → 子 Trace B(独立持久化,可单独续跑)
+    │    └── agent(task="分析子模块") → 子 Trace C(递归)
+    └── agent(agent_url="https://remote/", task="...")
+         → 远程 Trace(agent://remote/xxx,跨设备)
+```
+
+- **explore 模式**:`task: List[str]` 触发 `asyncio.gather()` 并行多 Agent
+- **evaluate 工具**:专用评估 Agent,从 GoalTree 自动注入评估标准
+- **续跑子 Agent**:`agent(continue_from=sub_trace_id)` 可续跑被中断的子 Agent
+
+### Claude Code:Coordinator-Worker(内存中)
+
+```
+Coordinator Agent
+    ├── AgentTool(subagent_type="worker") → Worker 1(内存,非持久化)
+    ├── AgentTool(subagent_type="worker") → Worker 2
+    └── SendMessageTool(to=agent_id)      → 向活跃 Worker 发消息
+```
+
+Worker 以 `<task-notification>` XML 上报状态,通过 `SendMessageTool` 持续对话。
+
+| 方面 | Cyber Agent | Claude Code |
+|------|-------------|-------------|
+| 子 Agent 持久化 | ✅ 独立 Trace | ❌ 内存中 |
+| 续跑子 Agent | ✅ `continue_from` | ✅ `SendMessageTool` |
+| 跨设备协作 | ✅ `agent_url` + 远程 Trace ID | ❌ 不支持 |
+| 并行执行 | ✅ explore 模式 | ✅ 多 Worker 并行 |
+| 评估反馈循环 | ✅ evaluate 工具 | ❌ 无原生评估工具 |
+
+---
+
+## 八、工具系统
+
+### 工具定义
+
+**Cyber Agent(装饰器,轻量):**
+```python
+@tool(description="执行 shell 命令")
+async def bash(command: str, ctx: ToolContext) -> ToolResult:
+    return ToolResult(
+        output=result.stdout,
+        long_term_memory=f"Ran: {command}",  # 压缩后保留的摘要
+    )
+```
+
+**Claude Code(接口,功能丰富):** 每个工具实现约 15 个方法:`call`、`description`、`checkPermissions`、`isReadOnly`、`isConcurrencySafe`、`prompt`(注入 system prompt 的说明)、`renderToolResultMessage`(UI 渲染)等。接口更重,但权限检查、并发安全标记、进度报告、UI 渲染都内置其中。
+
+### ToolResult 双层记忆(Cyber Agent 特有)
+
+```python
+ToolResult(
+    output="<10K tokens 完整内容>",      # 第一次给 LLM 完整内容
+    long_term_memory="提取了 10000 字符", # 之后只看摘要
+    include_output_only_once=True
+)
+```
+
+大输出只在首次注入完整内容,后续轮次自动替换为摘要,有效节约 context window。Claude Code 没有对应机制。
+
+### Bash 安全(Claude Code 更精细)
+
+Cyber Agent 通过 `AgentPreset.denied_tools` 黑名单控制,粒度是整个工具。
+
+Claude Code 有独立的 102KB 安全分析器(`bashSecurity.ts`):
+- 命令语义分析(参数级,不只是工具名匹配)
+- 路径白名单(工作目录 + 额外允许目录)
+- 破坏性命令检测(rm -rf, dd, mkfs 等)
+- 模式匹配规则:`"Bash(git *)"` 自动允许所有 git 命令
+
+---
+
+## 九、权限系统
+
+### Cyber Agent:静态预设(工具级)
+
+```python
+AgentPreset(
+    allowed_tools=["read", "glob", "grep"],
+    denied_tools=["write", "edit", "bash"],
+)
+```
+
+工具级粒度,静态配置,无动态审批。适合离线/批量 Agent 场景。
+
+### Claude Code:规则引擎(参数级)+ 交互审批
+
+- 规则格式:`"Bash(git *)"` 支持 glob 模式匹配到参数级
+- 三种规则:alwaysAllow / alwaysDeny / alwaysAsk
+- 权限模式:default(交互)/ auto(ML 分类器)/ bypass
+- **规则自动学习**:用户选择"始终允许"后自动写入配置,构建个性化规则库
+- 企业远程策略:通过 managed settings 下发权限规则
+
+适合面向开发者的交互式场景。
+
+---
+
+## 十、可观测性
+
+### Cyber Agent:结构化事件流(服务视角)
+
+- **WebSocket** `/api/traces/{id}/watch`:实时推送所有 Message 事件
+- **REST API**:查询 Trace、Messages(main_path / all)、GoalTree
+- **GoalTree 统计**:每个 Goal 有 `self_stats` 和 `cumulative_stats`(token、cost、duration)
+- **全历史可查**:包括 rewind 旧路径、侧分支
+
+### Claude Code:终端 UI + 遥测(工具视角)
+
+- **Ink 终端 UI**:实时 spinner、CoordinatorTaskPanel
+- **OpenTelemetry**:结构化日志(懒加载)
+- **无外部 API**:不暴露 HTTP 接口
+
+**关键差异**:Cyber Agent 从架构上是**服务**,天然可集成监控;Claude Code 是**工具**,可观测性服务于操作者自己。
+
+---
+
+## 总结:差异与互补
+
+### Cyber Agent 的架构优势
+
+| 优势 | 价值 |
+|------|------|
+| **消息树 + Rewind** | 非破坏性时间旅行,支持回溯,全历史可审计 |
+| **GoalTree** | 结构化计划,与消息关联,驱动细粒度压缩 |
+| **Knowledge + Reflect** | 跨任务知识积累,Agent 主动从经验中学习 |
+| **记忆组织轴** | 围绕执行语义积累,不依赖人为划定边界 |
+| **evaluate 工具** | 内置评估-反馈循环,Agent 可自我校正 |
+| **ToolResult 双层记忆** | 精细的 context 节约机制 |
+| **跨设备 Agent 协作** | 分布式 Agent 网络,协议级支持 |
+| **Context Injection Hooks** | 灵活的外部事件注入(A2A IM、监控告警等) |
+| **服务化 API** | REST + WebSocket,天然可集成、可观测 |
+
+### 值得从 Claude Code 借鉴的设计
+
+| 设计 | 价值 |
+|------|------|
+| **Loop State 整体替换 + transition 字段** | 消灭"隐式携带"bug;transition 记录每次 continue 的原因,可用于测试断言和 debug,扩展后也可通过 WebSocket 推出去实现循环级可观测性 |
+| **流式工具并发执行** | 工具在模型流式输出期间就开始执行,延迟藏在模型输出时间里 |
+| **循环内多层恢复路径** | prompt_too_long / max_tokens / 模型降级等错误在循环内动态恢复,无需重启 |
+| **工具摘要异步隐藏** | Haiku 摘要在工具执行后 fire-and-forget,延迟藏在下一轮 API 调用时间里 |
+| **Bash 语义级安全分析** | 参数级权限控制,远比工具级黑名单精准,生产环境自主运行时重要 |
+| **ToolSearch 延迟加载** | 工具数量大时按需加载描述,节约 system prompt token |
+| **权限规则自动学习** | "始终允许"自动写入配置,构建个性化规则库 |
+| **工具生命周期 hooks(PreToolUse / PostToolUse)** | 在工具执行前后插入外部逻辑:审批代理(PreToolUse allow/deny)、MCP 输出改写(PostToolUse updatedMCPToolOutput)、失败后通知(PostToolUseFailure)。Cyber Agent 目前对工具执行无介入点 |
+| **Stop hook(loop 控制)** | 每轮结束时调用,可返回 block 强制 loop 再 continue 一轮(`transition='stop_hook_blocking'`)。这是"外部事件驱动 Agent 继续"的最直接接入点 |
+| **UserPromptSubmit hook** | 用户消息进队列时触发,可注入 `additionalContext` 或改写消息。比 `context_hooks` 定时注入更精确,也更适合做"收到外部信号时注入上下文"的触发器 |
+| **工具结果自动存档(toolResultStorage)** | 每个工具声明 `maxResultSizeChars`,超限时自动写入磁盘临时文件,API 收到 preview(前 2000 字节)+ 文件路径,模型可按需用 FileReadTool 读回完整内容。与 Cyber Agent 的 `long_term_memory`(手动摘要、原始内容丢弃)互补:这是零开发者工作量的兜底层,防止意外大输出撑爆 context,且原始内容可恢复 |
+
+---
+
+## 待办:值得引入的改进
+
+| 优先级 | 改进项 | 说明 |
+|--------|--------|------|
+| ★★★ | **Loop State 整体替换 + transition 字段** | 将 AgentRunner loop 的跨迭代变量收拢为一个整体替换的 State 对象;每个 continue 点声明完整下一轮状态,消灭"忘记重置某字段"的隐患。新增 `transition` 字段记录每次 continue 的原因(`tool_use` / `compressed` / `healed_orphan` 等),可直接用于测试断言,也可通过 WebSocket 推出去作为循环级可观测事件 |
+| ★★★ | **工具结果自动存档** | 每个工具声明 `max_result_size`(字符数),超限时自动写入临时文件,给模型 preview(前 N 字节)+ 文件路径,模型可用 `read` 工具读回完整内容。与现有 `long_term_memory`(开发者手写精炼摘要)互补:这是零开发者工作量的兜底层,防止 `bash` 大输出、`webfetch` 整页 HTML 等意外撑爆 context |
+| ★★☆ | **工具并发执行** | 单次 LLM 响应包含多个 tool_use 时,用 `asyncio.gather()` 并行执行,而非顺序执行。对"同时读取多个文件"等场景延迟改善明显 |
+| ★★☆ | **工具摘要异步隐藏** | 工具执行完毕后 fire-and-forget 启动摘要生成(用小模型),不阻塞当前轮,下一轮迭代开头取结果。摘要延迟藏在下一次 LLM 调用时间里,对用户零感知 |
+| ★★☆ | **工具生命周期 hooks** | 在 `ToolRegistry` / `AgentRunner` 层面引入 `pre_tool_use` / `post_tool_use` / `post_tool_use_failure` 三个事件。最高价值用例:① `pre_tool_use` 实现无人值守审批代理(尤其对 `bash` 危险命令);② `post_tool_use_failure` 触发外部通知或重试策略;③ `post_tool_use` 对特定 MCP 工具的输出做后处理 |
+| ★★☆ | **Stop hook + Loop 控制接入点** | 在每轮 loop 结束时(得到 `stop_reason='end_turn'`)触发一个 hook,如果返回 block 则注入消息并 continue。这让外部系统(监控、测试框架)可以在运行时"插入"下一步指令,而无需重新发起整个对话 |
+| ★☆☆ | **循环内多层恢复路径** | 目前 `_heal_orphaned_tool_calls` 在进入循环前执行。可考虑将更多恢复逻辑移入循环内(context 超限时自动触发压缩并 continue,而非报错退出) |
+
+---
+
+*对比基于 Cyber Agent 文档(`architecture.md`)与 Claude Code v2.1.88 npm 包 source map 分析(非官方重建,仅供研究),生成日期:2026-04-02。*

+ 277 - 0
agent/agent/docs/context-management.md

@@ -0,0 +1,277 @@
+# Context 管理
+
+## 文档维护规范
+
+0. **先改文档,再动代码** - 新功能或重大修改需先完成文档更新、并完成审阅后,再进行代码实现;除非改动较小、不被文档涵盖
+1. **文档分层,链接代码** - 重要或复杂设计可以另有详细文档;关键实现需标注代码文件路径;格式:`module/file.py:function_name`
+2. **简洁快照,日志分离** - 只记录最重要的、与代码准确对应的或者明确的已完成的设计的信息,避免推测、建议、决策历史、修改日志、大量代码;决策依据或修改日志若有必要,可在 `docs/decisions.md` 另行记录
+
+---
+
+## 概述
+
+Context = 每次 LLM 调用时发送的完整消息列表。管理目标:
+
+1. **注入**:确保模型在正确的时间看到正确的信息(system prompt、plan、skill 策略)
+2. **压缩**:在 token 预算内去除冗余,保留核心上下文
+
+两者互相影响:压缩可能清掉已注入的内容,需要重新注入。
+
+---
+
+## Context 构成
+
+```
+[system]     System Prompt(角色 + 内置 skills)        ← 静态注入,trace 创建时
+[user]       第一条用户消息
+[assistant]  ...
+[tool]       ...                                         ← 可能包含周期注入的 context
+[assistant]  ...
+[tool]       ...                                         ← 可能包含动态注入的 skill
+[user]       第 N 条用户消息
+[assistant]  待模型生成
+```
+
+---
+
+## 注入机制
+
+### 1. System Prompt(静态注入)
+
+Trace 创建时构建一次,后续续跑不重复发送。
+
+内容:角色 prompt + 按 preset/call-site 过滤的内置/项目 skills。
+
+优先级:`messages 中的 system message` > `config.system_prompt` > `preset.system_prompt` > `DEFAULT_SYSTEM_PREFIX`
+
+**实现**:`agent/core/runner.py:_build_system_prompt`
+
+---
+
+### 2. Context 周期注入(当前实现)
+
+每 `CONTEXT_INJECTION_INTERVAL`(当前=5)轮迭代,框架自动向模型的 tool_calls 中追加一条 `get_current_context` 调用(如果模型本轮未主动调用)。
+
+注入内容(`_build_context_injection` 构建):
+- 当前时间
+- GoalTree(精简视图)+ focus 提醒
+- Active Collaborators 状态
+- IM 消息通知(如有)
+- Context Injection Hooks 注册的自定义内容
+
+注入形态:**合成的 tool_call + tool_result 消息对**。模型看到的效果等同于"我之前主动调用了 get_current_context"。
+
+```
+[assistant]  tool_calls: [..., get_current_context()]   ← 框架自动追加
+[tool]       { 当前时间, GoalTree, Collaborators... }   ← 工具正常执行
+```
+
+**特点**:
+- 固定间隔,不做去重(每次内容都是最新状态快照)
+- 仅在主路径执行(侧分支中跳过)
+- 检查模型是否已主动调用,避免重复
+
+**实现**:`agent/core/runner.py`(`CONTEXT_INJECTION_INTERVAL`, 工具执行后的自动注入逻辑)
+
+**详细文档**:[架构设计 § Context Injection Hooks](./architecture.md#context-injection-hooks上下文注入钩子)
+
+---
+
+### 3. Skill 指定注入(设计中,未实现)
+
+区别于模型通过 `skill()` 工具主动加载 skill,指定注入是**调用方在 `runner.run()` 时声明**需要哪些 skill,由框架自动注入。
+
+#### 动机
+
+System Prompt 中的内置 skills 是启动时一次性注入的。但某些 skill(如 Librarian 的查询策略/上传策略):
+- 不是每次都需要(按任务类型按需加载)
+- 在长期续跑的 agent 中会被压缩掉,需要重新注入
+- 应该和现有的 `skill` 工具统一管理
+
+#### 启用方式
+
+调用方通过 `runner.run()` 的 `inject_skills` 参数声明(不放在 RunConfig 中,因为同一个 agent 的不同调用可能需要不同的 skill):
+
+```python
+# Librarian ask 场景
+async for item in runner.run(
+    messages=[{"role": "user", "content": "[ASK] 查询内容"}],
+    config=config,
+    inject_skills=["ask_strategy"],         # 本次调用需要的 skill
+    skill_recency_threshold=10,             # 最近 N 条消息内有就不重复注入
+):
+    ...
+
+# Librarian upload 场景(同一个 agent,不同 skill)
+async for item in runner.run(
+    messages=[{"role": "user", "content": "[UPLOAD:BATCH] 数据"}],
+    config=config,
+    inject_skills=["upload_strategy"],
+    skill_recency_threshold=10,
+):
+    ...
+```
+
+#### 注入机制
+
+框架在工具执行阶段自动注入(与 context 周期注入同级处理)。
+
+**注入形态**:合成的 `skill("xxx")` tool_call + tool_result 消息对,复用现有 `skill` 工具。
+
+```
+[user]       [ASK] 有没有工具能做人物姿态控制?
+[assistant]  tool_calls: [skill("ask_strategy")]        ← 框架自动生成
+[tool]       { ask_strategy skill 完整内容 }             ← skill 工具正常执行
+[assistant]  让我来检索...                               ← 模型真实输出
+```
+
+#### 防重复:message ID 追踪
+
+在 `trace.context` 中维护已注入 skill 的记录:
+
+```python
+trace.context["injected_skills"] = {
+    "ask_strategy": {
+        "message_id": "msg_abc123",   # tool_result 消息的 ID
+        "sequence": 42                # 最新注入的消息序列号
+    }
+}
+```
+
+注入前检测流程:
+
+1. 查 `injected_skills[skill_id].message_id`
+2. 检查该 message_id 是否仍在当前提交给模型的消息列表中
+3. 在且 sequence 在最近 `recency_threshold` 条消息内(从当前提交给模型的列表中查,不按编号计算) → 跳过
+4. 不在(被压缩掉)或超过阈值 → 重新注入,更新记录
+
+#### Skill 的两种加载方式
+
+| 方式 | 触发者 | 场景 |
+|---|---|---|
+| 指定注入(本节) | 调用方,通过 `run(inject_skills=...)` | 已知任务类型,确保策略在场 |
+| 主动加载 | 模型自己调 `skill()` 工具 | 遇到未预见场景,自主判断需要什么 |
+
+两者都走 `skill` 工具,消息格式一致。
+
+#### Skill 文件位置
+
+指定注入的 skill 文件遵循现有 skill 体系(Markdown + frontmatter),按项目组织:
+
+```
+knowhub/agents/skills/         # KnowHub Librarian 的 skills
+├── ask_strategy.md            # 查询检索策略
+└── upload_strategy.md         # 上传编排策略
+```
+
+---
+
+## 压缩机制
+
+压缩分两级,通过 `RunConfig.goal_compression` 控制 Level 1 行为:
+
+| 模式 | 值 | Level 1 行为 | Level 2 行为 |
+|------|---|---|---|
+| 不压缩 | `"none"` | 跳过 Level 1 | 超限时直接进入 Level 2 |
+| 完成后压缩 | `"on_complete"` | 每个 goal 完成时立刻压缩该 goal 的消息 | 超限时进入 Level 2 |
+| 超长时压缩 | `"on_overflow"` | 超限时遍历所有 completed goal 逐个压缩 | Level 1 后仍超限则进入 Level 2 |
+
+默认值:`"on_overflow"`
+
+### Level 1:Goal 完成压缩(确定性,零 LLM 成本)
+
+对单个 completed/abandoned goal 的压缩逻辑:
+
+1. **识别目标消息**:找到该 goal 关联的所有消息(`msg.goal_id == goal.id`)
+2. **区分 goal 工具消息和非 goal 消息**:检查 assistant 消息的 tool_calls 中是否调用了 `goal` 工具
+3. **保留 goal 工具消息**:保留所有调用 `goal(...)` 的 assistant 消息及其对应的 tool result(包括 add、focus、under、done 等操作)
+4. **删除非 goal 消息**:从 history 中移除该 goal 的其他 assistant 消息及其 tool result(read_file、bash、search 等中间工具调用)
+5. **替换 done 的 tool result**:将 `goal(done=...)` 的 tool result 内容替换为 `"具体执行过程已清理"`
+6. **纯内存操作**:压缩仅操作内存中的 history 列表,不涉及新增消息或持久化变更,原始消息永远保留在存储层
+
+压缩后的 history 片段示例:
+
+```
+... (前面的消息)
+[assistant] tool_calls: [goal(focus="1.1")]
+[tool] goal result: "## 更新\n- 焦点切换到: 1.1\n\n## Current Plan\n..."
+[assistant] tool_calls: [goal(done="1.1", summary="前端使用 React...")]
+[tool] goal result: "具体执行过程已清理"
+... (后面的消息)
+```
+
+#### `on_complete` 模式
+
+在 goal 工具执行 `done` 操作后,立刻对该 goal 执行压缩。优点是 history 始终保持精简,缺点是如果后续需要回溯到该 goal 的中间过程,信息已丢失(存储层仍保留原始消息)。
+
+**触发点**:`agent/core/runner.py`(工具执行后检测 `goal(done=...)` 调用)
+
+#### `on_overflow` 模式
+
+在 `_manage_context_usage` 检测到 token 超限时,遍历所有 completed goal,逐个执行压缩,直到 token 数降到阈值以下或所有 completed goal 都已压缩。如果仍超限,进入 Level 2。
+
+**触发点**:`agent/core/runner.py:_manage_context_usage`
+
+**实现**:`agent/trace/compaction.py:compress_completed_goals`
+
+### Level 2:LLM 摘要压缩
+
+触发条件:Level 1 之后 token 数仍超过阈值(默认 `context_window × 0.5`)。
+
+通过侧分支队列机制执行,`force_side_branch` 为列表类型:
+
+1. **反思**(可选,由 `knowledge.enable_extraction` 控制):进入 `reflection` 侧分支,LLM 可多轮调用工具提取知识
+2. **知识评估**(可选):进入 `knowledge_eval` 侧分支,评估待评估知识
+3. **压缩**:进入 `compression` 侧分支,LLM 生成 summary
+
+侧分支队列示例:
+- 启用知识提取:`["reflection", "compression"]`
+- 有待评估知识:`["knowledge_eval", "compression"]`
+- 仅压缩:`["compression"]`
+
+压缩完成后重建 history 为:`system prompt + 第一条 user message + summary(含详细 GoalTree)`
+
+**实现**:`agent/core/runner.py:_agent_loop`(侧分支状态机), `agent/core/runner.py:_rebuild_history_after_compression`
+
+### 任务完成后反思
+
+主路径无工具调用(任务完成)时,如果 `knowledge.enable_completion_extraction` 为 True,通过侧分支进入反思,完成后退出循环。
+
+### GoalTree 双视图
+
+`to_prompt()` 支持两种模式:
+- `include_summary=False`(默认):精简视图,用于日常周期性注入
+- `include_summary=True`:含所有 completed goals 的 summary,用于 Level 2 压缩时提供上下文
+
+### 压缩存储
+
+- 原始消息永远保留在 `messages/`
+- 压缩 summary 作为普通 Message 存储
+- 侧分支消息通过 `branch_type` 和 `branch_id` 标记,查询主路径时自动过滤
+- 通过 `parent_sequence` 树结构实现跳过,无需 compression events 或 skip list
+- Rewind 到压缩区域内时,summary 脱离主路径,原始消息自动恢复
+
+---
+
+## 注入与压缩的交互
+
+压缩会移除消息,包括之前注入的 skill 和 context 内容。不同注入类型的应对策略:
+
+| 注入类型 | 压缩影响 | 恢复策略 |
+|---------|---------|---------|
+| System Prompt | Level 2 重建时保留 | 无需额外处理 |
+| Context 周期注入 | 可能被 Level 1/2 移除 | 固定间隔自动重注入(下一个 interval 即恢复) |
+| Skill 动态注入 | 可能被 Level 1/2 移除 | message ID 检测 → 自动重注入 |
+
+Skill 重注入的触发点:每次 runner 准备构建 LLM 调用前,检查 `injected_skills` 中记录的 message_id 是否仍在当前 history 中。不在则标记为需要重新注入,在下一轮工具执行阶段合成 tool_call。
+
+---
+
+## 相关文档
+
+| 文档 | 内容 |
+|------|------|
+| [架构设计](./architecture.md) | Agent 框架完整架构 |
+| [Skills 指南](./skills.md) | Skill 文件格式、分类、加载机制 |
+| [Prompt 规范](./prompt-guidelines.md) | 信息分层、条件注入原则 |
+| [Trace API](./trace-api.md) | 压缩和反思的 REST API |

+ 1372 - 0
agent/agent/docs/decisions.md

@@ -0,0 +1,1372 @@
+# 设计决策
+
+> 记录系统设计中的关键决策、权衡和理由。
+
+---
+
+## 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
+
+**详细设计**:见 [`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 回顾整个执行过程后生成知识总结,通过 `knowledge_save` 工具保存到 KnowHub。
+
+不单独调用 LLM、不解析结构化数据——反思就是一次普通的 agent 运行,只是 user message 是预置的反思 prompt。
+
+**注**:此决策中的"经验存储"设计(`./.cache/experiences.md` 文件)已被替换为 KnowHub 知识库(决策 24+),反思现在通过 `knowledge_save` 工具保存结构化知识。
+
+#### 19d. 经验存储简化为文件(已废弃)
+
+> **注**:此设计已被 KnowHub 知识库替代,保留此节仅作历史记录。
+
+经验存储从 MemoryStore(内存/数据库)简化为 `./.cache/experiences.md` 文件:
+- 人类可读可编辑(Markdown)
+- 可版本控制(git)
+- 新建 Trace 时由 Runner 读取并注入到第一条 user message 末尾
+- `GET /api/experiences` 直接读取文件内容返回
+
+### 最终 API 设计(已部分更新)
+
+> **注**:`GET /api/experiences` 端点已移除,知识管理现通过 KnowHub API 和 `knowledge_*` 工具实现。
+
+```
+控制类(3 个端点,替代原来的 3 个):
+  POST /api/traces              → 创建并运行(不变)
+  POST /api/traces/{id}/run     → 运行(合并 continue + rewind)
+  POST /api/traces/{id}/stop    → 停止(新增)
+
+学习类(1 个端点):
+  POST /api/traces/{id}/reflect → 追加反思 prompt 运行,通过 knowledge_save 保存知识
+```
+
+### 理由
+
+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. **知识管理**:通过 KnowHub 实现结构化知识存储和语义检索
+
+### 变更范围
+
+- `agent/trace/models.py` — Trace.status 增加 `"stopped"`
+- `agent/core/runner.py` — `_cancel_events` 字典,`stop()` 方法,agent loop 检查取消
+- `agent/trace/run_api.py` — 合并 `continue`/`rewind` 为 `run`,新增 `stop`/`reflect` 端点
+- `api_server.py` — 注入路由
+
+**实现**:`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 数仍超过阈值(默认 `context_window × 0.5`)。
+
+**注**:
+- 压缩判断仅基于 token 数量,不考虑消息数量(历史版本曾有 50 条消息的限制,已于 2026-03 移除)
+- Token 计算在图片优化后进行,确保基于实际消耗判断
+
+做法:在当前消息列表末尾追加压缩 prompt → 主模型回复 → summary 作为新消息存入 messages/,其 parent_sequence 跳过被压缩的范围。
+
+不使用 utility_llm,就用主模型。压缩和反思都是"在消息列表末尾追加 prompt,主模型回复"。
+
+#### 22d. 压缩前知识提取
+
+触发 Level 2 压缩之前,先在消息列表末尾追加反思 prompt → 主模型回复 → 通过 `knowledge_save` 工具保存到 KnowHub。反思消息为侧枝(parent_sequence 分叉,不在主路径上)。
+
+**注**:原设计中保存到 `./.cache/experiences.md` 文件,现已改为通过 KnowHub API 保存结构化知识。
+
+#### 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`
+
+---
+
+## Decision 24: 侧分支多轮 Agent 模式
+
+**日期**: 2026-03-09
+
+### 问题
+
+原有的压缩和反思使用单轮 LLM 调用,但这些任务可能需要多轮推理和工具调用才能做好:
+- **压缩**:可能需要查询 goal_tree 状态、分步总结
+- **反思**:可能需要先分析失败原因、再提取知识,或检查知识库避免重复
+
+单轮调用限制了 LLM 的推理能力,且改变 system prompt 或工具清单会导致缓存失效。
+
+### 决策
+
+**选择:侧分支在同一 agent loop 中以状态机模式运行**
+
+#### 24a. 核心设计
+
+侧分支不是递归调用 `_agent_loop`,而是在同一个循环中通过状态切换实现:
+
+```python
+# 主循环维护侧分支状态
+side_branch_ctx: Optional[SideBranchContext] = None
+
+for iteration in range(max_iterations):
+    # 进入侧分支:追加 prompt,设置状态
+    if needs_compression and not side_branch_ctx:
+        side_branch_ctx = SideBranchContext(...)
+        history.append({"role": "user", "content": compress_prompt})
+        continue
+
+    # 侧分支中:正常执行 LLM 调用和工具执行
+    result = await self.llm_call(history, tools=..., model=...)
+
+    # 退出侧分支:提取结果,回到起点
+    if side_branch_ctx and not tool_calls:
+        # 从数据库查询侧分支消息并提取 summary
+        all_messages = await trace_store.get_trace_messages(trace_id)
+        side_messages = [m for m in all_messages if m.branch_id == side_branch_ctx.branch_id]
+        summary = extract_summary(side_messages)
+
+        history = history[:side_branch_ctx.start_history_length]
+        # 创建主路径 summary 消息
+        side_branch_ctx = None
+        continue
+```
+
+**优势**:
+1. **缓存友好**:复用主路径的所有缓存,只有追加的 prompt 是新内容
+2. **工具自然可用**:不需要单独配置工具清单,agent 自由选择需要的工具
+3. **实现简洁**:不需要递归调用,状态管理清晰
+
+#### 24b. 侧分支上下文结构
+
+```python
+@dataclass
+class SideBranchContext:
+    type: str  # "compression" | "reflection"
+    branch_id: str
+    start_head_seq: int  # 起点的 head_seq
+    start_sequence: int  # 起点的 sequence
+    start_history_length: int  # 起点的 history 长度
+    start_iteration: int  # 侧分支开始时的 iteration
+    max_turns: int = 5  # 最大轮次
+```
+
+**设计说明**:
+1. **不维护 `side_messages` 列表**:所有侧分支消息已持久化到数据库(标记 `branch_id`),需要时通过查询获取,避免内存中的重复维护
+2. **复用主循环的 `iteration`**:不单独维护 `current_turn`,而是通过 `iteration - start_iteration` 计算侧分支已执行的轮次,简化计数逻辑
+
+#### 24c. 消息标记
+
+侧分支产生的消息通过 `branch_type` 和 `branch_id` 字段标记:
+- `branch_type`: "compression" | "reflection" | None(主路径)
+- `branch_id`: 同一侧分支的消息共享 branch_id
+- `parent_sequence`: 侧分支消息的 parent 指向主路径或前一条侧分支消息
+
+**关键设计**:`trace.head_sequence` 始终指向主路径的头节点。侧分支执行期间,`head_sequence` 保持在侧分支起点,不更新。侧分支完成后,创建主路径 summary 消息(parent 指向起点),然后更新 `head_sequence` 指向 summary。
+
+这样设计的好处:
+- `get_main_path_messages(trace_id, head_sequence)` 自然返回主路径消息
+- 侧分支消息通过 parent_sequence 链自动脱离主路径,无需额外过滤
+- 续跑时自动加载正确的主路径历史
+
+#### 24d. 停止条件
+
+侧分支使用与主 agent 相同的停止逻辑:
+- LLM 返回无工具调用 → 认为完成
+- 达到 `config.side_branch_max_turns` → 强制停止并处理:
+  - **压缩侧分支**:fallback 到单次 LLM 调用(无工具)
+  - **反思侧分支**:直接退出,不管结果
+
+用户在侧分支中追加的消息自动标记为侧分支消息,继续在侧分支中执行。
+
+#### 24e. 工具 context 传递
+
+侧分支信息通过 `context` 参数传递给工具,保持框架一致性:
+
+```python
+context = {
+    "store": self.trace_store,
+    "trace_id": trace_id,
+    "goal_id": current_goal_id,
+    "runner": self,
+    "goal_tree": goal_tree,
+    "knowledge_config": config.knowledge,
+    # 新增:侧分支信息
+    "side_branch": {
+        "type": side_branch_ctx.type,
+        "branch_id": side_branch_ctx.branch_id,
+        "is_side_branch": True,
+        "current_turn": side_branch_ctx.current_turn,
+        "max_turns": side_branch_ctx.max_turns,
+    } if side_branch_ctx else None,
+}
+```
+
+工具可以通过 `context.get("side_branch")` 感知自己是否在侧分支中执行,但当前不需要特殊处理。
+
+#### 24f. 主循环重构
+
+为避免主循环过于复杂,提取以下函数:
+- `_manage_context_usage()`: Context 用量检查、预警、压缩(整合 Level 1/2)
+- `_check_enter_side_branch()`: 检查是否需要进入侧分支
+- `_check_exit_side_branch()`: 检查是否需要退出侧分支
+- `_exit_side_branch()`: 执行退出逻辑(回到起点)
+- `_single_turn_compress()`: 单次 LLM 压缩(fallback 方案)
+
+主循环通过 `if not side_branch_ctx` 控制哪些逻辑只在主路径执行。
+
+#### 24g. 侧分支状态持久化
+
+侧分支状态存储在 `trace.context["active_side_branch"]`:
+- 进入侧分支时创建,记录 `max_turns`(来自 `RunConfig.side_branch_max_turns`,默认 5)
+- 每轮结束时更新 `current_turn`
+- 退出侧分支时清除
+- 续跑时自动恢复,使用持久化的 `max_turns` 值
+
+这确保了中断后可以继续完成侧分支,不浪费已执行的 LLM 调用。
+
+#### 24h. RunConfig 配置
+
+新增字段:
+- `side_branch_max_turns: int = 5` — 侧分支最大轮次,超过后强制退出
+- `force_side_branch: Optional[Literal["compression", "reflection"]] = None` — 强制进入侧分支(用于 API 手动触发压缩/反思)
+
+**force_side_branch 说明**:
+- 用于 API 接口手动触发压缩或反思(如 `/api/traces/{id}/compact`、`/api/traces/{id}/reflect`)
+- 设置后,agent loop 会在第一轮就进入指定类型的侧分支,而不是等待 context 超限
+- 侧分支完成后自动清除此配置(`config.force_side_branch = None`),避免影响后续续跑
+
+**API 触发实现**:
+- `/api/traces/{id}/reflect` — 设置 `RunConfig(force_side_branch="reflection")`,启动后台任务
+- `/api/traces/{id}/compact` — 设置 `RunConfig(force_side_branch="compression")`,启动后台任务
+- `agent/cli/interactive.py:manual_compact()` — 同样使用 `force_side_branch="compression"`,消费 `run()` 生成器
+
+**实现位置**:`agent/trace/run_api.py:reflect_trace`, `agent/trace/run_api.py:compact_trace`, `agent/cli/interactive.py:manual_compact`
+
+### 变更范围
+
+- `agent/trace/models.py` — Message 增加 `branch_type` 和 `branch_id` 字段
+- `agent/core/runner.py` — 增加 `SideBranchContext`,重构 `_agent_loop`
+- `agent/trace/compaction.py` — `_compress_history` 改为状态机模式
+- `agent/trace/protocols.py` — 查询接口支持过滤侧分支消息
+
+**实现**:`agent/core/runner.py:_agent_loop`, `agent/trace/models.py:Message`, `agent/trace/compaction.py`
+
+---
+
+## Decision 25: Agent 工具统一化 — `remote_` 前缀路由
+
+**日期**:2026-04-14
+
+**背景**:原先远端 Agent(Librarian、Research)通过各自独立的客户端工具(`ask_knowledge`、`ask_research`)调用,和本地 `agent` 工具签名不同;每加一个远端 Agent 要写一个新工具,模型要记忆多套调用约定。
+
+**决策**:
+1. 合并:所有远端服务(查询 / 上传 / 调研)走同一个 `agent` 工具,同一个服务器端点 `/api/agent`。
+2. 路由:`agent_type` 带 `remote_` 前缀 → HTTP 调用;否则本地执行。
+3. 全部同步:服务器处理完成后才返回结果。不保留原来 upload 的 202 异步语义——upload 数据只是 JSON,等同于一条 message,没必要特殊化。
+4. 完全删除客户端 `ask_knowledge` 和 `upload_knowledge` 工具,以及 `/api/knowledge/ask`、`/api/knowledge/research`、`/api/knowledge/upload` 端点。
+5. **一个 agent 多种模式**:Librarian 用 `skills` 参数切换模式(`ask_strategy` 查询 / `upload_strategy` 上传),**不**为每种模式拆独立 `agent_type`。这比依赖 task 内容或动态触发更显式、更可靠。
+6. **Skill 白名单机制**:远端每个 `agent_type` 定义 `ALLOWED_SKILLS`,调用方传的 skill 经白名单过滤。其他配置(`tools`/`model`/prompt)仍然纯服务器端。
+7. IO 契约:不发明 per-agent-type schema——Agent 之间通过 message 交流,结构化信息由 Agent 的 prompt 约定写进 message 文本,caller 自己 parse。
+8. 续跑:服务器不维护 `caller_trace_id → sub_trace_id` 映射;caller 显式传 `continue_from`。
+9. SDK 入口:`agent.invoke_agent()`(`agent/client.py`),统一路由 remote/本地。对应 Claude Code skill 薄脚本 `~/.claude/skills/agent/invoke.py` 透传 CLI 参数。**不再**用 `python -m agent.tools.builtin.subagent`——该路径触发 double-import bug(`agent.tools.builtin.__init__.py` 传递 import + runpy 作为 `__main__` 重载,环境变量在两次加载之间被污染)。
+10. 远端 Agent 的三条安全约束,每个 handler 直接在自己的 `RunConfig` 里配置(不搞抽象 helper):
+    - 禁止调用 `agent` / `evaluate`(防递归)——用 `tools=[...]` 精确列表 或 `exclude_tools=["agent","evaluate"]`
+    - 关闭自动知识提取 / 复盘(`enable_extraction` / `enable_completion_extraction` = False)
+    - 关闭自动知识注入(`enable_injection` = False,否则远端服务器会回调自己)
+    配合的底层机制:`RunConfig.exclude_tools: List[str]` 字段(重新引入),在 tool_groups / tools 基础上再扣减——一个通用字段,不只为远端场景。
+
+**理由**:
+- 加远端 Agent = 服务器注册 `agent_type`,客户端零改动
+- 服务器端无状态化(去掉 trace_map)
+- `remote_` 前缀让模型知道通信通道限制(不能用本地文件路径)
+
+**可用 agent_type 不做动态发现**:项目通过 prompt 显式告诉主 Agent 可用的远端类型,避免启动时依赖服务器。
+
+**实现**:`agent/tools/builtin/subagent.py`(路由)、`knowhub/server.py::agent_api`(`/api/agent`)、`knowhub/agents/librarian.py` / `research.py`(去 trace_map)。文档:`agent/docs/tools.md § Agent 工具`、`knowhub/docs/remote-agents.md`、`knowhub/docs/api.md`。
+
+---

+ 620 - 0
agent/agent/docs/memory.md

@@ -0,0 +1,620 @@
+# Memory 系统与元思考机制
+
+> 状态:已实现(2026-04)。本文档同时承担**设计理由**和**使用规范**。
+> 入口、工具、API 清单见文末"十、实现与入口"。
+> 一~九节解释"为什么这么做",改动前请先读懂论证。
+
+---
+
+## 概述
+
+本文档设计两个紧密关联的能力:
+
+1. **Memory**:为需要跨任务维持身份的 Agent 提供持久化记忆(Markdown 文件)
+2. **统一元思考机制**:整合现有的知识提取(reflection)、知识评估(knowledge_eval)和新增的记忆反思(dream),形成完整的"Agent 自我认知"能力
+
+---
+
+## 一、现有元思考机制梳理
+
+当前框架已实现两种元思考能力,都基于侧分支(side branch)机制:
+
+### 1.1 知识提取(reflection 侧分支)
+
+**目的**:从执行过程中提取客观知识,保存到 KnowHub(全局共享知识库)。
+
+**触发时机**:
+- 压缩前(`_manage_context_usage`,`runner.py:825-829`)
+- 任务完成后(`runner.py:1834-1838`,`enable_completion_extraction`)
+
+**输出**:调用 `upload_knowledge` 工具,保存 experience/tool/strategy/case 到 KnowHub。
+
+**Prompt**:`REFLECT_PROMPT`(压缩时)和 `COMPLETION_REFLECT_PROMPT`(任务完成后),定义在 `agent/core/prompts/knowledge.py`。
+
+**已知问题**:任务完成时触发的 reflection 使用 `config.knowledge.get_reflect_prompt()`(`runner.py:1249`),没有区分压缩场景和完成场景。应该在完成场景使用 `get_completion_reflect_prompt()`。
+
+### 1.2 知识评估(knowledge_eval 侧分支)
+
+**目的**:评估被注入的知识是否有用,记录到本地 `knowledge_log.json`。
+
+**触发时机**(详见 `agent/docs/cognition-log.md`):
+- Goal 完成时(`store.py:update_goal`,设置 `pending_knowledge_eval` 标志)
+- 压缩前(必须在压缩前完成评估,否则执行上下文丢失)
+- 任务结束时(兜底)
+
+**输出**:每条被注入的知识获得评估(irrelevant / unused / helpful / harmful / neutral),写入 `knowledge_log.json`。
+
+**当前局限**:评估结果只存本地,不自动回传 KnowHub。只有用户通过 API 手动反馈才同步。
+
+### 1.3 侧分支队列机制
+
+两种元思考通过 `force_side_branch` 队列协调执行顺序(`runner.py:1198-1207`):
+
+```
+压缩触发时的典型队列:
+  ["reflection", "knowledge_eval", "compression"]
+  
+任务完成时:
+  ["knowledge_eval"](先评估)→ ["reflection"](再提取)
+```
+
+每个侧分支执行完后 pop 队首,继续下一个,直到队列清空回到主路径。
+
+---
+
+## 二、缺失的能力:个人记忆
+
+### 问题
+
+现有的两种元思考都面向**全局共享知识**(KnowHub)。但有一类 Agent 需要维护**主观的、属于自己的长期记忆**:
+
+- 品味偏好、策略判断、风格积累、用户反馈
+- 属于特定 Agent 身份,不是公共知识
+- 需要人类能直接阅读和修改
+- 跨多次 trace 持续积累和演化
+- 可能被同一身份下的多个 Agent run 共享读写
+
+KnowHub 不适合承担这个职责——它是"大众点评",不是"个人日记"。
+
+### 记忆文件
+
+记忆以 Markdown 文件存储在指定目录下。每个文件覆盖一个语义维度。
+
+```
+{base_path}/
+├── taste.md        # 偏好判断
+├── strategy.md     # 当前策略
+├── skills.md       # 积累的技巧
+└── ...
+```
+
+**为什么是 Markdown 文件**:
+- 人类可直接阅读和修改(vim/VS Code 打开就能改)
+- Git 版本控制
+- Agent 用 read_file/write_file 工具即可操作,无需新增工具
+- 文件数量少(几个到十几个),不需要检索能力
+
+**共享读写**:同一身份下的多个 Agent run 可以读写同一组记忆文件。哪个 Agent 该关注哪些文件、怎么更新,由 dream prompt 来定义。
+
+---
+
+## 三、统一元思考模型
+
+### 三种元思考及其定位
+
+| | 知识提取(已有) | 知识评估(已有) | 记忆反思(新增) |
+|---|---|---|---|
+| **回答的问题** | 我学到了什么客观知识? | 给我的知识有没有用? | 这些经历对我的偏好/策略意味着什么? |
+| **输出目的地** | KnowHub(全局共享) | knowledge_log.json(本地) | 记忆文件(Agent 身份私有) |
+| **触发时机** | 压缩前、任务完成后 | Goal 完成、压缩前、任务结束 | Dream 时(见下文) |
+| **时效性要求** | 高(压缩会丢上下文) | 高(压缩会丢上下文) | 低(可以延迟处理) |
+| **实现机制** | reflection 侧分支 | knowledge_eval 侧分支 | dream 操作(新增) |
+
+**关键区分**:知识提取和知识评估必须在上下文丢失前完成(压缩前/任务结束时),所以是侧分支、即时触发。记忆反思可以延迟——甚至应该延迟,因为用户可能还有反馈、Agent 可能继续执行。
+
+### 为什么记忆反思不在 trace 结束时做
+
+trace 结束只意味着 Agent 行动完一个轮次。后续可能发生:
+- 用户在飞书里说"这个方向不对"
+- Agent 被唤醒在同一任务下继续执行
+- 新的 trace 产生了推翻前一个 trace 结论的信息
+
+如果 trace 一结束就做记忆反思,这些后续信息会被忽略。记忆反思的价值在于**综合一段时间的经历**,不是记录每次行动的即时感受。
+
+### 但知识提取仍然在压缩/完成时做(采用"提取-审核-提交"两阶段)
+
+知识提取必须在压缩/完成时做,因为压缩会删除历史,不在压缩前提取,知识就永久丢失。
+
+但"立即 upload 到 KnowHub"这一步并不需要立即做。所谓"客观知识"也可能被后续推翻:
+
+- 工具用法可能被后续 trace 发现是错的(例如某个参数其实有副作用)
+- 调研结论可能被用户反馈推翻
+- 一次 trace 的"成功经验"在更长窗口看可能是反模式
+
+如果 reflection 直接 upload 到 KnowHub,错误知识会立刻污染全局检索,影响所有 Agent。
+
+**两阶段方案**:
+
+```
+Step 1: extract(自动,压缩前/任务结束)
+  Reflection 侧分支提取知识 → 写 cognition_log: type="extraction_pending"
+  不调用 upload_knowledge(信息保全已完成)
+
+Step 2: review(人工,CLI 里逐条决策)
+  approve / edit / discard → 写 cognition_log: type="extraction_reviewed"
+
+Step 3: commit(人工触发,批量上传)
+  把 reviewed=approved 的批量 upload_knowledge
+  写 cognition_log: type="extraction_committed"
+```
+
+review 和 commit 分开的理由:review 是逐条语义判断(要不要、内容对不对),commit 是机械批量动作。两者分离允许用户分批 review、最后一次 commit;也允许撤回 review 决策。
+
+**默认行为**:所有 Agent(包括默认 Agent 和 memory-bearing Agent)`reflect_auto_commit` 默认关闭,pending 提取必须人工 review + commit 才会进 KnowHub。如需自动直通(保留旧行为),手动在 `KnowledgeConfig` 里打开 `reflect_auto_commit=True`。
+
+这与"信息保全 vs 全局发布解耦"的原则一致 —— 压缩前必须做的是**保全**(写本地 cognition_log),**发布**到 KnowHub 可以延迟到有人确认时。
+
+---
+
+## 四、Dream:记忆反思的触发与流程
+
+### 什么是 Dream
+
+Dream 是 memory-bearing Agent 的记忆整理操作。它不是一个侧分支(不在某个 trace 的执行过程中插入),而是一个**独立的顶层操作**,回顾多个 trace 的执行历史,更新记忆文件。
+
+### 触发方式
+
+- Agent 主动调用 `dream` 工具("我觉得该整理一下了")
+- 外部调度触发(定时、或人工 CLI 触发)
+- 框架可以在 run 启动时检测距上次 dream 的时间/trace 数量,建议 Agent dream
+
+### 反思状态追踪
+
+```
+Trace 模型新增字段:
+- reflected_at_sequence: Optional[int]    # 上次记忆反思时的最新 message sequence
+                                           # None = 从未被记忆反思处理
+```
+
+反思摘要不存在 Trace 模型中,而是作为 `reflection` 事件写入 `cognition_log.json`(详见 `agent/docs/cognition-log.md`)。
+
+- Agent run 产生新 message → `reflected_at_sequence` 自然落后于实际 sequence
+- 记忆反思完成 → 更新 `reflected_at_sequence` 为当前最新 sequence
+- Dream 扫描 `reflected_at_sequence < latest_sequence` 的 trace
+
+### Dream 流程
+
+```
+Dream 触发
+  │
+  ├─ Step 1: 扫描该 Agent 身份下所有 trace
+  │   找到 reflected_at_sequence < latest_sequence 的 trace
+  │
+  ├─ Step 2: Per-trace 记忆反思(逐个 trace)
+  │   对每个需要反思的 trace:
+  │   a. 加载 reflected_at_sequence 之后的消息(增量)
+  │   b. 同时加载该 trace 的 cognition_log.json(查询、评估、提取事件)
+  │   c. 用 reflect_prompt 生成反思摘要
+  │   d. 摘要作为 reflection 事件写入 cognition_log.json
+  │   e. 更新 reflected_at_sequence
+  │
+  ├─ Step 3: 跨 trace 整合
+  │   a. 收集各 trace 的 reflection 事件(cognition_log 中 type="reflection")
+  │   b. 读取当前记忆文件
+  │   c. 汇总 cognition_log 中的评估趋势(多次 harmful/unused 的 source 模式)
+  │   d. 用 dream_prompt 指导 LLM 更新记忆文件
+  │   e. 标记 reflection 事件为已消化
+  │
+  └─ 完成
+```
+
+### Per-trace 反思的输入
+
+反思 prompt 看到的不只是"发生了什么",还包括知识评估结果:
+
+```
+## 执行过程
+[该 trace 中 reflected_at_sequence 之后的消息]
+
+## 知识使用情况(来自 cognition_log.json)
+查询 1(sequence 42):"ControlNet 相关工具知识"
+  → source knowledge-a1b2: helpful — "准确定位了问题"
+  → source knowledge-c3d4: irrelevant — "与当前任务无关"
+  → source knowledge-e5f6: harmful — "建议的方法已过时"
+
+## 请反思
+1. 这次执行中有什么值得记住的经验?
+2. 哪些知识的评估结果反映了我的判断需要调整?
+3. 用户的反馈(如果有)说明了什么?
+```
+
+这样,已有的 knowledge_eval 结果直接成为记忆反思的输入,不需要重复评估。
+
+### Dream 整合的输入
+
+Dream prompt 看到的是:
+
+```
+## 最近的反思摘要
+[各 trace 的 reflect_summary,每份几百 token]
+
+## 知识评估趋势(汇总自各 trace 的 cognition_log)
+- 最近 N 个 trace 中,被评为 harmful 的 source:[列表]
+- 被评为 unused 的高频 source 类型:[统计]
+- 被评为 helpful 的查询模式:[统计]
+
+## 当前记忆文件
+[各文件内容]
+
+## 请更新记忆
+[dream_prompt 的具体指导]
+```
+
+---
+
+## 五、与现有实现的集成
+
+### 不改动的部分
+
+| 现有机制 | 保持不变 | 原因 |
+|---|---|---|
+| reflection 侧分支 | ✅ | 知识提取到 KnowHub,时效性要求高,必须在压缩前做 |
+| knowledge_eval 侧分支 | ✅ | 知识评估,时效性要求高,必须在压缩前做 |
+| force_side_branch 队列 | ✅ | 侧分支排序机制,成熟可靠 |
+| cognition_log.json | ✅ | 统一事件流存储(原 knowledge_log.json 扩展),dream 直接读取 |
+| 三个评估触发点 | ✅ | Goal 完成/压缩前/任务结束 |
+
+### 需要修改的部分
+
+**1. 任务完成时的 reflection prompt 选择**
+
+当前 `runner.py:1249` 始终使用 `get_reflect_prompt()`。应区分场景:
+
+```python
+# runner.py:1248-1249 修改
+if branch_type == "reflection":
+    if break_after_side_branch:  # 任务完成后的反思
+        prompt = config.knowledge.get_completion_reflect_prompt()
+    else:  # 压缩前的反思
+        prompt = config.knowledge.get_reflect_prompt()
+```
+
+这是一个独立的 bug fix,不依赖 Memory 系统。
+
+**2. Trace 模型扩展**
+
+`agent/trace/models.py:Trace` 新增字段:
+
+```python
+reflected_at_sequence: Optional[int] = None    # 上次记忆反思的 sequence
+# 反思摘要存在 cognition_log.json 中(type="reflection" 事件),不在 Trace 模型中
+```
+
+**3. RunConfig 扩展**
+
+`agent/core/runner.py:RunConfig` 新增可选字段:
+
+```python
+memory: Optional[MemoryConfig] = None
+```
+
+**4. KnowledgeConfig 扩展**
+
+`agent/core/runner.py:KnowledgeConfig`(或对应类)新增字段:
+
+```python
+reflect_auto_commit: bool = False
+# False(默认,所有 Agent): reflection 只写 cognition_log: type="extraction_pending"
+#                          人工通过 CLI review + commit 才进 KnowHub
+# True(手动开启)         : reflection 直接 upload_knowledge,保留旧的"提取即上传"行为
+```
+
+**5. Reflection 侧分支行为变更**
+
+当前 reflection 的 prompt 直接指导 LLM 调用 `upload_knowledge`。需要改为:
+- `reflect_auto_commit=False` 时:prompt 指导 LLM 调用新的 `record_pending_extraction` 工具(仅写 cognition_log)
+- `reflect_auto_commit=True` 时:保持当前行为
+
+或者更简洁的实现:reflection 始终调用 `record_pending_extraction`,由侧分支结束后的 hook 根据 `reflect_auto_commit` 决定是否立即调用 `commit_approved`(视为全部 approved)。这避免了 prompt 分叉。
+
+### 新增的部分
+
+**1. MemoryConfig**
+
+```python
+@dataclass
+class MemoryConfig:
+    """持久化记忆配置"""
+
+    base_path: str = ""                          # 记忆文件目录
+    files: Optional[Dict[str, str]] = None       # {路径: 用途说明}
+    # key 是相对 base_path 的路径,支持嵌套(如 "core/identity.md")或 glob
+    # (如 "relationships/*.md")。框架只负责按 key 读文件内容注入上下文,
+    # 组织结构由配置者决定。
+    dream_prompt: str = ""                       # Dream 整合 prompt(空用默认)
+    reflect_prompt: str = ""                     # Per-trace 反思 prompt(空用默认)
+```
+
+**2. Run 启动时记忆加载**
+
+Memory-bearing Agent 的 run 启动时,框架按 `files` 的 key 依次解析(直接路径或 glob 匹配),读取命中的文件内容以字符串形式注入上下文。Agent 可用 write_file 新增文件;只要新文件的路径匹配某条 key(直接路径或 glob),下次 run 启动时自动加载。
+
+**3. Dream 操作**
+
+以 `dream` 工具形式提供,Agent 可主动调用:
+
+```python
+@tool
+async def dream() -> ToolResult:
+    """整理长期记忆。回顾最近的执行历史,更新记忆文件。"""
+    # 1. 扫描需要反思的 trace
+    # 2. 逐个 per-trace 反思
+    # 3. 跨 trace 整合,更新记忆文件
+```
+
+也可以作为 `AgentRunner` 的方法暴露,供外部调度直接调用。
+
+**4. 提取审核 CLI 流程**
+
+为支持"提取-审核-提交"两阶段(见第三节),新增 `agent/cli/extraction_review.py` 模块。**不是 Agent 工具**(Agent 不应自我审核),是 CLI 内部模块 + 独立可执行脚本:
+
+```python
+# agent/cli/extraction_review.py
+
+async def list_pending(trace_id: str) -> list[PendingExtraction]:
+    """读 cognition_log,返回 type=extraction_pending 且未 reviewed 的条目"""
+
+async def review_one(
+    trace_id: str,
+    extraction_id: str,
+    decision: Literal["approve", "edit", "discard"],
+    edited_content: Optional[str] = None,
+) -> None:
+    """写 reviewed 事件到 cognition_log"""
+
+async def commit_approved(trace_id: str) -> CommitReport:
+    """批量上传 approved 条目到 KnowHub,写 committed 事件"""
+```
+
+可独立调用:
+
+```bash
+python -m agent.cli.extraction_review --trace XXX --list
+python -m agent.cli.extraction_review --trace XXX --commit
+```
+
+**集成到现有交互式 CLI**(`agent/cli/interactive.py:174` 的菜单)扩展两项:
+
+```
+  1. 插入干预消息并继续
+  2. 触发经验总结(reflect)         ← 现有
+  ...
+  8. 审核待提交知识(review)        ← 新增
+  9. 提交已审核知识到 KnowHub        ← 新增
+```
+
+`8` 进入交互式 review 循环:
+
+```
+[1/3] tool 经验
+─────────────────────
+nanobanana 工具的 strength 参数 < 0.3 时会丢失原图轮廓...
+─────────────────────
+[a]pprove / [e]dit / [d]iscard / [s]kip / [q]uit:
+```
+
+`9` 显示 approved 列表 + 用户最终确认 → 调 `commit_approved`,输出 commit 报告(成功/失败条数、KnowHub 返回的 ID)。
+
+**实现注意**:
+- 现有 `perform_reflection`(`interactive.py:269`)走 HTTP API(`/api/traces/{trace_id}/reflect`)。新流程同样应该走 API 端点(如 `POST /api/traces/{trace_id}/extractions/{id}/review`、`POST /api/traces/{trace_id}/extractions/commit`),让未来 Web UI 能复用同一套审核流,而不是 CLI 直接读写 cognition_log 文件。
+- "edit" 分支允许用户直接修改 LLM 生成的 markdown 内容;初版只支持改正文文本,后续可扩展到改类型/metadata。
+
+---
+
+## 六、完整的元思考数据流
+
+```
+Agent 执行任务(Trace)
+  │
+  ├─ 知识查询(ask)→ cognition_log: type="query"(含整合回答 + source_ids)
+  │
+  ├─ Goal 完成 → 触发 knowledge_eval 侧分支 → cognition_log: type="evaluation"
+  │
+  ├─ 压缩触发 →
+  │   队列: [reflection, knowledge_eval, compression]
+  │   reflection: 提取客观知识 → cognition_log: type="extraction_pending"
+  │                            (默认不直接 upload,等人工 review)
+  │   knowledge_eval: 评估各 source → cognition_log: type="evaluation"
+  │   compression: 压缩上下文
+  │
+  ├─ 任务完成 →
+  │   knowledge_eval(如有 pending)→ cognition_log: type="evaluation"
+  │   reflection → cognition_log: type="extraction_pending"
+  │
+  ├─ 人工审核(CLI 触发,可发生在任意时刻)→
+  │   逐条 approve/edit/discard → cognition_log: type="extraction_reviewed"
+  │   批量 commit → upload_knowledge → KnowHub
+  │                + cognition_log: type="extraction_committed"
+  │
+  └─ Trace 状态更新(新消息使 reflected_at_sequence 落后)
+
+         ···时间流逝,可能有多个 trace···
+
+Dream 触发(Agent 主动调用 / 外部调度)
+  │
+  ├─ Per-trace 记忆反思
+  │   输入: 未反思的消息 + cognition_log 中的 query/evaluation/extraction 事件
+  │   输出: cognition_log: type="reflection"
+  │
+  ├─ 跨 trace 整合
+  │   输入: 各 trace 的 reflection 事件 + evaluation 趋势 + 当前记忆文件
+  │   输出: 更新后的记忆文件(taste.md, strategy.md, ...)
+  │
+  └─ 记忆文件被下次 run 加载 → 影响 Agent 行为 → 新的 Trace → ...
+```
+
+### 三种元思考的时间线
+
+```
+Trace 执行中:
+  ──[Goal完成]──knowledge_eval──[压缩]──reflection→knowledge_eval→compression──
+
+Trace 结束后:
+  ──knowledge_eval──reflection(completion)──
+
+之后某个时刻:
+  ──dream──per-trace记忆反思──跨trace整合──更新记忆文件──
+```
+
+即时的元思考(knowledge_eval、reflection)保护信息不被压缩丢失。
+延迟的元思考(dream)在全局视角下更新个人记忆。两者互补。
+
+---
+
+## 七、记忆模型全景
+
+Memory 和 Knowledge 是**两条平行的线**,而不是抽象层级。区分维度是"主观 vs 客观"和"私有 vs 共享"。Memory 不会"升级"成 Knowledge,反过来也不会。
+
+```
+                    ┌─────────────────────────────┐
+                    │ Trace(任务状态 / 工作记忆)  │
+                    │ Messages + Goals             │
+                    │ + cognition_log              │
+                    └──────────┬──────────────────┘
+                               │
+              dream 反思       │      reflection 提取(→ pending)
+              (延迟、可选)    │      knowledge_eval 评估
+                               │      (即时、必做)
+                  ┌────────────┴───────────┐
+                  ▼                        ▼
+        ┌──────────────────┐     ┌──────────────────────┐
+        │ Memory           │     │ Knowledge            │
+        │ Agent 身份私有    │     │ KnowHub 全局共享      │
+        ├──────────────────┤     ├──────────────────────┤
+        │ 主观 / 偏好 / 策略 │     │ 客观 / 工具 / 调研    │
+        │ Markdown 文件     │     │ DB + 向量索引         │
+        │ 人类可直接编辑     │     │ 经 review 才入库      │
+        │ 来源: dream       │     │ 来源: reflection      │
+        └────────┬─────────┘     └──────────┬───────────┘
+                 │                          │
+                 └────注入下次 run──────────┘
+```
+
+**两条线的交互**(不是层级关系,是同源 + 互相参考):
+
+- 都源自同一个 Trace(cognition_log 是共同的事件流)
+- dream 在生成记忆摘要时可以参考 cognition_log 中的 evaluation 趋势(来自 Knowledge 这条线)
+- reflection 也可以参考 Memory 来判断"这条经验我已经记过了"
+- 但二者的**读者不同**:Memory 只服务于同一身份的未来 run;Knowledge 服务于所有 Agent
+
+---
+
+## 八、两类 Agent
+
+| | 默认 Agent | Memory-bearing Agent |
+|---|---|---|
+| 知识提取(reflection) | ✅ 配置 KnowledgeConfig | ✅ 配置 KnowledgeConfig |
+| 知识评估(knowledge_eval) | ✅ 自动 | ✅ 自动 |
+| 个人记忆 | ❌ | ✅ 配置 MemoryConfig |
+| Dream | ❌ | ✅ 可调用 dream 工具 |
+| Run 启动加载记忆 | ❌ | ✅ 自动注入 |
+
+Memory 是 opt-in 的增量能力。但**知识提取的提交行为变了**:默认 Agent 也不再自动 upload 到 KnowHub,必须通过 CLI 人工 review + commit;如需保留旧的"提取即上传"行为,手动设置 `KnowledgeConfig.reflect_auto_commit=True`。
+
+---
+
+## 九、开放与已决问题
+
+`[DECIDED]` 已有落地结论;`[OPEN]` 尚未决定,等真实运行数据再定。
+
+1. `[DECIDED]` **记忆注入方式** → system prompt 末尾追加。见 `runner.py:_build_system_prompt` 里调 `format_memory_injection`。代价:若记忆文件很大,每轮 LLM 调用都带 —— 暂时接受,等实际观察到膨胀再换方案。
+2. `[OPEN]` **并发写冲突**:多个 Agent run 同时写同一个记忆文件怎么办?文件锁?还是 dream 统一写、其他 run 只读?当前没做并发保护,假设 dream 是单一写入方。
+3. `[OPEN]` **记忆膨胀**:记忆文件越来越长怎么办?`DEFAULT_DREAM_PROMPT` 已写"在原有基础上演进不要重写",但是否真能控制住要看实际使用。
+4. `[OPEN]` **Per-trace 反思的成本控制**:很短的 trace 不值得反思。当前 `per_trace_reflect` 无下限阈值,所有 `reflected_at_sequence < last_sequence` 的 trace 都会反思。
+5. `[OPEN]` **Knowledge eval 结果回传 KnowHub**:仍然只存本地 cognition_log。
+6. `[DECIDED]` **Dream 中评估趋势的呈现方式** → LLM 直接读 cognition_log 原始事件。见 `dream.py:_build_reflect_input`,把 query / evaluation / extraction_pending / extraction_committed 事件摘要化后一并塞给 LLM,不做预计算统计。
+7. `[DECIDED]` **Dream 操作的实现形式** → 两者都提供。Agent 主动调用走 `dream` 工具(`agent/tools/builtin/memory.py`,`memory` 组),外部调度走 `AgentRunner.dream()` 方法。
+8. `[OPEN]` **未 review 的 pending 提取何时清理**:目前没有 TTL,pending 无限期累积。等观察积压速度再定(例如 30 天未 review 自动 discard / 归档)。
+9. `[OPEN]` **review 的"edit"分支允许多深**:初版只支持改 markdown 字段(task/content/score/tags)。改 type 或 metadata 目前需 discard 重写。
+10. `[OPEN]` **批量 review 的辅助能力**:当前逐条看。未做批量 approve / 相似条目去重 / LLM 预筛。
+11. `[OPEN]` **Dream 的 JSON 解析脆弱性**:`cross_trace_integrate` 依赖 LLM 严格输出 `{updates:[...]}` JSON(见 `dream.py:_parse_dream_output`)。真实 LLM 可能偶尔加前言、用不同键名。首次线上运行需监控 parse 失败率,必要时加重试 + 更严格 prompt。
+
+---
+
+## 十、实现与入口
+
+2026-04 落地清单,供看代码时快速定位。
+
+### 10.1 数据层
+
+| 改动 | 位置 |
+|---|---|
+| Trace 新字段 `reflected_at_sequence` | `agent/trace/models.py:Trace` |
+| cognition_log 事件 schema(含新增的 extraction_pending/reviewed/committed + reflection) | `agent/trace/store.py:append_cognition_event` docstring |
+
+### 10.2 提取-审核-提交两阶段
+
+| 职责 | 位置 |
+|---|---|
+| LLM 暂存用工具(core 组默认可见) | `agent/tools/builtin/knowledge.py:knowledge_save_pending` |
+| 反思 prompts(已改为调 `knowledge_save_pending`) | `agent/core/prompts/knowledge.py` |
+| Auto-commit 开关(默认 False) | `KnowledgeConfig.reflect_auto_commit` |
+| 反思侧分支退出时的 auto-commit hook | `agent/core/runner.py` 反射分支退出分支内 |
+| 核心逻辑(list_pending / review_one / commit_approved / auto_commit_branch) | `agent/trace/extraction_review.py` |
+| **独立 CLI 入口** | `python -m agent.cli.extraction_review --trace <ID> [--list/--list-all/--review/--commit]` |
+| **交互式菜单入口** | `agent/cli/interactive.py` 菜单项 8(review)/ 9(commit) |
+| **HTTP API 入口** | `GET /api/traces/{tid}/extractions`、`POST .../extractions/{eid}/review`、`POST .../extractions/commit`(见 `agent/trace/run_api.py`) |
+
+三种入口共享同一个核心模块 `agent/trace/extraction_review.py`。
+
+### 10.3 Memory + Dream
+
+| 职责 | 位置 |
+|---|---|
+| MemoryConfig 定义 | `agent/core/memory.py:MemoryConfig` |
+| 记忆文件加载(支持 glob + 去重) | `agent/core/memory.py:load_memory_files` |
+| 记忆注入格式 | `agent/core/memory.py:format_memory_injection` |
+| 注入到 system prompt | `agent/core/runner.py:_build_system_prompt`(memory 段落在 skills 段之后) |
+| Dream per-trace 反思 | `agent/core/dream.py:per_trace_reflect` |
+| Dream 跨 trace 整合 | `agent/core/dream.py:cross_trace_integrate` |
+| Dream 顶层入口 | `agent/core/dream.py:run_dream` |
+| **Agent 工具入口(memory 组)** | `agent/tools/builtin/memory.py:dream` |
+| **外部调度入口** | `AgentRunner.dream(memory_config, trace_filter=..., reflect_model=..., dream_model=...)` |
+| 默认 prompts | `dream.py:DEFAULT_REFLECT_PROMPT` / `DEFAULT_DREAM_PROMPT`(可通过 `MemoryConfig.reflect_prompt`/`dream_prompt` 覆盖) |
+
+### 10.4 启用方式
+
+默认 Agent 不启用 memory,但**提取审核仍然生效**(pending 不自动上传 KnowHub)。
+
+要让某个 example 直接上传(恢复旧行为):
+```python
+RunConfig(knowledge=KnowledgeConfig(reflect_auto_commit=True))
+```
+
+要让一个 Agent 变成 memory-bearing:
+```python
+from agent.core.memory import MemoryConfig
+
+RunConfig(
+    memory=MemoryConfig(
+        base_path="/path/to/agent_memory",
+        files={
+            "taste.md": "品味偏好",
+            "strategy.md": "当前策略",
+            "journals/*.md": "执行日记",
+        },
+    ),
+    tool_groups=["core", "memory"],   # memory 组暴露 dream 工具
+)
+```
+
+然后周期性(或 Agent 主动调用 `dream` 工具)触发:
+```python
+await runner.dream(memory_config=rc.memory)
+```
+
+### 10.5 已知 rough edges
+
+- 实施过程发现旧的 `upload_knowledge` 引用是悬空的(仓内无实现),未清理 `examples/*/prompt` 里的残留引用
+- Dream 两次 LLM 调用(reflect + integrate)默认模型写死 `gpt-4o-mini` / `gpt-4o`,未接入 RunConfig 的 utility_llm_call
+- `trace_filter` 没提供按 `agent_type` / `owner` 过滤的便捷函数,调用方传 lambda

+ 126 - 0
agent/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
+
+**未来扩展**:
+- 音频、视频等其他模态
+- 资源缓存和异步加载

+ 189 - 0
agent/agent/docs/prompt-guidelines.md

@@ -0,0 +1,189 @@
+# Prompt 撰写规范
+
+## 核心原则
+
+Prompt 是写给 LLM 的行为指令,不是写给人看的系统文档。判断标准:**这句话删掉后,LLM 的行为会变差吗?** 不会就删。
+
+---
+
+## 一、角色定位
+
+### 描述能力,不要给标签
+
+LLM 不需要知道自己"叫什么",需要知道**怎么行动**。好的角色定位激活训练数据中的行为模式。
+
+| 差 | 好 | 原因 |
+|---|---|---|
+| 你是 Librarian Agent | 你是一个知识库管理员,擅长从碎片信息中识别关联、去重和结构化整理 | 标签不激活行为;能力描述激活 |
+| 你是后端服务 X | (删掉) | 架构角色对行为无帮助 |
+
+### 角色三要素
+
+1. **我擅长什么?** — 激活能力("擅长从碎片信息中提炼结构")
+2. **我的核心任务是什么?** — 聚焦目标("确保新信息与已有知识正确关联")
+3. **我的行为边界是什么?** — 防止漂移("只整理和检索,不自行创造内容")
+
+---
+
+## 二、实现细节:不写
+
+LLM 不需要知道自己通过什么协议被调用、运行在什么框架上、调用方是谁、自己的代号。这些只消耗 token。
+
+唯一例外:实现细节**影响行为**时。比如"你的回复会被程序解析为 JSON"——这不是架构说明,是输出约束。
+
+---
+
+## 三、信息分层
+
+### 静态 vs 动态
+
+把**不变的基础指令**和**随上下文变化的信息**分开。不变的放 system prompt 缓存;变化的按需注入。
+
+参考 Claude Code 的做法:~25 个静态段落 + 若干条件段落(通过 feature flag 控制)。用户不需要某能力时,相关段落不注入。
+
+### 三层结构
+
+1. **角色和目标** — 做什么、怎么做的总纲
+2. **知识** — 完成任务所需的背景信息(schema、字段含义、关联关系)
+3. **操作策略** — 遇到 X 做 Y 的判断标准和输出格式
+
+**知识和策略必须分开。** 混在一起会导致 LLM 在需要行动时回顾 schema、在理解数据时翻操作步骤。
+
+### 按职责域分组,不按任务顺序
+
+把相关信息放在一起(所有检索工具在一节、所有写入工具在一节),不要按"先检索再判断再写入"的执行顺序排列。LLM 需要的是参考手册,不是 step-by-step 教程。
+
+---
+
+## 四、约束表达
+
+### 分层约束
+
+从绝对到灵活,分三层:
+
+1. **硬边界** — 绝对不可逾越:"不要直接入库,只写草稿"
+2. **条件规则** — 特定情况下的约束:"新建 Capability 需同时满足三个条件"
+3. **启发式** — 偏好和倾向:"优先复用已有实体"
+
+### 正面描述优于负面清单
+
+"不要做X,不要做Y,不要做Z"太多时适得其反。改为正面描述期望行为。但**硬边界**用负面表述更安全("绝对不要...")。
+
+### 预防合理化漂移
+
+对关键约束,预判 LLM 会找什么借口绕过,然后提前堵住:
+
+```
+# 差:
+不要跳过去重检查。
+
+# 好:
+收到新工具时必须先 tool_search 检查。不要因为"看起来是新的"就跳过检索——
+名称不同但功能相同的工具很常见。
+```
+
+参考 Claude Code 的 verification agent:直接列出 LLM 会用的借口("代码看起来是对的""测试已经通过了"),然后逐一反驳。
+
+---
+
+## 五、操作策略
+
+### 写判断标准,不写流程
+
+LLM 擅长在判断标准下自行组织行动。给决策规则,不给流程图:
+
+```
+# 差:
+步骤1:检索。步骤2:判断。步骤3:写入。
+
+# 好:
+收到新工具时:已有→复用ID;全新→放入草稿。
+新建 Capability 的条件:①有已验证工具 ②有真实用例 ③描述具体可操作
+```
+
+### 用例子代替抽象规则
+
+一个好例子胜过三段解释:
+
+```
+# 差:
+能力描述应该具体可操作,不要过于宽泛。
+
+# 好:
+能力描述要具体:不要写"图像处理",要写"使用 ControlNet 进行人物姿态控制"。
+```
+
+### 提供逃生通道
+
+告诉 LLM "不能做X"时,同时告诉它"改做Y"。否则 LLM 会自己发明绕过方案:
+
+```
+# 差:
+不能直接入库。
+
+# 好:
+不能直接入库。写入 .cache/.knowledge/pre_upload_list.json 草稿池,等待人工确认后入库。
+```
+
+---
+
+## 六、输出格式
+
+### 只在必要时约束格式
+
+程序解析的输出(JSON)→ 严格约束格式。人看的输出 → 不要过度约束,让 LLM 自然组织。
+
+### 格式模板放在策略末尾
+
+不要开头就给模板。LLM 会过早锁定结构而忽略内容质量。先描述任务和判断标准,最后给格式。
+
+### 结构化输出模板
+
+对复杂输出,提供带占位符的模板。这防止 LLM 臆造输出、方便程序解析、强制 LLM 真正执行而非编造结果:
+
+```
+### Check: [what you're verifying]
+**Command:** [exact command]
+**Output:** [actual output]
+**Result: PASS / FAIL**
+```
+
+---
+
+## 七、Token 效率
+
+### 选择性详略
+
+- **详写**:歧义代价高的地方(安全约束、关键判断标准)
+- **略写**:意图明确的地方(工具简介、通用行为)
+- **用表格**:多个相似项有多个属性时,表格比段落高效得多
+
+### 条件注入
+
+不是所有 Agent 都需要所有指令。用条件段落(类似 feature flag)控制注入,保持基础 prompt 精练:
+
+```
+# 只在需要入库能力时注入
+commit_to_database 工具:将草稿提交入库...
+```
+
+### 上下文窗口意识
+
+明确告诉 LLM 上下文会被管理(压缩、截断)。让它主动保存关键信息:
+
+> 工具调用的结果可能在后续被清理。如果结果中有你后面需要用到的信息,在回复中记录下来。
+
+---
+
+## 八、常见反模式
+
+| 反模式 | 问题 | 修正 |
+|---|---|---|
+| "你是 XX Agent" | 标签无行为激活效果 | 描述具体能力和行为特征 |
+| 实现细节(协议、框架、架构) | 噪音 | 删掉,除非影响行为 |
+| "请注意""重要""务必"满天飞 | 反复强调降低所有内容的权重 | 只强调一两个真正关键的点 |
+| 详尽 step-by-step 流程 | 机械跟随而非灵活判断 | 给判断标准和决策规则 |
+| 长负面清单 | 适得其反 | 正面描述期望行为 |
+| 所有信息塞 system prompt | 注意力稀释 | 分层:始终需要的(system)+ 按需加载的(skill) |
+| 开头就给输出模板 | 过早锁定结构 | 先给任务和标准,最后给格式 |
+| "不能做X"但不给替代方案 | LLM 自行发明绕过方式 | 同时说明"改做Y" |

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

@@ -0,0 +1,329 @@
+# 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/tools/builtin/knowledge.py`:知识管理工具(KnowHub API 封装)+ KnowledgeConfig
+
+## 扩展性
+
+### 新增 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/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/skill/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/skill/skill_loader.py`

+ 475 - 0
agent/agent/docs/tools-refactor-plan.md

@@ -0,0 +1,475 @@
+# 工具体系改造方案(Refactor Plan)
+
+> ✅ **方案一(内容工具族)** 和 **方案二(浏览器工具族)** 已于 2026-04-12 完成落地。
+> 下方保留原始方案文档供参考。沙箱工具已于此前删除。
+>
+> 当前工具体系的状态请看 [`tools.md`](./tools.md)。
+
+## 背景
+
+本框架的 `@tool` 注册体系经过一段时间的积累后,暴露了几个结构性问题:
+
+1. **工具粒度和组织方式是按"后端架构"而不是"任务语义"划分的**。典型表现:`search_posts`(聚合 9 个中文平台)和 `x_search`(独立)本质上是同一类任务,却因为后端一个统一 endpoint、一个独立 endpoint 就被分成了两个工具
+2. **浏览器工具有 28 个 @tool,LLM 选择负担严重超标**
+3. **沙箱工具已经不再需要**(原本是给运行工具准备的,但工具已经被提取出来单独处理)
+4. **同一套哲学没有贯彻到所有工具族**——toolhub 已经用了"动态发现"模式(search → call),但其他多后端的工具族还是"每个后端一个工具"
+
+本方案解决前两个问题(沙箱直接删除,不需要方案),确立一套**统一的工具设计哲学**供未来所有新工具族参考。
+
+---
+
+## 核心设计哲学:按任务语义划分 + 按规模选择模式
+
+### 哲学 1:LLM 心智负担分四类
+
+- **选择负担**:一堆工具中挑一个
+- **参数构造负担**:知道该工具要哪些参数
+- **流程负担**:需要按什么顺序调几次工具
+- **错误恢复负担**:失败时怎么修复
+
+工具设计要**平衡**这四类负担,而不是只优化其中一类。
+
+### 哲学 2:按"任务语义"而非"后端架构"划分工具
+
+工具的边界应该跟 LLM 的心智模型对齐,而不是跟后端服务的架构对齐。
+
+**反例(现状)**:LLM 看到 `search_posts` / `youtube_search` / `x_search` 三个并列工具,需要记住"中文平台用前者,YouTube 用中者,X 用后者"——这是后端知识泄露到工具层。
+
+**正确姿势**:LLM 看到一个统一的 `content_search(platform, keyword, ...)`,后端路由对 LLM 不可见。
+
+### 哲学 3:按"工具族规模"选择静态或动态模式
+
+| 场景 | 模式 | 代表 |
+|---|---|---|
+| 单一职责工具(正交能力) | 静态扁平 | `read_file` / `bash_command` |
+| 小规模异构工具族(3-10 个) | 静态扁平 + 良好命名 | `knowledge_*` / `sandbox_*` |
+| 中等规模异构工具族(10-20 个) | **语义合并**(Literal 枚举动词) | 浏览器工具 |
+| 大规模多实例工具族(20+ 个同类异质) | **动态发现**(toolhub 模式) | 内容搜索、远程工具库 |
+
+判断标准:**工具之间的差异主要在"参数"还是"能力"?**
+- 差异在参数(navigate/click/type 都是"DOM 操作",只是参数不同)→ 静态合并,用 Literal 动词
+- 差异在能力(9 个平台各有各的搜索语义和专用参数)→ 动态发现
+
+---
+
+## 方案一:内容工具族 → 动态发现模式
+
+### 现状
+
+| 工具 | 后端 | 平台 |
+|---|---|---|
+| `search_posts(keyword, channel, ...)` | `aigc-channel.aiddit.com/data` | 9 个中文平台 |
+| `select_post(index)` | 内存缓存 | 同上 |
+| `get_search_suggestions(keyword, channel)` | `aigc-channel.aiddit.com/suggest` | 同上 |
+| `youtube_search(keyword)` | `crawler.aiddit.com/youtube/keyword` | YouTube |
+| `youtube_detail(content_id, ...)` | `crawler.aiddit.com/youtube/detail` + yt-dlp | YouTube |
+| `x_search(keyword)` | `crawler.aiddit.com/x/keyword` | X |
+| `import_content(plan, data)` | `aigc-channel.aiddit.com/weixin/auto_insert` | 长文导入(非搜索) |
+| `extract_video_clip(...)` | 本地 ffmpeg | 媒体处理(非搜索) |
+
+### 新方案
+
+**3 个统一入口 + N 个内部实现函数(非 @tool)**
+
+```python
+@tool()
+async def content_platforms() -> ToolResult:
+    """列出所有支持的内容平台及其搜索参数 schema。
+
+    建议在 session 开始时调一次,后续 content_search / content_detail 调用时
+    依据此返回构造参数。返回内容在 session 内可以缓存。
+    """
+    return ToolResult(output=json.dumps({
+        "xhs": {
+            "name": "小红书",
+            "backend": "aigc-channel",
+            "search_params": {
+                "sort_type": {
+                    "values": ["综合排序", "最新发布", "最多点赞"],
+                    "default": "综合排序"
+                },
+                "publish_time": {
+                    "values": ["不限", "近1天", "近7天", "近30天"],
+                    "default": "不限"
+                },
+                "content_type": {
+                    "values": ["不限", "图文", "视频", "文章"],
+                    "default": "不限"
+                },
+                "filter_note_range": {
+                    "values": ["不限", "1分钟以内", "1-5分钟", "5分钟以上"],
+                    "default": "不限",
+                    "note": "仅视频内容生效"
+                }
+            },
+            "detail_mode": "cache_index",
+            "extras_example": {
+                "sort_type": "最新发布",
+                "publish_time": "近7天"
+            }
+        },
+        "youtube": {
+            "name": "YouTube",
+            "backend": "crawler",
+            "search_params": {},
+            "detail_mode": "content_id",
+            "detail_extras": {
+                "include_captions": {"type": "bool", "default": True},
+                "download_video": {"type": "bool", "default": False}
+            },
+            "extras_example": {}
+        },
+        "x": {
+            "name": "X (Twitter)",
+            "backend": "crawler",
+            "search_params": {},
+            "detail_mode": "not_supported",
+            "extras_example": {}
+        },
+        # ... 9 个中文平台 + YouTube + X,总共 11 个
+    }))
+
+
+@tool()
+async def content_search(
+    platform: str,
+    keyword: str,
+    max_count: int = 20,
+    extras: Optional[Dict[str, Any]] = None,
+) -> ToolResult:
+    """跨平台内容搜索,返回带索引编号的封面拼图 + 结构化列表。
+
+    参数说明:
+        platform: 平台标识,如 'xhs'、'youtube'、'x'。完整列表见 content_platforms。
+        keyword: 搜索关键词。
+        max_count: 返回条数上限,默认 20。
+        extras: 平台专用参数。如果不清楚某平台支持什么,先调用 content_platforms
+                查看 search_params 字段。xhs 支持 sort_type / publish_time /
+                content_type / filter_note_range;YouTube / X 当前无额外参数。
+
+    返回:
+        ToolResult.images 含 1 张带索引编号的封面拼图;output 含列表和每条记录的
+        元数据。拼图遵循 read_images 的自适应布局规则(最多 12 张)。
+    """
+
+
+@tool()
+async def content_detail(
+    platform: str,
+    identifier: str,
+    extras: Optional[Dict[str, Any]] = None,
+) -> ToolResult:
+    """查看内容详情。identifier 的含义因平台而异(见 content_platforms 的 detail_mode)。
+
+    - xhs / gzh / douyin / ...: identifier 是 content_search 返回的索引(1-based),
+      从 session 级缓存取完整记录
+    - youtube: identifier 是 video_id;extras 可传 include_captions / download_video
+    - x: 当前不支持详情查看
+    """
+```
+
+### 内部实现(不注册给 LLM)
+
+```
+agent/tools/builtin/content/
+├── __init__.py           # 空
+├── tools.py              # 3 个 @tool 入口
+├── registry.py           # PLATFORM_IMPLS 路由表
+└── platforms/
+    ├── aigc_channel.py   # 9 个中文平台的 search / detail / suggest 实现
+    ├── youtube.py        # youtube_search / youtube_detail 纯函数
+    └── x.py              # x_search 纯函数
+```
+
+### 迁移步骤
+
+1. 新建 `agent/tools/builtin/content/` 目录结构
+2. 把 `search.py` 的 `search_posts` / `select_post` / `get_search_suggestions` 逻辑移到 `content/platforms/aigc_channel.py`,拆成按 channel 分的纯函数
+3. 把 `crawler.py` 的 `youtube_search` / `youtube_detail` / `x_search` 移到 `content/platforms/`
+4. 在 `content/tools.py` 写 3 个 @tool 入口,内部调用路由
+5. 从 `builtin/__init__.py` 删除旧的 `search_posts` / `youtube_search` / `x_search` 等导出
+6. 添加新的 `content_platforms` / `content_search` / `content_detail` 导出
+7. 更新现有 prompt 里对旧工具的引用(**破坏性改动**)
+8. `extract_video_clip` 和 `import_content` 不搬——它们不是搜索工具,保留在原位或移到 `content/media.py` / `content/ingestion.py`
+
+### 未决策的设计问题
+
+1. **`extras` 的 schema 怎么处理?**
+   - 方案 i:声明为 `Optional[Dict[str, Any]]`,LLM 从 `content_platforms()` 返回的 schema 文本里学参数——**推荐**
+   - 方案 ii:为每个平台单独生成 schema(discriminated union),本框架 `SchemaGenerator` 当前不支持
+   - 方案 iii:把常用的平台专用参数都显式列在 `content_search` 签名里,用 Optional——签名臃肿,不如方案 i
+
+2. **缓存 `_search_cache` 的生命周期**
+   - 现状:进程内字典,进程重启就丢
+   - 问题:CLI 模式下每次进程都是新的,`content_detail(platform="xhs", identifier=3)` 拿不到缓存
+   - 方案:用磁盘持久化缓存 `/tmp/content_cache_{trace_id}.json`,配合之前给 toolhub/librarian 做的 trace_id 三级回退机制,同 session 内 CLI 调用也能复用
+
+3. **拼图的图片数量是否和 `read_images` 一致(12 张上限)?**
+   - read_images 是"让 LLM 看清",12 是硬上限
+   - 内容搜索是"让 LLM 浏览",可能需要更多(20-30 条也常见)
+   - 建议:区分"详查模式"(layout=detail,≤12)和"概览模式"(layout=overview,≤25,每格更小)
+
+4. **X 的 `content_detail` 怎么处理?**
+   - 当前 `x_search` 没有配对的 detail 工具
+   - 要么 `content_platforms` 里标明 `detail_mode: "not_supported"`,要么后端补一个 X 详情接口
+
+### 新增后的用户流程
+
+```
+Step 0(session 开始时一次): content_platforms() → 所有平台 schemas
+Step 1(每次任务):          content_search(platform, keyword, extras)
+Step 2(需要细看时):        content_detail(platform, id, extras)
+```
+
+---
+
+## 方案二:浏览器工具族 → 语义合并
+
+### 现状
+
+28 个 `@tool`(在 `agent/tools/builtin/browser/baseClass.py`),按任务语义分组:
+
+| 类别 | 数量 | 工具 |
+|---|---|---|
+| 导航 | 4 | `browser_navigate_to_url`、`browser_search_web`、`browser_go_back`、`browser_get_live_url` |
+| 等待 / 人机协同 | 2 | `browser_wait`、`browser_wait_for_user_action` |
+| 元素交互 | 6 | `browser_click_element`、`browser_input_text`、`browser_send_keys`、`browser_upload_file`、`browser_get_dropdown_options`、`browser_select_dropdown_option` |
+| 视口 / 查找 | 2 | `browser_scroll_page`、`browser_find_text` |
+| 内容读取 | 6 | `browser_screenshot`、`browser_get_visual_selector_map`、`browser_get_selector_map`、`browser_get_page_html`、`browser_read_long_content`、`browser_extract_content` |
+| 标签页管理 | 2 | `browser_switch_tab`、`browser_close_tab` |
+| Cookie / 登录 | 3 | `browser_ensure_login_with_cookies`、`browser_export_cookies`、`browser_load_cookies` |
+| 其他 | 3 | `browser_download_direct_url`、`browser_evaluate`、`browser_done` |
+
+### 与 browser-use MCP 的重叠分析
+
+约一半工具和 browser-use 原生 MCP 提供的能力重叠:
+
+| 你们的 @tool | browser-use MCP | 状态 |
+|---|---|---|
+| `browser_navigate_to_url` | `browser_navigate` | 重复 |
+| `browser_click_element` | `browser_click` | 重复 |
+| `browser_input_text` | `browser_type` | 重复 |
+| `browser_scroll_page` | `browser_scroll` | 重复 |
+| `browser_go_back` | `browser_go_back` | 重复 |
+| `browser_switch_tab` / `close_tab` | 同名 | 重复 |
+| `browser_extract_content` | 同名 | 重复 |
+| `browser_get_selector_map` | `browser_get_state` | 部分重复 |
+
+剩余 14 个是自研扩展(cookie 全家桶、upload、send_keys、dropdown、visual_selector_map、read_long_content、find_text、evaluate、download、done、wait_for_user_action、get_live_url、search_web、get_page_html)。
+
+### 新方案:语义合并(方案 A)
+
+采用"按动词合并 + Literal 枚举 action"模式,从 28 个 @tool 压缩到约 11 个。**不引入 MCP Client 基础设施**(那是未来的独立决策)。
+
+**目标签名:**
+
+```python
+@tool()
+async def browser_navigate(
+    target: str,
+    mode: Literal["url", "search", "back"] = "url",
+    engine: str = "bing",
+    new_tab: bool = False,
+) -> ToolResult:
+    """导航工具。
+    - mode="url": target 为 URL,直接访问
+    - mode="search": target 为搜索词,通过 engine 搜索
+    - mode="back": 浏览器后退,target 和 engine 忽略
+    """
+
+
+@tool()
+async def browser_interact(
+    action: Literal["click", "type", "send_keys", "upload", "dropdown_list", "dropdown_select"],
+    index: Optional[int] = None,
+    text: Optional[str] = None,
+    path: Optional[str] = None,
+    keys: Optional[str] = None,
+    clear: bool = True,
+) -> ToolResult:
+    """元素交互:
+    - click: 需要 index
+    - type: 需要 index + text
+    - send_keys: 需要 keys(如 'Enter'、'Ctrl+A'),不依赖 index
+    - upload: 需要 index + path
+    - dropdown_list: 需要 index(列出选项)
+    - dropdown_select: 需要 index + text(按文字选中)
+    """
+
+
+@tool()
+async def browser_screenshot(highlight_elements: bool = False) -> ToolResult:
+    """截图。
+    - highlight_elements=False: 纯截图
+    - highlight_elements=True: 带交互元素编号的标注截图(原 visual_selector_map)
+    """
+
+
+@tool()
+async def browser_elements() -> ToolResult:
+    """获取当前页面的可交互元素列表(文本版,不截图)。用于 LLM 按 index 与元素交互。"""
+
+
+@tool()
+async def browser_read(
+    mode: Literal["html", "find", "long_content", "extract"],
+    query: Optional[str] = None,
+    start_line: int = 0,
+    lines_per_page: int = 100,
+    extract_links: bool = False,
+) -> ToolResult:
+    """页面内容读取:
+    - html: 整页 HTML(大页面慎用)
+    - find: 在页面中查找 query 文本
+    - long_content: 分页读取长内容,配合 start_line / lines_per_page
+    - extract: 用 LLM 根据 query 抽取结构化信息,可选 extract_links
+    """
+
+
+@tool()
+async def browser_scroll(
+    down: bool = True,
+    pages: float = 1.0,
+    into_view_index: Optional[int] = None,
+) -> ToolResult:
+    """滚动页面。down=True 向下,pages 是滚动的页面数;
+    传 into_view_index 则滚动到指定元素可见(忽略 down 和 pages)。
+    """
+
+
+@tool()
+async def browser_tabs(
+    action: Literal["switch", "close", "list"],
+    tab_id: Optional[str] = None,
+) -> ToolResult:
+    """标签页管理。list 不需要 tab_id,switch 和 close 需要。"""
+
+
+@tool()
+async def browser_cookies(
+    action: Literal["load", "export", "ensure_login"],
+    name: str = "",
+    account: str = "",
+    url: str = "",
+    cookie_type: str = "",
+    auto_navigate: bool = True,
+) -> ToolResult:
+    """Cookie 管理:
+    - load: 加载已保存的 cookie(url + name, auto_navigate 控制是否自动导航)
+    - export: 导出当前 cookie 保存(name + account 标识)
+    - ensure_login: 检查登录状态,未登录时自动加载 cookie_type 对应的 cookie
+    """
+
+
+@tool()
+async def browser_wait(
+    seconds: Optional[int] = None,
+    user_action_message: Optional[str] = None,
+) -> ToolResult:
+    """等待:
+    - 传 seconds: 纯等待指定秒数
+    - 传 user_action_message: 暂停并提示用户在浏览器里手动操作,用户完成后 Agent 继续
+    - 两者都不传: 报错
+    """
+
+
+@tool()
+async def browser_evaluate(code: str) -> ToolResult:
+    """在当前页面上下文执行 JavaScript。"""
+
+
+@tool()
+async def browser_download(url: str, save_name: str = "") -> ToolResult:
+    """直接下载给定 URL 的文件到本地。"""
+```
+
+**可选保留(视使用频率决定):**
+
+- `browser_get_live_url()` — 远程浏览器场景专用,可能删除
+- `browser_done(text, success)` — 任务完成信号,可能删除(让 agent 用普通 completion 输出)
+
+**28 → 约 11 个 @tool,下降 60%。**
+
+### 条件必填参数的处理
+
+Python 函数签名里所有参数都声明为 Optional,但某些组合是运行时强制的:
+
+```python
+async def browser_interact(action, index, text, path, keys, clear):
+    if action == "click" and index is None:
+        return ToolResult(error="click action requires index")
+    if action == "type" and (index is None or text is None):
+        return ToolResult(error="type action requires both index and text")
+    ...
+```
+
+静态 schema 表达不了这个,只能靠 docstring 说清楚 + 运行时 validate。
+
+### 迁移步骤
+
+1. 在 `baseClass.py` 里先保留所有现有的非 @tool 内部辅助函数(它们负责实际调用 browser-use)
+2. 把 30 个原 `@tool` 函数**去掉 @tool 装饰器**,降级为内部函数 `_navigate_to_url` / `_click_element` 等
+3. 在 `baseClass.py` 底部新增 11 个 @tool 入口函数,每个内部根据 action 路由到对应的内部函数
+4. 从 `browser/__init__.py` 更新导出列表
+5. 更新 `agent/docs/tools.md` 的浏览器工具小节
+6. 更新现有的浏览器 prompt(破坏性)
+
+### 未决策的设计问题
+
+1. **`browser_read` 的 4 个 mode 是否要再拆分?** `extract` 是 LLM 驱动的,和其他 3 个差异较大。可以拆成 `browser_read(mode="html|find|long")` + `browser_extract(query, ...)`。
+2. **`browser_interact` 的 6 个 action 都合并合适吗?** `dropdown_list` 和 `dropdown_select` 与其他 action 的参数差异较大,可以独立出 `browser_dropdown(index, select_text=None)`。
+3. **`browser_done` 的去留** — 这个是给上层 Agent 发任务完成信号的协议约定,不是浏览器操作。建议移到框架通用的 task 信号机制里,或删除。
+4. **`browser_search_web` 要不要作为 mode 合并到 `browser_navigate`?** 搜索引擎的具体实现(`engine: "bing"|"google"|...`)和 URL 导航差异较大,合并后签名变乱。可能独立保留更好。
+5. **重命名的破坏性改动** — 所有 `browser_navigate_to_url` 等现存引用都要更新。需要在 PR 描述里列出 before/after 对照表。
+
+---
+
+## 方案三(暂不采用):引入 MCP Client 基础设施
+
+**思路:** 让本框架的 Agent Runner 作为 MCP Client 连接 browser-use 原生 MCP,**删除**所有与 browser-use MCP 重叠的 @tool,只保留自研扩展(cookie、wait_for_user_action、dropdown 等约 14 个)。
+
+**优点:** 消除代码重复;未来 browser-use 升级自动获益;和 Claude Code 的浏览器体验一致。
+
+**缺点:** 需要给框架的 Agent Runner 新增 MCP Client 基础设施;启动时需要管理 MCP server 进程生命周期;双路共存(部分本地 @tool + 部分远程 MCP)增加复杂度。
+
+**结论:** 当前不做,视未来框架是否引入通用 MCP Client 基础设施再议。方案二(语义合并)的收益已经够大,投入更小。
+
+---
+
+## 共同原则(所有工具族改造都要遵守)
+
+1. **破坏性改动集中做**——所有重命名、删除、合并都在同一个 PR 里完成,不要分期做。分期反而让用户迁移更痛苦
+2. **每个工具族都要有对应的 CLI 入口 + 自包含 `if __name__ == "__main__"`**——参考 toolhub / librarian 已有的模式
+3. **对应的 skill 写到 `~/.claude/skills/`**——让 Claude Code 等外部 Agent 能用
+4. **破坏性改动后同步更新 `agent/docs/tools.md` 和所有现存 prompt**
+
+---
+
+## 待决策清单(落地前必须定)
+
+### 内容工具族
+
+- [ ] `extras` schema 处理方式(推荐方案 i)
+- [ ] 缓存持久化方案(推荐磁盘 + trace_id)
+- [ ] 拼图上限策略(推荐分 detail/overview 两档)
+- [ ] X 是否补 detail 接口(取决于后端支持)
+
+### 浏览器工具族
+
+- [ ] `browser_read` 是否拆成 read + extract 两个
+- [ ] `browser_interact` 是否拆出 dropdown
+- [ ] `browser_done` 去留
+- [ ] `browser_search_web` 是否合并到 navigate
+- [ ] 重命名破坏性改动的迁移策略
+
+### 哲学选择
+
+- [ ] 是否未来引入 MCP Client 基础设施(影响浏览器工具的最终形态)
+
+---
+
+## 不做的事情
+
+- **沙箱工具**:直接删除,不改造(参考 `tools.md` 对应修改记录)
+- **文件工具、bash、skill 等正交单能力工具**:保持现状
+- **knowledge 工具族**:已经是 `ask_knowledge` / `upload_knowledge` 两个入口,规模小且清晰,无改造必要

+ 1541 - 0
agent/agent/docs/tools.md

@@ -0,0 +1,1541 @@
+# 工具系统文档
+
+> Agent 框架的工具系统:定义、注册、执行工具调用。
+
+---
+
+## 目录
+
+1. [核心概念](#核心概念)
+2. [定义工具](#定义工具)
+3. [ToolResult 和记忆管理](#toolresult-和记忆管理)
+4. [ToolContext 和依赖注入](#toolcontext-和依赖注入)
+5. [高级特性](#高级特性)
+6. [工具分组](#工具分组)
+7. [内置基础工具](#内置基础工具)
+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 可见,框架自动注入默认值或与 LLM 值合并(如 `owner`, `tags`)
+
+```python
+@tool(
+    hidden_params=["context", "owner"],  # 不生成 schema,LLM 看不到
+    inject_params={                       # 声明注入规则
+        "owner": {"mode": "default", "key": "knowledge_config.owner"},
+        "tags":  {"mode": "merge",   "key": "knowledge_config.default_tags"},
+        "scopes": {"mode": "merge",  "key": "knowledge_config.default_scopes"},
+    }
+)
+async def knowledge_save(
+    task: str,                          # 业务参数:LLM 填写
+    content: str,                       # 业务参数:LLM 填写
+    types: List[str],                   # 业务参数:LLM 填写
+    tags: Optional[Dict] = None,        # 注入参数:LLM 可填,框架合并默认值
+    scopes: Optional[List] = None,      # 注入参数:LLM 可填,框架合并默认值
+    owner: Optional[str] = None,        # 隐藏参数:LLM 看不到,框架注入
+    context: Optional[ToolContext] = None,  # 隐藏参数:LLM 看不到
+) -> ToolResult:
+    """保存知识到知识库"""
+    ...
+```
+
+**inject_params 声明格式**:
+
+```python
+inject_params={
+    "param_name": {
+        "mode": "default" | "merge",  # 注入模式
+        "key": "config_obj.field",    # 从 context 中取值的路径
+    }
+}
+```
+
+- `mode: "default"`:LLM 未提供时注入框架值
+- `mode: "merge"`:框架值与 LLM 值合并。dict 按 key 合并(框架 key 不可被覆盖,LLM 可追加新 key);list 合并去重
+
+**值的来源**:通过 `key` 指定从 `context` 中取值的路径(如 `"knowledge_config.default_tags"` 表示 `context["knowledge_config"].default_tags`)。runner 在调用 `execute()` 时将配置对象放入 context,框架根据 key 路径自动取值。
+
+**注入时机**:
+- 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", "owner"],
+    inject_params={
+        "owner": {"mode": "default", "key": "knowledge_config.owner"},
+        "tags":  {"mode": "merge",   "key": "knowledge_config.default_tags"},
+        "scopes": {"mode": "merge",  "key": "knowledge_config.default_scopes"},
+    }
+)
+async def knowledge_save(
+    task: str,
+    content: str,
+    types: List[str],
+    tags: Optional[Dict] = None,  # LLM 可填,框架合并默认值
+    scopes: Optional[List] = None,  # LLM 可填,框架合并默认值
+    owner: Optional[str] = None,  # LLM 看不到,框架注入
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    保存知识
+
+    Args:
+        task: 任务描述
+        content: 知识内容
+        types: 知识类型
+        tags: 业务标签(可选,框架合并默认值)
+        scopes: 可见范围(可选,框架合并默认值)
+    """
+    ...
+```
+
+**注入规则**:
+- `inject_params` 的 value 是一个 dict,包含:
+  - `mode`: `"default"`(LLM 未提供则注入)或 `"merge"`(与 LLM 值合并)
+  - `key`: 从 context 中取值的路径(如 `"knowledge_config.default_tags"`)
+- 参数同时在 `hidden_params` 中时,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%}")
+```
+
+---
+
+## 工具分组
+
+工具通过 `@tool(groups=[...])` 声明所属分组,`RunConfig.tool_groups` 控制 Agent 可用哪些分组。
+
+### 机制
+
+```python
+# 注册时声明分组
+@tool(groups=["browser"])
+async def browser_navigate(url: str) -> ToolResult: ...
+
+@tool(groups=["content", "media"])  # 支持多标签
+async def extract_video_clip(...) -> ToolResult: ...
+```
+
+```python
+# 配置时按分组过滤
+RunConfig(tool_groups=["core", "content"])        # 只用核心 + 内容工具
+RunConfig(tool_groups=["core", "browser"])         # 只用核心 + 浏览器工具
+RunConfig(tool_groups=None)                        # 全部工具(默认)
+RunConfig(tools=["knowledge_search", "read_file"]) # 精确指定(优先于 tool_groups)
+```
+
+### 过滤逻辑
+
+`_get_tool_schemas(tools, tool_groups)` 的优先级:
+
+1. `tools` 非空 → 精确使用指定列表(忽略 `tool_groups`)
+2. `tool_groups` 非空 → 按分组白名单过滤(工具的任一 group 命中即选中)
+3. 两者都为 None → 返回所有已注册工具
+
+### 实现位置
+
+- 分组声明:`@tool(groups=[...])` — `agent/tools/registry.py`
+- 分组存储:`ToolRegistry._tools[name]["groups"]`
+- 分组过滤:`ToolRegistry.get_tool_names(groups=[...])`
+- 配置入口:`RunConfig.tool_groups` — `agent/core/runner.py`
+
+### 分组一览
+
+完整的分组和工具列表见 [agent/README.md 附录](../README.md#附录工具分组)。
+
+---
+
+## 内置基础工具
+
+> 参考 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 |
+| `read_images` | 批量读取图片,支持自动降采样和网格拼图 | 自研 |
+| `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 执行任务(本地执行或路由到远端服务器,由 `agent_type` 决定) | 自研 |
+| `evaluate` | 评估目标执行结果是否满足要求 | 自研 |
+| `toolhub_health` | 检查 ToolHub 远程工具库服务状态 | 自研 |
+| `toolhub_search` | 搜索/发现 ToolHub 远程工具 | 自研 |
+| `toolhub_call` | 调用 ToolHub 远程工具(图片参数支持本地文件路径) | 自研 |
+| `content_platforms` | 列出/查询内容平台及其搜索参数(支持模糊匹配) | 自研 |
+| `content_search` | 跨平台内容搜索(11 个平台统一入口) | 自研 |
+| `content_detail` | 查看内容详情(从搜索缓存按索引取) | 自研 |
+| `content_suggest` | 搜索关键词补全建议 | 自研 |
+| `extract_video_clip` | 截取已下载 YouTube 视频的片段 | 自研 |
+| `import_content` | 批量导入文章到 CMS | 自研 |
+
+#### `read_file` vs `read_images`
+
+| 场景 | 工具 |
+|------|------|
+| 读取 **1 张**图片 / 文本 / PDF | `read_file` |
+| 批量读取 **2 张以上**图片 | `read_images` |
+| 需要 AI 对多张图做**对比选择**(选图、挑错、横向比较) | `read_images` 且 `layout="grid"` |
+| 需要对多张图**逐张独立分析** | `read_images` 且 `layout="separate"` |
+
+`read_images` 默认 `layout="grid"` — 多张图拼成一张**带索引编号**的网格图(1,2,3…),**省 token 的同时让 LLM 能在单次注视中做横向对比**。拼图和降采样在内部组合使用:先降采样每张缩略图,再拼成整图,最终图片大小约等于一张普通图的开销,而非所有原图的累积。
+
+**Grid 模式的 16 张硬上限:** grid 模式下单次调用最多 16 张图片。超过会报错,需要分批调用。上限来自于 LLM 内部图片缩放的物理限制——Claude/Qwen-VL 会把图片缩到长边约 1568 像素,当拼图里格子太多时,每格会糊到无法识别。16 张对应 4×4 布局,每格约 300px,缩放后仍能保持约 280px,人物和场景细节仍然可辨。如需处理更多图片,或切换到 `layout="separate"`(无数量限制但每张图都有独立的结构开销 token)。
+
+**自适应布局:** grid 模式下根据图片数量动态选择列数和缩略图尺寸,小批量时每张图更清晰:
+
+| 图片数 | 布局 | 每格大小 |
+|------|------|---------|
+| 2 张 | 2 列 | 500px |
+| 3-4 张 | 2 列 | 450px |
+| 5-6 张 | 3 列 | 400px |
+| 7-9 张 | 3 列 | 380px |
+| 10-12 张 | 4 列 | 320px |
+| 13-16 张 | 4 列 | 300px |
+
+**关于标签/标题:** `read_images` 的拼图**不显示文件名**,只显示索引序号——因为本地文件名(如 `IMG_1234.jpg`)对 LLM 理解内容没有帮助,而索引到原始路径的对照表通过返回文本提供,LLM 可以用"第 3 张"这种引用方式精确指代。对比之下 `content_search` 的拼图**会**显示 label(帖子/视频标题),因为这些是内容型元数据,有实际信息量。这一差异反映在 `build_image_grid(labels=...)` 参数上:传 `None` 只画序号,传列表则在每格下方画标题。
+
+网格和降采样的实现在 `agent/tools/utils/image.py`,`content_search` 等内容工具也复用同一套拼图逻辑。
+
+### Agent 工具
+
+创建子 Agent 执行任务。同一个 `agent` 工具既可以启动**本地**子 Agent,也可以路由到**远端服务器**上的 Agent,由 `agent_type` 前缀决定。
+
+```python
+@tool(description="创建子 Agent 执行任务", groups=["core"])
+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]:
+```
+
+#### 本地 vs 远端:`remote_` 前缀约定
+
+| `agent_type` | 执行位置 | 通信通道 | 典型用途 |
+|--------------|---------|---------|---------|
+| 无前缀(`delegate` / `explore` / `deconstruct` 等) | **本地** 进程内 | 共享文件系统 + message | 项目内委托任务,可通过文件路径传递大数据 |
+| `remote_` 前缀(`remote_librarian` / `remote_research` 等) | **远端** KnowHub 服务器 | **仅 message 通道** | 跨项目复用的能力(知识查询、深度调研) |
+
+**为什么要前缀**:远端 Agent 无法访问调用方的本地文件。前缀告诉模型"不要在 task 里引用本地路径,所有输入必须 inline;所有输出在 response message 里拿"。要传大文件时,先通过 `upload_knowledge` / `toolhub` 等基础设施上传到共享存储,再把 ID 传给远端 Agent。
+
+**哪些远端类型可用**:不通过动态发现,由项目的 prompt 或 preset 显式告诉主 Agent。例如:
+```
+# prompt 片段
+你可以调用以下远端 Agent:
+- remote_librarian(skills=["ask_strategy"]): 整合知识库查询结果,返回带引用的回答
+- remote_librarian(skills=["upload_strategy"]): 上传调研结果(task 为 JSON 字符串)
+- remote_research: 深度调研,全网搜集+总结
+```
+
+**Skill 白名单**:远端 `agent_type` 由服务器定义一个允许调用方注入的 skill 列表(如 `remote_librarian` 的 `["ask_strategy", "upload_strategy"]`)。调用方传的 skill 经白名单过滤后生效——这比拆出多个 agent_type 更简洁:**一个 Agent 多种模式,模式由 skill 触发**。
+
+#### 本地模式:单任务 vs 多任务
+
+本地调用(`agent_type` 无 `remote_` 前缀)根据 `task` 类型分两种模式:
+
+| task 类型 | 模式 | 并行执行 | 工具权限 |
+|-----------|------|---------|---------|
+| `str`(单任务) | delegate | ❌ | 完整(除 agent/evaluate 外) |
+| `List[str]`(多任务) | explore | ✅ | 只读(read_file, grep_content, glob_files, goal) |
+
+**messages 参数**:
+- `None`:无预置消息
+- `Messages`(1D 列表):所有 agent 共享
+- `List[Messages]`(2D 列表):per-agent 独立消息
+
+运行时判断:`messages[0]` 是 dict → 1D 共享;是 list → 2D per-agent。
+
+**单任务(delegate)**:适合委托专门任务,完整工具权限;支持 `continue_from` 续跑已有 Sub-Trace。
+**多任务(explore)**:适合对比多个方案,并行执行,只读权限,不支持 `continue_from`。
+
+#### 远端模式
+
+`remote_` 前缀的 `agent_type` 通过 HTTP 调用 KnowHub 服务器的 `POST /api/agent` 端点执行。
+
+| 字段 | 客户端传 | 服务器说了算 |
+|------|---------|-------------|
+| `agent_type` / `task` / `messages` / `continue_from` | ✓ | — |
+| `skills` / `tool_groups` / `model` / prompt | 传了也会被服务器忽略 | ✓(由服务器 preset 决定) |
+
+**理由**:固定服务器端的能力包络避免越权(如客户端请求 `tool_groups=["knowledge_internal"]`),同时让 Agent 升级变成服务器单方面部署。
+
+**限制**:
+- 远端模式**只支持单任务**(`task: str`)。需要并行时在客户端 fan-out 发多个请求。
+- 不支持跨终端文件共享——输入输出全走 message。
+
+**续跑**:完全由 caller 负责记住和传入 `continue_from`(服务器不再维护 `caller_trace_id → sub_trace_id` 的映射)。首次调用不传 `continue_from`,服务器创建新 trace 并在返回值中给出 `sub_trace_id`;下次调用时 caller 把它作为 `continue_from` 传回。
+
+**返回值**:本地和远端统一返回 `{status, sub_trace_id, summary, stats}`。`summary` 是 Agent 最终产出的 message 文本——Agent 之间通过 message 通信,不定义 per-agent 的返回 schema。如果需要结构化输出(引用来源、ID 列表等),由 Agent 的 prompt 约定写进 message 文本,调用方自行 parse。
+
+#### SDK / CLI 调用
+
+公开 SDK 入口:`agent.invoke_agent()`(定义在 `agent/client.py`),和 `agent` 工具签名一致,路由规则相同。任何 Python 进程只要装了 `cyber-agent` 包就能调用:
+
+```python
+import asyncio
+from agent import invoke_agent
+
+result = asyncio.run(invoke_agent(
+    agent_type="remote_librarian",
+    task="ControlNet 相关的工具知识",
+    skills=["ask_strategy"],
+))
+```
+
+本地 agent 调用需额外传 `project_root`,SDK 会按约定读项目的 `config.py`(`RUN_CONFIG`)、`presets.json`、`tools/` 包。
+
+配套一个 Claude Code skill 薄脚本在 `~/.claude/skills/agent/invoke.py`,透传命令行参数到 `invoke_agent()`,stdout 输出 JSON 结果。让 Claude Code 用同一套语义驱动 Agent。
+
+基于此 CLI,项目在 `~/.claude/skills/` 或项目 skills 目录可以注册 user skill,让 Claude Code 也能通过 skill 调用远端 Agent——模式和 `toolhub` / `knowhub` skill 一致。
+
+#### `agent_type` 与 Presets
+
+- 本地 `agent_type`:在项目 `presets.json` 中定义(工具权限、system prompt、skills 等),支持从 `.prompt` 文件加载 system prompt
+- 远端 `agent_type`:在**服务器** `knowhub/agents/` 下定义(如 `knowhub/agents/research.py`),客户端 presets 不需要配置
+- 详见 `agent/docs/architecture.md` 的 "Agent 预设" 章节
+
+### 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
+        }
+    """
+    ...
+```
+
+---
+
+## 跨框架使用(CLI / MCP)
+
+工具设计为可跨 Agent 框架使用(本框架 Agent、Claude Code、其他 LLM IDE 等),遵循以下原则:
+
+- **无状态工具** → 自包含 CLI:每个工具文件可独立运行,零外部依赖
+- **有状态工具组**(浏览器、沙箱等需要持久 session) → MCP server:使用标准协议管理 session
+- **禁止中间态**:不造私有协议;简单就 CLI,复杂就 MCP
+
+### 判断标准
+
+| 问题 | 答案 | 选择 |
+|------|------|------|
+| 工具调用之间是否有进程内状态需要保持?(浏览器 session、数据库连接、缓存) | 否 | **CLI** |
+| 同上 | 是 | **MCP** |
+| 是否需要 Claude Desktop、Cursor 等客户端原生识别? | 需要 | **MCP** |
+
+### 无状态 CLI 工具规范
+
+一个工具想同时作为 Agent tool(`@tool` 注册)和 CLI 工具使用,需要满足以下要求:
+
+**1. 文件末尾添加自包含的 `if __name__ == "__main__"` 块**
+
+参数解析、asyncio.run、结果输出这些 CLI 样板代码**直接内联**在工具文件里,不要抽取到共享 `cli.py` 模块——这样每个工具文件可以独立迁移到其他项目。
+
+```python
+# 示例:agent/tools/builtin/toolhub.py 末尾
+if __name__ == "__main__":
+    import sys, asyncio, os, uuid
+
+    COMMANDS = {"health": toolhub_health, "search": toolhub_search, "call": toolhub_call}
+
+    def _parse_args(argv):
+        kwargs = {}
+        for arg in argv:
+            if arg.startswith("--") and "=" in arg:
+                k, v = arg.split("=", 1)
+                k = k.lstrip("-").replace("-", "_")
+                try: v = json.loads(v)
+                except: pass
+                kwargs[k] = v
+        return kwargs
+
+    # trace_id 三级回退:CLI 参数 > 环境变量 > 自动生成
+    cmd = sys.argv[1]
+    kwargs = _parse_args(sys.argv[2:])
+    trace_id = kwargs.pop("trace_id", None) or os.getenv("TRACE_ID") or f"cli-{uuid.uuid4().hex[:8]}"
+    set_trace_context(trace_id)
+
+    result = asyncio.run(COMMANDS[cmd](**kwargs))
+    # 输出 JSON(注意 double-encoding 问题)
+    ...
+```
+
+**2. 输出统一为 JSON 格式**
+
+```json
+{
+  "trace_id": "...",
+  "output": "...",  // 原生 dict/list/str,不要预先 json.dumps
+  "error": "...",   // 可选
+  "metadata": {...}  // 可选
+}
+```
+
+**3. trace_id 三级回退策略**
+
+对于需要会话语义(同一 trace_id 内多次调用共享状态)的工具(librarian、toolhub 的图片输出目录等):
+
+1. **CLI 参数** `--trace_id=xxx`(显式)
+2. **环境变量** `TRACE_ID`(同一 shell session 共享)
+3. **自动生成** `cli-{random}`(兜底)
+
+外部 Agent 只需 `export TRACE_ID=session-xxx` 一次,后续所有 CLI 调用自动归到同一会话。
+
+**4. 二进制产出写文件,JSON 返回路径**
+
+像 `read_images` 这种产出图片/大文件的工具,CLI 模式下**不要**把 base64 塞进 stdout(刷屏 + 调用方还要解码)。应该:
+- 要求用户显式传 `--out=<path>` 指定输出路径
+- 把文件写到 `<path>`
+- JSON 响应里返回 `out_path` 供调用方用 Read 工具查看
+
+**5. 避免双重 JSON 编码**
+
+如果工具内部已经 `json.dumps()` 把 result dict 塞进了 `ToolResult.output`,CLI 层再 `json.dumps(result.output)` 会产生双重转义(`"output": "{\"model\": ..."` 这种反人类形式)。CLI 层要在输出前检测并解码:
+
+```python
+output_value = result.output
+if isinstance(output_value, str):
+    stripped = output_value.lstrip()
+    if stripped.startswith(("{", "[")):
+        try:
+            output_value = json.loads(output_value)
+        except (json.JSONDecodeError, ValueError):
+            pass  # 非 JSON 文本,保持原样
+```
+
+### Skill 安装规范
+
+CLI 工具对外暴露给 Claude Code(或其他支持 skill 的客户端)时,需要配套写一个 `SKILL.md`:
+
+**位置:** `~/.claude/skills/<name>/SKILL.md`(用户全局)或项目级 `.claude/skills/<name>/SKILL.md`
+
+**格式:**
+
+```markdown
+---
+name: <skill-name>
+description: <一句话,描述用途和触发时机。这是 Claude Code 决定何时加载该 skill 的唯一依据>
+---
+
+# <Skill Name>
+
+<简短一段话介绍工具>
+
+## 用法
+
+```bash
+python <绝对路径>/tool.py <子命令> --key=value
+```
+
+- `--key=...` 参数说明
+- 关键约束(如数量上限)
+
+<调用后怎么解读输出,典型 workflow>
+```
+
+**尺寸原则:** SKILL.md **越短越好**。它每次触发时都会进入 context 占据 token。和 `agent/docs/tools.md` 的职责区分:
+
+| 文件 | 读者 | 触发 | 长度 |
+|------|------|------|------|
+| `SKILL.md` | **运行时的 Claude Code**(动态加载) | 每次匹配自动加载到 context | **短**(20 行以内为佳) |
+| `agent/docs/tools.md` | **开发者**(静态阅读) | 从不自动加载 | 长,可以详细展开原理、设计取舍 |
+
+SKILL.md 只写"调用这个工具所需的最小信息集",原理和细节放到 docs。
+
+**当前已安装的 skill**(`~/.claude/skills/`):
+- `toolhub/` — 搜索和调用 ToolHub 远程 AI 工具
+- `knowhub/` — 查询和上传 KnowHub 知识库
+- `stitch-images/` — 批量图片拼成网格供 Read 一次查看
+
+### ToolHub 图片管线
+
+`toolhub_call` 内置完整的图片处理管线,无需单独的上传/下载工具:
+
+- **输入**:`params` 中的图片参数(`image`、`image_url`、`mask_image`、`pose_image`、`images`)可直接传本地文件路径,系统自动上传
+- **输出**:生成的图片自动保存到 `outputs/` 目录,返回结果中 `saved_files` 包含本地路径
+
+### MCP 集成(有状态工具组)
+
+对于需要维持 session 的工具组,使用 MCP server。两种注册方式:
+
+**1. 使用现成的 MCP server**(推荐)
+
+例如浏览器工具直接用 browser-use 原生 MCP:
+
+```json
+// .mcp.json(项目根目录;不要写在 settings.json,Claude Code 不会从那里读 mcpServers)
+{
+  "mcpServers": {
+    "browser-use": {
+      "command": "/Users/sunlit/.pyenv/versions/3.13.1/bin/python",
+      "args": ["-m", "browser_use.skill_cli.main", "--mcp"],
+      "env": {
+        "OPENAI_API_KEY": "sk-...",
+        "OPENAI_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
+        "BROWSER_USE_LLM_MODEL": "qwen-plus"
+      }
+    }
+  }
+}
+```
+
+**注意事项:**
+- `command` 必须用**绝对路径**,不要用 pyenv shim(shim 在 Claude Code 子进程里无法正确解析)
+- MCP server 配置放 `.mcp.json`,**不是** `~/.claude/settings.json`(后者只管 permissions/outputStyle 等)
+- 第三方包的 LLM 配置如果 Pydantic schema 吞字段(比如 browser-use 的 `LLMEntry` 不支持 `base_url`),可以通过**环境变量**绕过(如 `OPENAI_BASE_URL` 是 OpenAI SDK 原生环境变量)
+
+**2. 为自研有状态工具组写 MCP server**
+
+当你有一组需要共享 session 的自研工具时,用 `mcp` Python SDK 写一个 server,每个工具作为 `@app.tool()` 暴露。server 进程内维护 session 状态。避免造私有 stdio 协议。
+
+---
+
+## 总结
+
+| 特性 | 状态 | 说明 |
+|------|------|------|
+| **基础注册** | ✅ 已实现 | `@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. **可观测性**:内建统计和监控能力
+6. **跨框架**:无状态工具自包含 CLI,有状态工具走 MCP 标准协议

+ 414 - 0
agent/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 文档
+- [context-management.md](./context-management.md) - Context 管理完整设计
+- [agent/goal/models.py](../agent/goal/models.py) - GoalTree 模型定义
+- [docs/REFACTOR_SUMMARY.md](./REFACTOR_SUMMARY.md) - 重构总结

+ 36 - 0
agent/agent/llm/__init__.py

@@ -0,0 +1,36 @@
+"""
+LLM Providers
+
+各个 LLM 提供商的适配器
+"""
+
+from .gemini import create_gemini_llm_call
+from .openrouter import create_openrouter_llm_call
+from .claude import create_claude_llm_call
+from .yescode import create_yescode_llm_call
+from .qwen import create_qwen_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_claude_llm_call",
+    "create_yescode_llm_call",
+    "create_qwen_llm_call",
+    # Usage
+    "TokenUsage",
+    "TokenUsageAccumulator",
+    "create_usage_from_response",
+    # Pricing
+    "ModelPricing",
+    "PricingCalculator",
+    "get_pricing_calculator",
+    "calculate_cost",
+]

+ 209 - 0
agent/agent/llm/claude.py

@@ -0,0 +1,209 @@
+"""
+Native Anthropic Provider
+
+直接使用 Anthropic 原生 API (或完全兼容原生协议的反代如 imds.ai) 调用 Claude 模型。
+复用 openrouter.py 中的格式化拦截模块(无缝支持 AgentRunner Cache Control)。
+"""
+
+import os
+import asyncio
+import logging
+import httpx
+from typing import List, Dict, Any, Optional
+
+from .pricing import calculate_cost
+
+# 直接复用底层已经在 openrouter 内部写好的格式转换/拦截器
+from .openrouter import (
+    _normalize_tool_call_ids,
+    _to_anthropic_messages,
+    _to_anthropic_tools,
+    _parse_anthropic_response,
+    _RETRYABLE_EXCEPTIONS
+)
+
+logger = logging.getLogger(__name__)
+
+async def anthropic_native_llm_call(
+    messages: List[Dict[str, Any]],
+    model: str = "claude-3-5-sonnet-20241022",
+    tools: Optional[List[Dict]] = None,
+    **kwargs
+) -> Dict[str, Any]:
+    """
+    原生 Anthropic API 调用函数
+    支持 CLAUDE_CODE_KEY/CLAUDE_CODE_URL 或 ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL
+    """
+    # 优先使用 CLAUDE_CODE_KEY,如果不存在则使用 ANTHROPIC_API_KEY
+    claude_code_key = os.getenv("CLAUDE_CODE_KEY")
+    anthropic_key = os.getenv("ANTHROPIC_API_KEY")
+
+    if claude_code_key:
+        api_key = claude_code_key
+        key_source = "CLAUDE_CODE_KEY"
+    elif anthropic_key:
+        api_key = anthropic_key
+        key_source = "ANTHROPIC_API_KEY"
+    else:
+        raise ValueError("CLAUDE_CODE_KEY or ANTHROPIC_API_KEY environment variable not set")
+
+    # 优先使用 CLAUDE_CODE_URL,如果不存在则使用 ANTHROPIC_BASE_URL
+    claude_code_url = os.getenv("CLAUDE_CODE_URL")
+    anthropic_url = os.getenv("ANTHROPIC_BASE_URL")
+
+    if claude_code_url:
+        base_url = claude_code_url
+        url_source = "CLAUDE_CODE_URL"
+    elif anthropic_url:
+        base_url = anthropic_url
+        url_source = "ANTHROPIC_BASE_URL"
+    else:
+        base_url = "https://api.anthropic.com"
+        url_source = "default"
+
+    endpoint = f"{base_url.rstrip('/')}/v1/messages"
+
+    # 记录使用的配置(只在第一次调用时输出)
+    if not hasattr(anthropic_native_llm_call, '_logged_config'):
+        logger.info(f"[Anthropic Native] Using {key_source}: {api_key[:20]}...")
+        logger.info(f"[Anthropic Native] Using {url_source}: {base_url}")
+        print(f"[Anthropic Native] Using {key_source}: {api_key[:20]}...")
+        print(f"[Anthropic Native] Using {url_source}: {base_url}")
+        anthropic_native_llm_call._logged_config = True
+
+    anthropic_version = os.getenv("ANTHROPIC_VERSION", "2023-06-01")
+
+    # 去掉 anthropic/ opneai/ 等命名空间前缀(如果传入的话)
+    if "/" in model:
+        model = model.split("/", 1)[1]
+
+    # 工具前缀规范化为 toolu
+    messages = _normalize_tool_call_ids(messages, "toolu")
+    
+    # 转换为 Anthropic 格式(这一步也会自动把 Cache Control 拦截写入 payload)
+    system_prompt, anthropic_messages = _to_anthropic_messages(messages)
+    
+    # ── Anthropic 原生 API 严格校验:中间位置的 assistant 消息不能有空 content ──
+    # OpenRouter 对此容忍,但原生 API 会直接 400。因此做净化处理:
+    # 1. 非末尾的 assistant 消息若 content 为空字符串 → 补一个占位符
+    # 2. content 为空列表 [] → 同样补位
+    sanitized = []
+    for i, m in enumerate(anthropic_messages):
+        is_last = (i == len(anthropic_messages) - 1)
+        if m.get("role") == "assistant" and not is_last:
+            c = m.get("content", "")
+            if c == "" or c == [] or c is None:
+                # 跳过完全空的中间 assistant 消息(通常是历史 bug)
+                logger.debug("[Anthropic Native] Dropped empty assistant message at index %d", i)
+                continue
+        sanitized.append(m)
+    anthropic_messages = sanitized
+
+    payload: Dict[str, Any] = {
+        "model": model,
+        "messages": anthropic_messages,
+        "max_tokens": kwargs.get("max_tokens", 8192),
+    }
+    
+    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"[Anthropic Native] 发现 {cache_control_count} 个 cache_control 标记,将被发送到原生端点")
+
+    headers = {
+        "x-api-key": api_key,
+        "anthropic-version": anthropic_version,
+        "content-type": "application/json",
+    }
+    
+    # 支持外部打标签如 anthropic_beta 字段
+    if kwargs.get("anthropic_beta"):
+        headers["anthropic-beta"] = kwargs["anthropic_beta"]
+
+    max_retries = 3
+    last_exception = None
+    
+    for attempt in range(max_retries):
+        # 将默认 5 分钟超时延迟加长至 15 分钟,防止长文本输出(如 strategy.json 合成)时引发 ReadTimeout
+        async with httpx.AsyncClient(timeout=900.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("[Anthropic Native] 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("[Anthropic Native] HTTP %d error body: %s", status, error_body)
+                print(f"[Anthropic Native] 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("[Anthropic Native] %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]
+
+    # 解析响应并抽离 Usage
+    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_claude_llm_call(model: str = "claude-3-5-sonnet-20241022"):
+    """
+    创建 Anthropic 原生 LLM 调用函数
+    
+    Args:
+        model: 模型名称如 "claude-3-5-sonnet-20241022" 
+               (可带前缀,将会自动截取)
+
+    Returns:
+        异步 LLM 调用函数提供给 AgentRunner
+    """
+    async def llm_call(messages: List[Dict[str, Any]], model: str = model, tools: Optional[List[Dict]] = None, **kwargs) -> Dict[str, Any]:
+        return await anthropic_native_llm_call(messages, model, tools, **kwargs)
+    return llm_call

+ 277 - 0
agent/agent/llm/claude_code_oauth.py

@@ -0,0 +1,277 @@
+"""
+Claude Code OAuth Provider
+
+通过 claude-agent-sdk 复用 `claude` CLI 的 OAuth 登录态调用 Claude(Max 订阅额度)。
+
+实现方式:使用 `ClaudeSDKClient`(双向 session)+ AsyncIterable[dict] 形式发送
+用户消息。这种模式同时满足:
+  1. 协议正确(client 内部管 stdin 生命周期,不会卡死)
+  2. 支持多模态(content blocks 可带 image 节点)
+
+Auth:依赖 `~/.claude/.credentials.json` 的 OAuth token;如父进程有
+  ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL,会从子进程 env 中剥离,让 CLI
+  回落到 OAuth。父进程 os.environ 不变。
+
+输出契约(与现有 llm_call 一致):
+    {"content": str, "usage": {"input_tokens": int, "output_tokens": int}}
+"""
+
+import logging
+import os
+from typing import Any, Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+
+def _convert_messages(
+    messages: List[Dict[str, Any]],
+) -> Tuple[Optional[str], List[Dict[str, Any]], bool]:
+    """
+    把 OpenAI 风格 messages 拆为 (system_prompt, anthropic_content_blocks, has_image)。
+
+    - role=system 拼接为 system_prompt
+    - role=user/assistant 的 content 转为 Anthropic content blocks (text/image)
+    - OpenAI {"type":"image_url","image_url":{"url":...}} 转为
+      Anthropic {"type":"image","source":{"type":"url","url":...}}
+    - has_image:是否包含图片块,用于决定走 string 还是 AsyncIterable 模式
+    """
+    system_parts: List[str] = []
+    blocks: List[Dict[str, Any]] = []
+    has_image = False
+
+    for msg in messages:
+        role = msg.get("role")
+        content = msg.get("content")
+
+        if role == "system":
+            if isinstance(content, str):
+                system_parts.append(content)
+            continue
+
+        if isinstance(content, str):
+            blocks.append({"type": "text", "text": content})
+            continue
+
+        if isinstance(content, list):
+            for block in content:
+                if not isinstance(block, dict):
+                    blocks.append({"type": "text", "text": str(block)})
+                    continue
+                btype = block.get("type")
+                if btype == "text":
+                    blocks.append({"type": "text", "text": block.get("text", "")})
+                elif btype == "image_url":
+                    url = (block.get("image_url") or {}).get("url", "")
+                    if url:
+                        blocks.append(
+                            {"type": "image", "source": {"type": "url", "url": url}}
+                        )
+                        has_image = True
+                elif btype == "image":
+                    blocks.append(block)
+                    has_image = True
+
+    system_prompt = "\n\n".join(system_parts).strip() or None
+    return system_prompt, blocks, has_image
+
+
+def _blocks_to_string(blocks: List[Dict[str, Any]]) -> str:
+    """把 content blocks 拍平成字符串(图片降级为 [图片URL: ...] 占位)— string 模式用"""
+    parts: List[str] = []
+    for block in blocks:
+        btype = block.get("type")
+        if btype == "text":
+            parts.append(block.get("text", ""))
+        elif btype == "image":
+            src = block.get("source") or {}
+            url = src.get("url") or src.get("data", "")[:60]
+            parts.append(f"[图片URL: {url}]")
+    return "\n\n".join(p for p in parts if p).strip()
+
+
+def create_claude_code_oauth_llm_call(model: str = "claude-sonnet-4-5"):
+    """
+    工厂:返回兼容 pipeline llm_call 契约的异步函数(基于 ClaudeSDKClient)。
+
+    返回函数签名:
+        async (messages, model=..., temperature=..., max_tokens=...,
+               response_schema=None, tools=None, **kwargs) -> dict
+    其中 temperature / max_tokens / response_schema / tools 静默忽略
+    (SDK 不透传这些参数,CLI 用自己的默认值)。
+    """
+    from claude_agent_sdk import (
+        AssistantMessage,
+        ClaudeAgentOptions,
+        ClaudeSDKClient,
+        ClaudeSDKError,
+        RateLimitEvent,
+        ResultMessage,
+        TextBlock,
+    )
+
+    # 让 SDK 子进程看不到 API key 相关变量,回落到 OAuth。
+    # SDK 内部把 options.env 当作"覆盖层"叠在父进程 os.environ 之上,
+    # 所以从 dict 里"移除"这些 key 没用 — 必须显式以空串覆盖父值。
+    # 父进程 os.environ 不变(其他 LLM provider 继续可用 API key)。
+    _override_env: Dict[str, str] = {
+        "ANTHROPIC_API_KEY": "",
+        "ANTHROPIC_BASE_URL": "",
+        "ANTHROPIC_AUTH_TOKEN": "",
+    }
+    if "ANTHROPIC_API_KEY" in os.environ or "ANTHROPIC_BASE_URL" in os.environ:
+        logger.info(
+            "[claude_code_oauth] Overriding ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL "
+            "with empty values in SDK subprocess env so CLI falls back to OAuth."
+        )
+
+    default_model = model
+
+    async def llm_call(
+        messages: List[Dict[str, Any]],
+        model: Optional[str] = None,
+        tools: Optional[List[Dict]] = None,
+        **kwargs: Any,
+    ) -> Dict[str, Any]:
+        actual_model = (model or default_model).split("/")[-1]
+
+        system_prompt, content_blocks, has_image = _convert_messages(messages)
+        if not content_blocks:
+            content_blocks = [{"type": "text", "text": " "}]
+
+        stderr_lines: List[str] = []
+
+        def _capture_stderr(line: str) -> None:
+            if line:
+                stderr_lines.append(line)
+
+        options = ClaudeAgentOptions(
+            model=actual_model,
+            system_prompt=system_prompt,
+            allowed_tools=[],
+            max_turns=1,
+            env=_override_env,
+            stderr=_capture_stderr,
+            # 关键:屏蔽 CLI 加载用户级 ~/.claude/ 配置(output_style/skills/plugins 等)
+            # 否则这些会被注入 system prompt,浪费 token + 影响输出格式
+            setting_sources=[],
+        )
+
+        text_parts: List[str] = []
+        usage: Dict[str, Any] = {}
+        is_error = False
+        api_error_status: Optional[int] = None
+        result_subtype: Optional[str] = None
+        result_errors: List[str] = []
+        rate_limit_signal: Optional[str] = None
+
+        def _emit(line: str) -> None:
+            print(f"[claude] {line}", flush=True)
+
+        try:
+            async with ClaudeSDKClient(options=options) as client:
+                if has_image:
+                    # 多模态:用 AsyncIterable[dict] 模式发送 Anthropic content blocks
+                    async def _input_stream():
+                        yield {
+                            "type": "user",
+                            "message": {"role": "user", "content": content_blocks},
+                            "parent_tool_use_id": None,
+                            "session_id": "default",
+                        }
+                    await client.query(_input_stream())
+                else:
+                    # 纯文本:走 SDK string 模式(已验证稳定路径)
+                    await client.query(_blocks_to_string(content_blocks))
+
+                async for msg in client.receive_response():
+                    msg_type = type(msg).__name__
+
+                    if isinstance(msg, AssistantMessage):
+                        for block in msg.content:
+                            if hasattr(block, "thinking"):
+                                # thinking 内容太多,跳过
+                                continue
+                            elif isinstance(block, TextBlock):
+                                _emit(f"[text] {block.text}")
+                                text_parts.append(block.text)
+                            elif hasattr(block, "name") and hasattr(block, "input"):
+                                _emit(f"[tool_use] {block.name}({block.input})")
+                            else:
+                                _emit(f"[{type(block).__name__}] {block!r}")
+                        if msg.usage and not usage:
+                            usage = dict(msg.usage)
+                    elif isinstance(msg, ResultMessage):
+                        if msg.usage:
+                            usage = dict(msg.usage)
+                        _emit(
+                            f"[result] subtype={msg.subtype} "
+                            f"is_error={msg.is_error} turns={msg.num_turns} "
+                            f"duration={msg.duration_ms}ms "
+                            f"in={msg.usage.get('input_tokens', 0) if msg.usage else 0} "
+                            f"out={msg.usage.get('output_tokens', 0) if msg.usage else 0}"
+                        )
+                        if msg.is_error:
+                            is_error = True
+                            api_error_status = msg.api_error_status
+                            result_subtype = msg.subtype
+                            result_errors = list(msg.errors or [])
+                    elif isinstance(msg, RateLimitEvent):
+                        # RateLimitEvent 是 SDK 定期播报 quota 状态,不等于被限流。
+                        # 只有 rate_limit_info.status != 'allowed' 才算真限流。
+                        info = getattr(msg, "rate_limit_info", None)
+                        info_status = getattr(info, "status", None) if info else None
+                        _emit(f"[rate_limit] status={info_status!r} type={getattr(info, 'rate_limit_type', None)!r}")
+                        if info_status and info_status != "allowed":
+                            rate_limit_signal = f"status={info_status!r}"
+                    else:
+                        # SystemMessage 简化为关键字段;其他未知类型 fallback
+                        if msg_type == "SystemMessage":
+                            data = getattr(msg, "data", {}) or {}
+                            subtype = getattr(msg, "subtype", "?")
+                            if subtype == "init":
+                                _emit(
+                                    f"[init] model={data.get('model')!r} "
+                                    f"apiKeySource={data.get('apiKeySource')!r} "
+                                    f"session={data.get('session_id', '')[:8]}"
+                                )
+                            else:
+                                _emit(f"[system] subtype={subtype}")
+                        else:
+                            _emit(f"[{msg_type}] {msg!r}")
+        except ClaudeSDKError as e:
+            stderr_tail = "\n".join(stderr_lines[-20:])
+            raise RuntimeError(
+                f"claude_agent_sdk error: {type(e).__name__}: {e}\n"
+                f"--- CLI stderr (last 20 lines) ---\n{stderr_tail}"
+            ) from e
+
+        if rate_limit_signal or api_error_status == 429:
+            raise RuntimeError(
+                "Claude Code OAuth rate-limited (429). "
+                "Max subscription quota may be exhausted in current 5-hour window. "
+                "Run `claude /status` to check remaining."
+            )
+
+        if is_error:
+            stderr_tail = "\n".join(stderr_lines[-20:])
+            errors_str = "; ".join(result_errors) or "(empty errors[])"
+            raise RuntimeError(
+                f"claude_agent_sdk is_error=True "
+                f"subtype={result_subtype!r} status={api_error_status} "
+                f"errors={errors_str}\n"
+                f"--- CLI stderr (last 20 lines) ---\n{stderr_tail}"
+            )
+
+        content = "".join(text_parts)
+
+        normalized_usage = {
+            "input_tokens": int(usage.get("input_tokens", 0) or 0),
+            "output_tokens": int(usage.get("output_tokens", 0) or 0),
+        }
+        for k in ("cache_creation_input_tokens", "cache_read_input_tokens"):
+            if k in usage:
+                normalized_usage[k] = int(usage[k] or 0)
+
+        return {"content": content, "usage": normalized_usage}
+
+    return llm_call

+ 464 - 0
agent/agent/llm/gemini.py

@@ -0,0 +1,464 @@
+"""
+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")
+        raw_gemini_parts = msg.get("raw_gemini_parts")
+
+        # Assistant 消息 + tool_calls
+        if role == "assistant" and tool_calls:
+            if raw_gemini_parts:
+                contents.append({
+                    "role": "model",
+                    "parts": raw_gemini_parts
+                })
+                continue
+
+            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,
+            "raw_gemini_parts": candidate.get("content", {}).get("parts", []) if candidates else [],
+            "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

+ 932 - 0
agent/agent/llm/openrouter.py

@@ -0,0 +1,932 @@
+"""
+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 _sanitize_schema_name(title: str) -> str:
+    """清理 schema title 为符合 API 要求的 name(只允许 [a-zA-Z0-9_-])"""
+    import re
+    sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', title)
+    return sanitized.strip('_') or "response"
+
+
+def _ensure_strict_schema(schema: Dict) -> Dict:
+    """
+    将 JSON Schema 转换为 OpenAI strict mode 兼容格式。
+
+    OpenAI strict mode 要求:
+    1. 所有 object 必须有 additionalProperties: false
+    2. 所有 object 的 required 必须包含 properties 的所有 key
+    3. type: ["object", "null"] 不能直接用,需要转成 anyOf
+    4. object 必须有 properties(即使为空)
+
+    Returns:
+        转换后的 schema(深拷贝)
+    """
+    import copy
+    result = copy.deepcopy(schema)
+
+    def _process(obj):
+        if not isinstance(obj, dict):
+            if isinstance(obj, list):
+                for item in obj:
+                    _process(item)
+            return
+
+        obj_type = obj.get("type")
+
+        # 处理 type: ["X", "null"] → anyOf: [{type: "X", ...}, {type: "null"}]
+        if isinstance(obj_type, list) and "null" in obj_type:
+            non_null_types = [t for t in obj_type if t != "null"]
+            if len(non_null_types) == 1:
+                base_type = non_null_types[0]
+                if base_type == "object":
+                    # type: ["object", "null"] → anyOf
+                    props = obj.pop("properties", {})
+                    obj.pop("type", None)
+                    obj.pop("additionalProperties", None)
+                    obj.pop("required", None)
+                    obj["anyOf"] = [
+                        {
+                            "type": "object",
+                            "properties": props,
+                            "required": list(props.keys()),
+                            "additionalProperties": False,
+                        },
+                        {"type": "null"}
+                    ]
+                    # 递归处理 props 内部
+                    for v in props.values():
+                        _process(v)
+                    return
+                else:
+                    # type: ["string", "null"] 等简单类型 → anyOf
+                    remaining = {k: v for k, v in obj.items() if k != "type"}
+                    obj.clear()
+                    obj["anyOf"] = [
+                        {"type": base_type, **remaining},
+                        {"type": "null"}
+                    ]
+                    return
+
+        # 处理纯 object 类型
+        if obj_type == "object":
+            obj["additionalProperties"] = False
+            if "properties" not in obj:
+                obj["properties"] = {}
+            if isinstance(obj.get("properties"), dict):
+                obj["required"] = list(obj["properties"].keys())
+
+        # 递归处理所有值
+        for v in list(obj.values()):
+            _process(v)
+
+    _process(result)
+
+    # 移除 $schema 字段(API 不接受)
+    result.pop("$schema", None)
+
+    return result
+    _process(result)
+
+    return result
+
+
+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"]
+
+    # 如果有 response_schema,转换为 OpenRouter 的 response_format
+    if "response_schema" in kwargs:
+        schema = kwargs["response_schema"]
+        schema_name = _sanitize_schema_name(schema.get("title", "response"))
+        payload["response_format"] = {
+            "type": "json_schema",
+            "json_schema": {
+                "name": schema_name,
+                "strict": True,
+                "schema": _ensure_strict_schema(schema),
+            }
+        }
+        logger.info("[OpenRouter/Anthropic] Using structured output with schema: %s", schema_name)
+
+    # 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,
+    }
+
+
+def _resolve_local_images_in_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    """将消息中指向本地文件的 image_url 提前转为 data:image/...;base64,... URI,
+    确保所有 Provider (包括 fallback 到 chat/completions 的 Gemini/GPT) 都能正确接收图片。
+    """
+    import copy
+    import base64
+    import mimetypes
+
+    result = []
+    for msg in messages:
+        if not isinstance(msg.get("content"), list):
+            result.append(msg)
+            continue
+
+        new_msg = copy.copy(msg)
+        new_content = []
+        for block in msg["content"]:
+            if not isinstance(block, dict) or block.get("type") != "image_url":
+                new_content.append(block)
+                continue
+
+            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 not url.startswith("data:") and not url.startswith("http"):
+                local_path = Path(url)
+                if local_path.exists() and local_path.is_file():
+                    mime_type, _ = mimetypes.guess_type(str(local_path))
+                    mime_type = mime_type or "image/png"
+                    raw = local_path.read_bytes()
+                    b64_data = base64.b64encode(raw).decode("ascii")
+                    new_url = f"data:{mime_type};base64,{b64_data}"
+                    logger.info(f"[OpenRouter] 本地图片统一转 base64: {url} ({len(raw)} bytes)")
+
+                    new_block = copy.deepcopy(block)
+                    if isinstance(new_block["image_url"], dict):
+                        new_block["image_url"]["url"] = new_url
+                    else:
+                        new_block["image_url"] = {"url": new_url}
+                    new_content.append(new_block)
+                    continue
+
+            new_content.append(block)
+        new_msg["content"] = new_content
+        result.append(new_msg)
+
+    return result
+
+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, response_schema 等)
+
+    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")
+
+    # 提取 response_schema(不传给底层 API)
+    response_schema = kwargs.pop("response_schema", None)
+
+    # 统一转换所有本地图片为 base64 data URI
+    messages = _resolve_local_images_in_messages(messages)
+
+    # Claude 模型走 Anthropic 原生端点,其余走 OpenAI 兼容端点
+    provider = _detect_provider_from_model(model)
+    if provider == "anthropic":
+        logger.debug("[OpenRouter] Routing Claude model to Anthropic native endpoint")
+        if response_schema:
+            kwargs["response_schema"] = response_schema
+        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"]
+
+    # 如果有 response_schema,转换为 OpenRouter 的 response_format
+    if response_schema:
+        schema_name = _sanitize_schema_name(response_schema.get("title", "response"))
+        payload["response_format"] = {
+            "type": "json_schema",
+            "json_schema": {
+                "name": schema_name,
+                "strict": True,
+                "schema": _ensure_strict_schema(response_schema),
+            }
+        }
+
+    # 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

+ 354 - 0
agent/agent/llm/pricing.py

@@ -0,0 +1,354 @@
+"""
+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
+
+        # 基础输入费用
+        # 根据 Anthropic 官方文档:
+        # total_input_tokens = input_tokens + cache_read_tokens + cache_creation_tokens
+        # 这三个字段是分开的,不重叠
+        cost += (usage.input_tokens / 1_000_000) * self.input_price
+
+        # 缓存相关费用(如果有)
+        if usage.cache_read_tokens:
+            cost += (usage.cache_read_tokens / 1_000_000) * self.get_cache_read_price()
+        if usage.cache_creation_tokens:
+            cost += (usage.cache_creation_tokens / 1_000_000) * self.get_cache_creation_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/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/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/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)

+ 210 - 0
agent/agent/llm/qwen.py

@@ -0,0 +1,210 @@
+"""
+Qwen LLM provider using OpenAI SDK.
+"""
+
+import os
+import logging
+from typing import Any, Callable, Dict, List, Optional
+from openai import AsyncOpenAI
+
+# 这里的导入根据你的项目结构调整
+from .usage import TokenUsage
+from .pricing import PricingCalculator
+
+logger = logging.getLogger(__name__)
+
+# 2026 推荐:如果 qwen3.5-plus 报 404,请先用 qwen-plus 测试
+# 阿里有时要求兼容模式下的 ID 必须是特定的字符串
+DEFAULT_QWEN_MODEL = "qwen-plus" 
+
+def create_qwen_llm_call(
+    model: str = DEFAULT_QWEN_MODEL,
+    base_url: Optional[str] = None,
+    api_key: Optional[str] = None,
+) -> Callable:
+    """
+    Create a Qwen LLM call function using the OpenAI SDK.
+    """
+    # 获取配置
+    # 注意:使用 OpenAI SDK 时,base_url 必须包含到 /v1
+    api_key = api_key or os.getenv("QWEN_API_KEY")
+    base_url = base_url or os.getenv("QWEN_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
+
+    if not api_key:
+        raise ValueError("QWEN_API_KEY is required")
+
+    # 初始化 OpenAI 异步客户端
+    # SDK 会自动处理 /chat/completions 的拼接
+    client = AsyncOpenAI(
+        api_key=api_key,
+        base_url=base_url
+    )
+
+    pricing_calc = PricingCalculator()
+
+    async def llm_call(
+        messages: List[Dict[str, Any]],
+        model: str = model,
+        tools: Optional[List[Dict]] = None,
+        temperature: float = 0.2,
+        max_tokens: int = 16384,
+        **kwargs
+    ) -> Dict[str, Any]:
+        
+        try:
+            response = await client.chat.completions.create(
+                model=model,
+                messages=messages,
+                tools=tools,
+                temperature=temperature,
+                max_tokens=max_tokens,
+                **kwargs
+            )
+
+            # 获取内容
+            content = response.choices[0].message.content or ""
+
+            # 捕获 thinking 模式的推理内容(不影响 tool_calls 解构)
+            reasoning_content = getattr(response.choices[0].message, "reasoning_content", None) or ""
+
+            # --- 关键修正位置 ---
+            # 将 Pydantic 对象转换为原始 Dict 列表,这样 runner.py 的 .get() 才不会报错
+            tool_calls = None
+            if response.choices[0].message.tool_calls:
+                tool_calls = [
+                    tc.model_dump() for tc in response.choices[0].message.tool_calls
+                ]
+            # ------------------
+
+            usage = TokenUsage(
+                input_tokens=response.usage.prompt_tokens,
+                output_tokens=response.usage.completion_tokens,
+            )
+
+            cost = pricing_calc.calculate_cost(model=model, usage=usage)
+
+            return {
+                "content": content,
+                "reasoning_content": reasoning_content,
+                "tool_calls": tool_calls, # 现在这里是 List[Dict] 了
+                "prompt_tokens": usage.input_tokens,
+                "completion_tokens": usage.output_tokens,
+                "reasoning_tokens": getattr(response.usage, "reasoning_tokens", 0),
+                "finish_reason": response.choices[0].finish_reason,
+                "cost": cost,
+                "usage": usage,
+            }
+
+        except Exception as e:
+            logger.error(f"Qwen SDK Call Failed: {str(e)}")
+            raise
+
+    return llm_call
+
+
+async def qwen_llm_call(
+    messages: List[Dict[str, Any]],
+    model: str = DEFAULT_QWEN_MODEL,
+    tools: Optional[List[Dict]] = None,
+    **kwargs
+) -> Dict[str, Any]:
+    """
+    Qwen LLM 调用函数(独立函数,可直接使用)
+
+    Args:
+        messages: OpenAI 格式消息列表
+        model: 模型名称(如 "qwen-plus", "qwen-turbo", "qwen-max")
+        tools: OpenAI 格式工具定义
+        **kwargs: 其他参数(temperature, max_tokens 等)
+
+    Returns:
+        {
+            "content": str,
+            "tool_calls": List[Dict] | None,
+            "prompt_tokens": int,
+            "completion_tokens": int,
+            "reasoning_tokens": int,
+            "cache_creation_tokens": int,
+            "cache_read_tokens": int,
+            "finish_reason": str,
+            "cost": float,
+            "usage": TokenUsage,
+        }
+    """
+    import asyncio
+    from .pricing import calculate_cost
+
+    api_key = os.getenv("QWEN_API_KEY")
+    if not api_key:
+        raise ValueError("QWEN_API_KEY environment variable not set")
+
+    base_url = os.getenv("QWEN_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
+    client = AsyncOpenAI(api_key=api_key, base_url=base_url)
+
+    # 构建请求参数
+    create_kwargs = {
+        "model": model,
+        "messages": messages,
+    }
+    if tools:
+        create_kwargs["tools"] = tools
+    if "temperature" in kwargs:
+        create_kwargs["temperature"] = kwargs["temperature"]
+    if "max_tokens" in kwargs:
+        create_kwargs["max_tokens"] = kwargs["max_tokens"]
+
+    # 带重试的调用
+    max_retries = 3
+    last_exception = None
+    for attempt in range(max_retries):
+        try:
+            response = await client.chat.completions.create(**create_kwargs)
+            break
+        except (ConnectionError, TimeoutError, OSError) as e:
+            last_exception = e
+            if attempt < max_retries - 1:
+                wait = 2 ** attempt * 2
+                logger.warning(
+                    "[Qwen] %s (attempt %d/%d), retrying in %ds",
+                    type(e).__name__, attempt + 1, max_retries, wait,
+                )
+                await asyncio.sleep(wait)
+                continue
+            logger.error("[Qwen] Request failed after %d attempts: %s", max_retries, e)
+            raise
+        except Exception as e:
+            logger.error("[Qwen] Request failed: %s", e)
+            raise
+    else:
+        raise last_exception  # type: ignore[misc]
+
+    # 解析响应
+    choice = response.choices[0]
+    content = choice.message.content or ""
+    finish_reason = choice.finish_reason
+
+    # tool_calls: Pydantic 对象转 dict
+    tool_calls = None
+    if choice.message.tool_calls:
+        tool_calls = [tc.model_dump() for tc in choice.message.tool_calls]
+
+    # 解析 usage
+    usage = TokenUsage(
+        input_tokens=response.usage.prompt_tokens,
+        output_tokens=response.usage.completion_tokens,
+    )
+
+    cost = calculate_cost(model, usage)
+
+    return {
+        "content": content,
+        "tool_calls": tool_calls,
+        "prompt_tokens": usage.input_tokens,
+        "completion_tokens": usage.output_tokens,
+        "reasoning_tokens": getattr(response.usage, "reasoning_tokens", 0) or 0,
+        "cache_creation_tokens": 0,
+        "cache_read_tokens": 0,
+        "finish_reason": finish_reason,
+        "cost": cost,
+        "usage": usage,
+    }

+ 297 - 0
agent/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/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

+ 16 - 0
agent/agent/skill/__init__.py

@@ -0,0 +1,16 @@
+"""
+Skill - 技能系统
+
+核心职责:
+1. Skill 数据模型
+2. Skill 加载器(从 Markdown 加载技能)
+"""
+
+from agent.skill.models import Skill
+from agent.skill.skill_loader import SkillLoader, load_skills_from_dir
+
+__all__ = [
+    "Skill",
+    "SkillLoader",
+    "load_skills_from_dir",
+]

+ 99 - 0
agent/agent/skill/models.py

@@ -0,0 +1,99 @@
+"""
+Skill 数据模型
+"""
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Dict, Any, List, Optional
+import uuid
+
+
+@dataclass
+class Skill:
+    """
+    技能 - 从 Markdown 文件加载的领域知识
+
+    技能可以形成层次结构(通过 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)
+
+    # 版本
+    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
+        """
+        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)

+ 402 - 0
agent/agent/skill/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.skill.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/skill/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
+

+ 46 - 0
agent/agent/skill/skills/browser.md

@@ -0,0 +1,46 @@
+---
+name: browser
+description: 浏览器自动化工具使用指南
+---
+
+## 浏览器工具使用指南
+
+所有浏览器工具都以 `browser_` 为前缀。浏览器会话会持久化,无需每次重新启动。
+
+### 基本工作流程
+
+1. **页面导航**: 使用 `browser_navigate(url)` 或 `browser_search(query)` 到达目标页面
+2. **等待加载**: 页面跳转后调用 `browser_wait(seconds=2)` 等待内容加载
+3. **获取元素索引**: 调用 `browser_screenshot(highlight_elements=True)` 获取带编号标注的截图 + 元素列表
+4. **执行交互**: 使用 `browser_interact(action, index, ...)` 操作页面(click / type / send_keys / upload / dropdown)
+5. **提取内容**: 使用 `browser_extract(query)` 让 LLM 提取结构化数据,或 `browser_read(mode="long")` 分页读取长内容
+
+### 关键原则
+
+- **禁止模拟结果**:不要输出你认为的搜索结果,而是要调用工具获取真实结果
+- **必须先获取索引**: 所有 `index` 参数都需要先通过 `browser_screenshot(highlight_elements=True)` 或 `browser_elements()` 获取
+- **高级工具**:优先使用 `browser_extract` / `browser_read` 获取数据,而不是手动解析元素
+- **操作后等待**: 任何可能触发页面变化的操作后都要调用 `browser_wait`
+- **登录处理**:
+  - **正常登录**:使用 `browser_cookies(action="load", url=...)` 注入已保存的 cookie
+  - **首次登录**:需要人类协助——导航到登录页,通过飞书发送链接,等待确认后 `browser_cookies(action="export")` 保存
+- **复杂操作用JS**: 当标准工具无法满足时,使用 `browser_js(code)` 执行 JavaScript
+
+### 工具一览
+
+| 工具 | 功能 |
+|------|------|
+| `browser_navigate(url)` | 导航到 URL |
+| `browser_search(query, engine)` | 搜索引擎搜索 |
+| `browser_back()` | 返回上一页 |
+| `browser_interact(action, ...)` | 元素交互(click/type/send_keys/upload/dropdown) |
+| `browser_scroll(down, pages)` | 滚动页面 |
+| `browser_screenshot(highlight)` | 截图(highlight=True 带元素编号标注) |
+| `browser_elements()` | 获取可交互元素列表(纯文本) |
+| `browser_read(mode)` | 读取页面(html/find/long) |
+| `browser_extract(query)` | LLM 驱动的结构化数据提取 |
+| `browser_tabs(action, tab_id)` | 标签页管理(switch/close) |
+| `browser_cookies(action, ...)` | Cookie/登录态管理(load/export/ensure_login) |
+| `browser_wait(seconds/user_message)` | 等待(定时 or 等用户操作) |
+| `browser_js(code)` | 执行 JavaScript |
+| `browser_download(url)` | 下载文件 |

+ 121 - 0
agent/agent/skill/skills/core.md

@@ -0,0 +1,121 @@
+---
+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"])`
+
+## 信息调研
+
+你可以通过 `content_search(platform, keyword)` 搜索来自 GitHub、小红书、微信公众号、知乎、YouTube、X 等平台的信息,再用 `content_detail(platform, index)` 查看完整内容。不确定平台参数时先调 `content_platforms()` 查看。
+对于需要深度交互的网页内容,使用浏览器工具进行操作。
+
+调研过程可能需要多次搜索,比如基于搜索结果中获得的启发或信息启动新的搜索,直到得到令人满意的答案。你可以使用`goal`工具管理搜索的过程,或者使用文档记录搜索的中间或最终结果。
+
+## 浏览器工具使用指南
+
+所有浏览器工具都以 `browser_` 为前缀。浏览器会话会持久化,无需每次重新启动。
+
+### 基本工作流程
+
+1. **页面导航**: 使用 `browser_navigate(url)` 或 `browser_search(query)` 到达目标页面
+2. **等待加载**: 页面跳转后调用 `browser_wait(seconds=2)` 等待内容加载
+3. **获取元素索引**: 调用 `browser_screenshot(highlight_elements=True)` 获取带编号标注的截图 + 元素列表
+4. **执行交互**: 使用 `browser_interact(action, index, ...)` 操作页面(click / type / send_keys / upload / dropdown)
+5. **提取内容**: 使用 `browser_extract(query)` 让 LLM 提取结构化数据,或 `browser_read(mode="long")` 分页读取长内容
+
+### 关键原则
+
+- **禁止模拟结果**:不要输出你认为的搜索结果,而是要调用工具获取真实结果
+- **必须先获取索引**: 所有 `index` 参数都需要先通过 `browser_screenshot(highlight_elements=True)` 或 `browser_elements()` 获取
+- **高级工具**:优先使用 `browser_extract` / `browser_read` 获取数据,而不是手动解析元素
+- **操作后等待**: 任何可能触发页面变化的操作后都要调用 `browser_wait`
+- **登录处理**:
+  - **正常登录**:使用 `browser_cookies(action="load", url=...)` 注入已保存的 cookie
+  - **首次登录**:需要人类协助——导航到登录页,通过飞书发送链接,等待确认后 `browser_cookies(action="export")` 保存
+- **复杂操作用JS**: 当标准工具无法满足时,使用 `browser_js(code)` 执行 JavaScript
+
+### 工具一览
+
+| 工具 | 功能 |
+|------|------|
+| `browser_navigate(url)` | 导航到 URL |
+| `browser_search(query, engine)` | 搜索引擎搜索 |
+| `browser_back()` | 返回上一页 |
+| `browser_interact(action, ...)` | 元素交互(click/type/send_keys/upload/dropdown) |
+| `browser_scroll(down, pages)` | 滚动页面 |
+| `browser_screenshot(highlight)` | 截图(highlight=True 带元素编号标注) |
+| `browser_elements()` | 获取可交互元素列表(纯文本) |
+| `browser_read(mode)` | 读取页面(html/find/long) |
+| `browser_extract(query)` | LLM 驱动的结构化数据提取 |
+| `browser_tabs(action, tab_id)` | 标签页管理(switch/close) |
+| `browser_cookies(action, ...)` | Cookie/登录态管理(load/export/ensure_login) |
+| `browser_wait(seconds/user_message)` | 等待(定时 or 等用户操作) |
+| `browser_js(code)` | 执行 JavaScript |
+| `browser_download(url)` | 下载文件 |

+ 65 - 0
agent/agent/skill/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/agent/skill/skills/research.md

@@ -0,0 +1,419 @@
+---
+name: atomic_research
+description: 知识调研 - 根据目标和任务自动执行搜索,返回结构化知识列表
+---
+
+## 信息调研
+
+你可以通过 `content_search` 工具搜索来自 GitHub、小红书、微信公众号、知乎、YouTube、X 等平台的信息,并使用 `content_detail` 查看具体内容。如不确定平台参数,先调 `content_platforms` 查看。
+
+## 调研过程可能需要多次搜索,比如基于搜索结果中获得的启发或信息启动新的搜索,直到得到令人满意的答案。你可以使用 `goal` 工具管理搜索的过程,或者使用文档记录搜索的中间或最终结果。(可以着重参考browser的工具来辅助搜索)
+
+## 工作流程
+
+### 输入
+
+- **目标**:要达成的目标(如"找到 PDF 表格提取的最佳方案")
+- **任务**:具体的任务描述(如"从复杂 PDF 中提取表格数据")
+
+### 执行流程
+
+**Step 1: 拆解搜索维度**
+
+```
+goal(add="搜索工具, 搜索案例, 搜索方法论")
+```
+
+**Step 2: 多维度搜索**
+
+- 搜索工具:`content_search(platform="github", keyword="PDF table extraction tool")`
+- 搜索案例:`content_search(platform="xhs", keyword="PDF表格提取")`
+- 搜索定义:`content_search(platform="zhihu", keyword="PDF table extraction")`
+- 搜索方法:`content_search(platform="gzh", keyword="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 个维度记录
+- **可追溯**:记录所有参考来源
+- **可评估**:初始评分 + 持续反馈

+ 21 - 0
agent/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/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/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/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/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/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/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/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
+    )

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

@@ -0,0 +1,86 @@
+"""
+内置基础工具 - 参考 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.read_images import read_images
+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
+# sandbox 工具已废弃(2026-04);search.py / crawler.py 已重构为 content/ 工具族(2026-04)
+from agent.tools.builtin.knowledge import(knowledge_search,knowledge_save,knowledge_save_pending,knowledge_list,knowledge_update,knowledge_batch_update,knowledge_slim)
+# Memory / Dream(见 agent/docs/memory.md)
+from agent.tools.builtin.memory import dream
+# 知识上传/查询已统一到 agent 工具:
+#   agent(agent_type="remote_librarian", task=...)         # 查询
+#   agent(agent_type="remote_librarian_ingest", task=...)  # 上传(异步)
+#   agent(agent_type="remote_research", task=...)          # 深度调研
+from agent.tools.builtin.context import get_current_context
+from agent.tools.builtin.toolhub import toolhub_health, toolhub_search, toolhub_call
+from agent.tools.builtin.resource import resource_list_tools, resource_get_tool
+from agent.tools.builtin.content import (
+    content_platforms, content_search, content_detail, content_suggest,
+    extract_video_clip, import_content,
+)
+from agent.trace.goal_tool import goal
+# 导入浏览器工具以触发注册 (因 P1 流水线不需要,且加载缓慢,暂时全局屏蔽)
+# import agent.tools.builtin.browser  # noqa: F401
+
+import agent.tools.builtin.feishu
+import agent.tools.builtin.im
+
+__all__ = [
+    # 文件操作
+    "read_file",
+    "read_images",
+    "edit_file",
+    "write_file",
+    "glob_files",
+    "grep_content",
+    # 系统工具
+    "bash_command",
+    "skill",
+    # 知识管理:统一通过 agent(agent_type="remote_librarian" / "remote_librarian_ingest" / "remote_research")
+    # 知识管理(旧架构 - 直接 HTTP API,仅供 Knowledge Manager 内部使用)
+    # "knowledge_search",
+    # "knowledge_save",
+    # "knowledge_list",
+    # "knowledge_update",
+    # "knowledge_batch_update",
+    # "knowledge_slim",
+    "list_skills",
+    "agent",
+    "evaluate",
+    # 内容工具族(重构自 search.py + crawler.py)
+    "content_platforms",
+    "content_search",
+    "content_detail",
+    "content_suggest",
+    # 上下文工具
+    "get_current_context",
+    # ToolHub 远程工具库
+    "toolhub_health",
+    "toolhub_search",
+    "toolhub_call",
+    # image_uploader / image_downloader 已内化到 toolhub_call 的图片管线中,不再单独暴露
+    # 资源查询
+    "resource_list_tools",
+    "resource_get_tool",
+    # 媒体 / 导入
+    "extract_video_clip",
+    "import_content",
+    # Goal 管理
+    "goal",
+    # Memory & Knowledge 提取审核
+    "knowledge_save_pending",  # 反思侧分支暂存(core 组默认可见)
+    "dream",                    # memory-bearing Agent 整理长期记忆(memory 组)
+]

+ 315 - 0
agent/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"], groups=["system"])
+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}
+        )

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

@@ -0,0 +1,56 @@
+"""
+浏览器工具 - Browser-Use 原生工具适配器
+
+基于 browser-use 实现的浏览器自动化工具集。
+28 个原始工具已合并为 14 个语义化入口(2026-04 重构)。
+"""
+
+from agent.tools.builtin.browser.baseClass import (
+    # 会话管理(非 @tool,供框架内部调用)
+    init_browser_session,
+    get_browser_session,
+    get_browser_live_url,
+    cleanup_browser_session,
+    kill_browser_session,
+
+    # 14 个 @tool 入口
+    browser_navigate,
+    browser_search,
+    browser_back,
+    browser_interact,
+    browser_scroll,
+    browser_screenshot,
+    browser_elements,
+    browser_read,
+    browser_extract,
+    browser_tabs,
+    browser_cookies,
+    browser_wait,
+    browser_js,
+    browser_download,
+)
+
+__all__ = [
+    # 会话管理
+    'init_browser_session',
+    'get_browser_session',
+    'get_browser_live_url',
+    'cleanup_browser_session',
+    'kill_browser_session',
+
+    # @tool 入口
+    'browser_navigate',
+    'browser_search',
+    'browser_back',
+    'browser_interact',
+    'browser_scroll',
+    'browser_screenshot',
+    'browser_elements',
+    'browser_read',
+    'browser_extract',
+    'browser_tabs',
+    'browser_cookies',
+    'browser_wait',
+    'browser_js',
+    'browser_download',
+]

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

@@ -0,0 +1,2494 @@
+"""
+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 Literal, 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.qwen import qwen_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
+_last_browser_type: str = "local"
+_last_headless: bool = True
+_live_url: Optional[str] = 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",
+    headless: bool = 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, _last_browser_type, _last_headless, _live_url
+
+    if _browser_session is not None:
+        return _browser_session, _browser_tools
+
+    _last_browser_type = browser_type
+    _last_headless = headless
+
+    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}")
+
+    # 云浏览器:捕获 live URL
+    if browser_type == "cloud":
+        import urllib.parse
+        cdp_url = getattr(_browser_session, 'cdp_url', '') or ''
+        if 'browser-use.com' in cdp_url:
+            # 从 cdp_url (wss://xxx.cdp1.browser-use.com/...) 提取主机名,用 https:// 拼接
+            parsed = urllib.parse.urlparse(cdp_url)
+            host_url = f"https://{parsed.hostname}"
+            _live_url = f"https://live.browser-use.com?wss={urllib.parse.quote(host_url)}"
+            print(f"📡 实时画面链接: {_live_url}")
+
+    if browser_type in ["local", "cloud"] and url:
+        await _browser_tools.navigate(url=url, browser_session=_browser_session)
+
+    return _browser_session, _browser_tools
+
+
+def get_browser_live_url() -> Optional[str]:
+    """获取云浏览器的实时画面链接"""
+    return _live_url
+
+
+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(browser_type=_last_browser_type, headless=_last_headless)
+
+    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)
+# ============================================================
+
+async def browser_get_live_url() -> ToolResult:
+    """
+    获取云浏览器的实时画面链接(Live URL),可用于在本地浏览器中查看或分享给他人操作。
+    仅在云浏览器模式下有效,本地浏览器返回空。
+    """
+    url = get_browser_live_url()
+    if url:
+        return ToolResult(
+            title="云浏览器实时画面链接",
+            output=url,
+            metadata={"live_url": url}
+        )
+    return ToolResult(
+        title="无可用链接",
+        output="当前未使用云浏览器,或浏览器尚未初始化",
+    )
+
+
+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} 失败"
+        )
+
+
+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}' 失败"
+        )
+
+
+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="返回上一页失败"
+        )
+
+
+async def browser_wait_impl(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]}...") # 调试用
+
+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)}"
+        )
+    
+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)
+
+
+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"输入文本失败"
+        )
+
+
+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="发送按键失败"
+        )
+
+
+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)
+# ============================================================
+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)
+        )
+
+
+
+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}' 失败"
+        )
+
+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="获取视觉元素映射失败"
+        )
+    
+async def browser_screenshot_impl() -> 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, file_system=_file_system)
+
+        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)
+# ============================================================
+
+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} 失败"
+        )
+
+
+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)
+# ============================================================
+
+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} 选项失败"
+        )
+
+
+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 qwen_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)
+
+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
+
+
+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="参数解析或校验失败,请检查输入"
+        )
+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 失败"
+        )
+
+
+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)
+# ============================================================
+
+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 执行失败"
+        )
+
+
+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)
+# ============================================================
+
+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)
+# ============================================================
+
+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"
+
+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 失败")
+
+
+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 失败")
+
+
+# ============================================================
+# 新版统一入口(13 个 @tool,替代原来 28 个)
+# ============================================================
+
+
+@tool(groups=["browser"])
+async def browser_navigate(url: str, new_tab: bool = False) -> ToolResult:
+    """
+    导航到指定 URL。
+
+    Args:
+        url: 目标 URL
+        new_tab: 是否在新标签页打开(默认 False)
+    """
+    return await browser_navigate_to_url(url=url, new_tab=new_tab)
+
+
+@tool(groups=["browser"])
+async def browser_search(query: str, engine: str = "bing") -> ToolResult:
+    """
+    使用搜索引擎搜索。
+
+    Args:
+        query: 搜索关键词
+        engine: 搜索引擎,可选 google / bing / duckduckgo,默认 bing
+    """
+    return await browser_search_web(query=query, engine=engine)
+
+
+@tool(groups=["browser"])
+async def browser_back() -> ToolResult:
+    """返回上一页。"""
+    return await browser_go_back()
+
+
+@tool(groups=["browser"])
+async def browser_interact(
+    action: Literal["click", "type", "send_keys", "upload", "dropdown_list", "dropdown_select"],
+    index: Optional[int] = None,
+    text: Optional[str] = None,
+    path: Optional[str] = None,
+    keys: Optional[str] = None,
+    clear: bool = True,
+) -> ToolResult:
+    """
+    与页面元素交互。根据 action 选择具体操作:
+
+    - click: 点击元素。需要 index。
+    - type: 在输入框输入文本。需要 index + text。clear 控制是否先清空。
+    - send_keys: 发送键盘按键(如 Enter、Control+A)。需要 keys,不需要 index。
+    - upload: 上传文件到文件输入框。需要 index + path(绝对路径)。
+    - dropdown_list: 列出下拉框选项。需要 index。
+    - dropdown_select: 选择下拉框选项。需要 index + text(选项文本)。
+
+    Args:
+        action: 交互类型
+        index: 元素索引(从 browser_elements 或 browser_screenshot(highlight=True) 获取)
+        text: 输入文本 / 下拉框选项文本
+        path: 上传文件的绝对路径
+        keys: 键盘按键字符串(如 "Enter"、"Control+A")
+        clear: type 时是否先清空(默认 True)
+    """
+    if action == "click":
+        if index is None:
+            return ToolResult(title="参数错误", output="", error="click 需要 index 参数")
+        return await browser_click_element(index=index)
+
+    elif action == "type":
+        if index is None or text is None:
+            return ToolResult(title="参数错误", output="", error="type 需要 index 和 text 参数")
+        return await browser_input_text(index=index, text=text, clear=clear)
+
+    elif action == "send_keys":
+        if keys is None:
+            return ToolResult(title="参数错误", output="", error="send_keys 需要 keys 参数")
+        return await browser_send_keys(keys=keys)
+
+    elif action == "upload":
+        if index is None or path is None:
+            return ToolResult(title="参数错误", output="", error="upload 需要 index 和 path 参数")
+        return await browser_upload_file(index=index, path=path)
+
+    elif action == "dropdown_list":
+        if index is None:
+            return ToolResult(title="参数错误", output="", error="dropdown_list 需要 index 参数")
+        return await browser_get_dropdown_options(index=index)
+
+    elif action == "dropdown_select":
+        if index is None or text is None:
+            return ToolResult(title="参数错误", output="", error="dropdown_select 需要 index 和 text 参数")
+        return await browser_select_dropdown_option(index=index, text=text)
+
+    else:
+        return ToolResult(title="未知 action", output="", error=f"不支持的 action: {action}")
+
+
+@tool(groups=["browser"])
+async def browser_scroll(
+    down: bool = True,
+    pages: float = 1.0,
+    into_view_index: Optional[int] = None,
+) -> ToolResult:
+    """
+    滚动页面。
+
+    Args:
+        down: True 向下滚动,False 向上(默认 True)
+        pages: 滚动的页面数(默认 1.0)
+        into_view_index: 传入元素索引则滚动到该元素可见(忽略 down 和 pages)
+    """
+    return await browser_scroll_page(down=down, pages=pages, index=into_view_index)
+
+
+@tool(groups=["browser"])
+async def browser_screenshot(highlight_elements: bool = False) -> ToolResult:
+    """
+    截取当前页面。
+
+    Args:
+        highlight_elements: False 返回纯截图;True 返回带交互元素编号标注的截图
+                           + 元素列表(原 visual_selector_map 功能)
+    """
+    if highlight_elements:
+        return await browser_get_visual_selector_map()
+    else:
+        return await browser_screenshot_impl()
+
+
+@tool(groups=["browser"])
+async def browser_elements() -> ToolResult:
+    """
+    获取当前页面的可交互元素列表(纯文本,不截图)。
+    返回的 index 用于 browser_interact / browser_scroll 等操作。
+    """
+    return await browser_get_selector_map()
+
+
+@tool(groups=["browser"])
+async def browser_read(
+    mode: Literal["html", "find", "long"],
+    query: Optional[str] = None,
+    source: str = "page",
+    context: str = "",
+) -> ToolResult:
+    """
+    读取页面内容,三种模式:
+
+    - html: 获取当前页面的 HTML 源码(大页面会截断到 10000 字符)
+    - find: 在页面中查找文本。需要 query。
+    - long: 智能分页读取长内容(支持自动检测 PDF)。query 描述阅读目标。
+
+    Args:
+        mode: 读取模式
+        query: find 模式下的查找文本;long 模式下的阅读目标描述
+        source: long 模式的内容来源("page" 或文件路径),默认 "page"
+        context: long 模式的业务背景(可选)
+    """
+    if mode == "html":
+        return await browser_get_page_html()
+
+    elif mode == "find":
+        if not query:
+            return ToolResult(title="参数错误", output="", error="find 模式需要 query 参数")
+        return await browser_find_text(text=query)
+
+    elif mode == "long":
+        return await browser_read_long_content(
+            goal=query or "阅读页面内容",
+            source=source,
+            context=context,
+        )
+
+    else:
+        return ToolResult(title="未知 mode", output="", error=f"不支持的 mode: {mode}")
+
+
+@tool(groups=["browser"])
+async def browser_extract(
+    query: str,
+    extract_links: bool = False,
+    start_from_char: int = 0,
+) -> ToolResult:
+    """
+    使用 LLM 从当前页面提取结构化数据。
+
+    与 browser_read 不同,此工具会调用 LLM 分析页面内容并返回结构化结果。
+    适合"提取所有产品价格"、"总结文章要点"等需要理解语义的场景。
+
+    Args:
+        query: 提取指令(如"提取页面上所有产品名称和价格")
+        extract_links: 是否同时提取链接(默认 False)
+        start_from_char: 从第几个字符开始提取(用于分页处理大内容)
+    """
+    return await browser_extract_content(
+        query=query,
+        extract_links=extract_links,
+        start_from_char=start_from_char,
+    )
+
+
+@tool(groups=["browser"])
+async def browser_tabs(
+    action: Literal["switch", "close"],
+    tab_id: str = "",
+) -> ToolResult:
+    """
+    管理浏览器标签页。
+
+    Args:
+        action: "switch" 切换到指定标签页;"close" 关闭指定标签页
+        tab_id: 标签页 ID(4 字符)
+    """
+    if not tab_id:
+        return ToolResult(title="参数错误", output="", error="需要 tab_id 参数")
+
+    if action == "switch":
+        return await browser_switch_tab(tab_id=tab_id)
+    elif action == "close":
+        return await browser_close_tab(tab_id=tab_id)
+    else:
+        return ToolResult(title="未知 action", output="", error=f"不支持的 action: {action}")
+
+
+@tool(groups=["browser"])
+async def browser_cookies(
+    action: Literal["load", "export", "ensure_login"],
+    url: str = "",
+    name: str = "",
+    account: str = "",
+    cookie_type: str = "",
+    auto_navigate: bool = True,
+) -> ToolResult:
+    """
+    Cookie / 登录态管理:
+
+    - load: 从本地加载已保存的 cookie 并注入浏览器。需要 url(自动匹配 cookie 文件)。
+    - export: 导出当前浏览器 cookie 到本地。可选 name 和 account 标识。
+    - ensure_login: 检查登录状态,未登录时自动注入 cookie。需要 cookie_type 和 url。
+
+    Args:
+        action: 操作类型
+        url: 目标 URL(load / ensure_login 必填)
+        name: cookie 文件名(可选)
+        account: 账号名(export 时可选)
+        cookie_type: cookie 类型标识(ensure_login 必填)
+        auto_navigate: load 时找不到 cookie 是否自动导航到目标页面(默认 True)
+    """
+    if action == "load":
+        if not url:
+            return ToolResult(title="参数错误", output="", error="load 需要 url 参数")
+        return await browser_load_cookies(url=url, name=name, auto_navigate=auto_navigate)
+
+    elif action == "export":
+        return await browser_export_cookies(name=name, account=account)
+
+    elif action == "ensure_login":
+        if not cookie_type:
+            return ToolResult(title="参数错误", output="", error="ensure_login 需要 cookie_type 参数")
+        return await browser_ensure_login_with_cookies(
+            cookie_type=cookie_type,
+            url=url or "https://www.xiaohongshu.com",
+        )
+
+    else:
+        return ToolResult(title="未知 action", output="", error=f"不支持的 action: {action}")
+
+
+@tool(groups=["browser"])
+async def browser_wait(
+    seconds: Optional[int] = None,
+    user_message: Optional[str] = None,
+    timeout: int = 300,
+) -> ToolResult:
+    """
+    等待。两种模式:
+
+    - 传 seconds: 纯等待指定秒数(默认 3 秒)
+    - 传 user_message: 暂停并提示用户在浏览器中完成操作(如登录、验证码),
+      用户完成后按回车继续。timeout 控制最长等待时间。
+    - 两者都不传: 默认等待 3 秒
+
+    Args:
+        seconds: 等待秒数
+        user_message: 用户操作提示消息
+        timeout: user_message 模式的最长等待(秒),默认 300
+    """
+    if user_message:
+        return await browser_wait_for_user_action(message=user_message, timeout=timeout)
+    else:
+        return await browser_wait_impl(seconds=seconds or 3)
+
+
+@tool(groups=["browser"])
+async def browser_js(code: str) -> ToolResult:
+    """
+    在当前页面执行 JavaScript 代码。
+
+    Args:
+        code: JavaScript 代码字符串。返回值会被自动序列化。
+    """
+    return await browser_evaluate(code=code)
+
+
+@tool(groups=["browser"])
+async def browser_download(url: str, save_name: str = "") -> ToolResult:
+    """
+    下载指定 URL 的文件到本地。
+
+    Args:
+        url: 文件 URL
+        save_name: 保存文件名(可选,默认自动推断)
+    """
+    return await browser_download_direct_url(url=url, save_name=save_name or "download")
+
+
+# ============================================================
+# 导出(供外部使用)
+# ============================================================
+
+__all__ = [
+    # 会话管理(非 @tool)
+    'init_browser_session',
+    'get_browser_session',
+    'get_browser_live_url',
+    'cleanup_browser_session',
+    'kill_browser_session',
+
+    # 13 个 @tool 入口
+    'browser_navigate',
+    'browser_search',
+    'browser_back',
+    'browser_interact',
+    'browser_scroll',
+    'browser_screenshot',
+    'browser_elements',
+    'browser_read',
+    'browser_extract',
+    'browser_tabs',
+    'browser_cookies',
+    'browser_wait',
+    'browser_js',
+    'browser_download',
+]

+ 86 - 0
agent/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()
+
+
+

+ 29 - 0
agent/agent/tools/builtin/content/__init__.py

@@ -0,0 +1,29 @@
+"""
+内容工具族 —— 统一的跨平台内容搜索/详情/建议词 + 媒体处理 + 内容导入
+
+@tool 入口:
+  content_platforms  - 查看平台及参数
+  content_search     - 跨平台搜索
+  content_detail     - 查看详情
+  content_suggest    - 搜索建议词
+  extract_video_clip - YouTube 视频片段截取
+  import_content     - 内容批量导入 CMS
+"""
+
+from agent.tools.builtin.content.tools import (
+    content_platforms,
+    content_search,
+    content_detail,
+    content_suggest,
+)
+from agent.tools.builtin.content.media import extract_video_clip
+from agent.tools.builtin.content.ingestion import import_content
+
+__all__ = [
+    "content_platforms",
+    "content_search",
+    "content_detail",
+    "content_suggest",
+    "extract_video_clip",
+    "import_content",
+]

+ 5 - 0
agent/agent/tools/builtin/content/__main__.py

@@ -0,0 +1,5 @@
+"""CLI 入口:`python -m agent.tools.builtin.content <cmd> [...]`"""
+from agent.tools.builtin.content.tools import cli_main
+
+if __name__ == "__main__":
+    cli_main()

+ 175 - 0
agent/agent/tools/builtin/content/cache.py

@@ -0,0 +1,175 @@
+"""
+内容搜索缓存(磁盘持久化)
+
+搜索结果按 trace_id 隔离,同一 Agent session 内的 CLI 多次调用也能复用。
+文件格式:<cwd>/.cache/content_search/{trace_id}.json
+锚在调用方 CWD 的 .cache/ 下,每个项目隔离且 gitignore 友好。
+"""
+
+import json
+import os
+import time
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+# CWD 锚定 —— 每个调用项目有独立缓存目录,避免 /tmp 跨会话污染
+_CACHE_DIR = Path(os.getcwd()) / ".cache" / "content_search"
+_CACHE_DIR.mkdir(parents=True, exist_ok=True)
+_CACHE_TTL = 3600  # 1 小时过期
+
+
+def _cache_path(trace_id: str) -> Path:
+    safe_id = trace_id.replace("/", "_").replace("..", "_")
+    return _CACHE_DIR / f"{safe_id}.json"
+
+
+def _load_raw(trace_id: str) -> dict:
+    p = _cache_path(trace_id)
+    if not p.exists():
+        return {}
+    try:
+        data = json.loads(p.read_text("utf-8"))
+        # 检查过期
+        if time.time() - data.get("_ts", 0) > _CACHE_TTL:
+            p.unlink(missing_ok=True)
+            return {}
+        return data
+    except Exception:
+        return {}
+
+
+def _save_raw(trace_id: str, data: dict) -> None:
+    data["_ts"] = time.time()
+    try:
+        _cache_path(trace_id).write_text(
+            json.dumps(data, ensure_ascii=False), encoding="utf-8"
+        )
+    except Exception:
+        pass
+
+
+def save_search_results(
+    trace_id: str,
+    platform: str,
+    keyword: str,
+    posts: List[Dict[str, Any]],
+) -> None:
+    """保存搜索结果到磁盘缓存(保留历史搜索)"""
+    data = _load_raw(trace_id)
+    key = f"search:{platform}"
+
+    # 初始化或获取现有的历史记录结构
+    if key not in data or not isinstance(data[key], dict) or "history" not in data[key]:
+        # 兼容旧格式:如果是旧的单次搜索格式,转换为历史格式
+        old_data = data.get(key)
+        data[key] = {"history": [], "latest_index": -1}
+        if old_data and isinstance(old_data, dict) and "keyword" in old_data:
+            # 保留旧数据作为第一条历史记录
+            data[key]["history"].append({
+                "timestamp": time.time(),
+                "keyword": old_data["keyword"],
+                "posts": old_data.get("posts", []),
+            })
+            data[key]["latest_index"] = 0
+
+    # 添加新的搜索记录
+    data[key]["history"].append({
+        "timestamp": time.time(),
+        "keyword": keyword,
+        "posts": posts,
+    })
+    data[key]["latest_index"] = len(data[key]["history"]) - 1
+
+    _save_raw(trace_id, data)
+
+
+def get_cached_post(
+    trace_id: str,
+    platform: str,
+    index: int,
+) -> Optional[Dict[str, Any]]:
+    """按索引从缓存取一条完整记录(1-based),默认从最新搜索结果中获取"""
+    data = _load_raw(trace_id)
+    entry = data.get(f"search:{platform}")
+    if not entry:
+        return None
+
+    # 支持新格式(历史列表)和旧格式(单次搜索)
+    if isinstance(entry, dict) and "history" in entry:
+        # 新格式:从最新的搜索结果中获取
+        latest_idx = entry.get("latest_index", -1)
+        if latest_idx < 0 or latest_idx >= len(entry["history"]):
+            return None
+        posts = entry["history"][latest_idx].get("posts", [])
+    else:
+        # 旧格式:兼容处理
+        posts = entry.get("posts", [])
+
+    if 1 <= index <= len(posts):
+        return posts[index - 1]
+    return None
+
+
+def update_post_field(
+    trace_id: str,
+    platform: str,
+    content_id: str,
+    field: str,
+    value: Any,
+) -> bool:
+    """按 channel_content_id 定位缓存里的 post,更新其某个字段并写回磁盘。
+
+    返回是否实际写入(False 表示未找到匹配 post 或缓存不存在)。
+    在 detail() 异步拉到补充数据后调用,让数据持久化到 cache,
+    供后续 extract_sources 等离线流程读取。
+    """
+    if not trace_id or not content_id:
+        return False
+    data = _load_raw(trace_id)
+    entry = data.get(f"search:{platform}")
+    if not isinstance(entry, dict):
+        return False
+
+    if "history" in entry:
+        histories = entry.get("history", [])
+    else:
+        histories = [entry]
+
+    cid_str = str(content_id)
+    updated = False
+    # Match by channel_content_id (primary, used by X / aigc-channel platforms)
+    # or video_id (fallback, used by YouTube whose post id field is named differently).
+    for hist in histories:
+        for post in hist.get("posts", []) or []:
+            if not isinstance(post, dict):
+                continue
+            if (
+                str(post.get("channel_content_id", "")) == cid_str
+                or str(post.get("video_id", "")) == cid_str
+            ):
+                post[field] = value
+                updated = True
+
+    if updated:
+        _save_raw(trace_id, data)
+    return updated
+
+
+def get_cached_search_info(trace_id: str, platform: str) -> Optional[Dict[str, Any]]:
+    """获取缓存的搜索信息(keyword + 总条数),用于错误提示"""
+    data = _load_raw(trace_id)
+    entry = data.get(f"search:{platform}")
+    if not entry:
+        return None
+
+    # 支持新格式(历史列表)和旧格式(单次搜索)
+    if isinstance(entry, dict) and "history" in entry:
+        # 新格式:返回最新搜索的信息
+        latest_idx = entry.get("latest_index", -1)
+        if latest_idx < 0 or latest_idx >= len(entry["history"]):
+            return None
+        latest = entry["history"][latest_idx]
+        return {"keyword": latest.get("keyword"), "total": len(latest.get("posts", []))}
+    else:
+        # 旧格式:兼容处理
+        return {"keyword": entry.get("keyword"), "total": len(entry.get("posts", []))}

+ 46 - 0
agent/agent/tools/builtin/content/ingestion.py

@@ -0,0 +1,46 @@
+"""
+内容导入工具
+
+将文章链接批量导入 AIGC CMS 系统。
+"""
+
+import json
+from typing import Any, Dict, List
+
+import httpx
+
+from agent.tools import tool, ToolResult
+
+AIGC_BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
+DEFAULT_TIMEOUT = 60.0
+
+
+@tool(groups=["content"])
+async def import_content(plan_name: str, content_data: List[Dict[str, Any]]) -> ToolResult:
+    """
+    导入长文内容到 CMS(微信公众号、小红书、抖音等通用链接)。
+
+    Args:
+        plan_name: 计划名称
+        content_data: 内容数据列表,每项包含 channel、content_link、title 等字段
+    """
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(
+                f"{AIGC_BASE_URL}/weixin/auto_insert",
+                json={"plan_name": plan_name, "data": content_data},
+            )
+            response.raise_for_status()
+            data = response.json()
+
+        if data.get("code") == 0:
+            result_data = data.get("data", {})
+            return ToolResult(
+                title=f"内容导入: {plan_name}",
+                output=json.dumps(result_data, ensure_ascii=False, indent=2),
+                long_term_memory=f"Imported {len(content_data)} items to plan '{plan_name}'",
+            )
+        return ToolResult(title="导入失败", output="", error=f"导入失败: {data.get('msg', '未知错误')}")
+
+    except Exception as e:
+        return ToolResult(title="内容导入异常", output="", error=str(e))

+ 114 - 0
agent/agent/tools/builtin/content/media.py

@@ -0,0 +1,114 @@
+"""
+媒体处理工具
+
+- extract_video_clip: 从已下载的 YouTube 视频中截取片段
+- download_youtube_video / parse_srt_to_outline: 供 YouTube 详情调用的辅助函数
+"""
+
+import asyncio
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from agent.tools import tool, ToolResult
+
+VIDEO_DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "youtube_videos"
+VIDEO_DOWNLOAD_DIR.mkdir(exist_ok=True)
+
+
+# ── 辅助函数(供 platforms/youtube.py 调用) ──
+
+def download_youtube_video(video_id: str) -> Optional[str]:
+    """使用 yt-dlp 下载 YouTube 视频,返回文件路径"""
+    try:
+        output_path = VIDEO_DOWNLOAD_DIR / f"{video_id}.mp4"
+        if output_path.exists():
+            return str(output_path)
+
+        cmd = [
+            "yt-dlp",
+            "-f", "best[ext=mp4]",
+            "-o", str(output_path),
+            f"https://www.youtube.com/watch?v={video_id}",
+        ]
+        result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
+        if result.returncode == 0 and output_path.exists():
+            return str(output_path)
+        return None
+    except Exception:
+        return None
+
+
+def parse_srt_to_outline(srt_content: str) -> List[Dict[str, str]]:
+    """解析 SRT 字幕,生成带时间戳的大纲"""
+    if not srt_content:
+        return []
+    outline = []
+    blocks = srt_content.strip().split("\n\n")
+    for block in blocks:
+        lines = block.strip().split("\n")
+        if len(lines) >= 3:
+            timestamp_line = lines[1]
+            if "-->" in timestamp_line:
+                start_time = timestamp_line.split("-->")[0].strip()
+                text = " ".join(lines[2:])
+                outline.append({"timestamp": start_time, "text": text})
+    return outline
+
+
+# ── @tool ──
+
+@tool(groups=["content"])
+async def extract_video_clip(
+    video_id: str,
+    start_time: str,
+    end_time: str,
+    output_name: Optional[str] = None,
+) -> ToolResult:
+    """
+    从已下载的 YouTube 视频中截取指定时间段的片段。
+
+    必须先通过 content_detail(platform="youtube", index=..., extras={"download_video": true})
+    下载视频后才能使用。
+
+    Args:
+        video_id: YouTube 视频 ID
+        start_time: 开始时间,格式 HH:MM:SS 或 MM:SS
+        end_time: 结束时间,格式 HH:MM:SS 或 MM:SS
+        output_name: 输出文件名(可选,自动生成)
+    """
+    source_video = VIDEO_DOWNLOAD_DIR / f"{video_id}.mp4"
+    if not source_video.exists():
+        return ToolResult(
+            title="视频截取失败",
+            output="",
+            error="源视频不存在,请先使用 content_detail(platform='youtube', ..., extras={'download_video': true}) 下载",
+        )
+
+    if not output_name:
+        output_name = f"{video_id}_clip_{start_time.replace(':', '-')}_{end_time.replace(':', '-')}.mp4"
+    output_path = VIDEO_DOWNLOAD_DIR / output_name
+
+    cmd = ["ffmpeg", "-i", str(source_video), "-ss", start_time, "-to", end_time, "-c", "copy", "-y", str(output_path)]
+
+    try:
+        result = await asyncio.to_thread(subprocess.run, cmd, capture_output=True, text=True, timeout=60)
+    except subprocess.TimeoutExpired:
+        return ToolResult(title="视频截取超时", output="", error="ffmpeg 超时(60秒)")
+
+    if result.returncode == 0 and output_path.exists():
+        file_size = output_path.stat().st_size / (1024 * 1024)
+        return ToolResult(
+            title=f"视频片段: {start_time} - {end_time}",
+            output=json.dumps({
+                "video_id": video_id,
+                "clip_path": str(output_path),
+                "start_time": start_time,
+                "end_time": end_time,
+                "file_size_mb": round(file_size, 2),
+            }, ensure_ascii=False, indent=2),
+            long_term_memory=f"Extracted clip from {video_id}: {start_time}-{end_time}",
+        )
+    return ToolResult(title="视频截取失败", output="", error=f"ffmpeg 执行失败: {result.stderr}")

+ 1 - 0
agent/agent/tools/builtin/content/platforms/__init__.py

@@ -0,0 +1 @@
+"""内容平台实现模块"""

+ 450 - 0
agent/agent/tools/builtin/content/platforms/aigc_channel.py

@@ -0,0 +1,450 @@
+"""
+AIGC-Channel 平台实现(9 个中文平台)
+
+后端:aigc-channel.aiddit.com
+平台:xhs / gzh / sph / github / toutiao / douyin / bili / zhihu / weibo
+"""
+
+import json
+import re
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+from agent.tools.models import ToolResult
+from agent.tools.utils.image import build_image_grid, encode_base64, load_images
+from agent.tools.builtin.content.registry import (
+    PlatformDef, ParamSpec, register_platform,
+)
+
+BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
+DEFAULT_TIMEOUT = 60.0
+
+# aigc-channel returns search-highlighted titles like
+# '<em class="highlight">关键词</em>'. Strip before any rendering / scoring use.
+_HTML_TAG_RE = re.compile(r"<[^>]+>")
+
+
+def _strip_html(text: Optional[str]) -> str:
+    if not text:
+        return ""
+    return _HTML_TAG_RE.sub("", text)
+
+
+_SPH_TITLE_MAX = 20  # sph normalized title 截断字符数
+
+
+def _normalize_sph_post(post: Dict[str, Any]) -> None:
+    """In-place: 视频号没有独立 title,后端把 caption 塞进 title 字段而 body_text 留空。
+
+    把整段 title 搬到 body_text,title 取剥 HTML 后前 20 字 + '...' 作为短摘要。
+    幂等:如果 body_text 已经有内容则不动,避免重复迁移或覆盖;title 已经 <=20 字
+    也不强加省略号。
+    """
+    if not isinstance(post, dict):
+        return
+    raw_title = post.get("title") or ""
+    body = post.get("body_text") or ""
+    body = body.strip() if isinstance(body, str) else ""
+    if not raw_title or body:
+        return
+    clean = _strip_html(raw_title).strip()
+    if not clean:
+        return
+    post["body_text"] = clean
+    if len(clean) > _SPH_TITLE_MAX:
+        post["title"] = clean[:_SPH_TITLE_MAX] + "..."
+    else:
+        post["title"] = clean
+
+
+# ── 平台注册 ──
+
+_XHS_SEARCH_PARAMS = {
+    "sort_type": ParamSpec(
+        values=["综合排序", "最新发布", "最多点赞"],
+        default="综合排序",
+    ),
+    "publish_time": ParamSpec(
+        values=["不限", "近1天", "近7天", "近30天"],
+        default="不限",
+    ),
+    "content_type": ParamSpec(
+        values=["不限", "图文", "视频", "文章"],
+        default="不限",
+    ),
+    "filter_note_range": ParamSpec(
+        values=["不限", "1分钟以内", "1-5分钟", "5分钟以上"],
+        default="不限",
+        note="仅视频内容生效",
+    ),
+}
+
+_COMMON_CONTENT_TYPE = {
+    "content_type": ParamSpec(
+        values=["视频", "图文"],
+        default="",
+        note="留空不限",
+    ),
+}
+
+# 9 个中文平台定义
+_AIGC_PLATFORMS = [
+    PlatformDef(id="xhs",     name="小红书",   aliases=["RED", "xiaohongshu"], search_params=_XHS_SEARCH_PARAMS, supports_suggest=True),
+    PlatformDef(id="gzh",     name="公众号",   aliases=["微信公众号", "wechat"], search_params=_COMMON_CONTENT_TYPE),
+    PlatformDef(id="sph",     name="视频号",   aliases=["微信视频号"], search_params=_COMMON_CONTENT_TYPE),
+    PlatformDef(id="github",  name="GitHub",   aliases=["gh"], search_params=_COMMON_CONTENT_TYPE),
+    PlatformDef(id="toutiao", name="头条",     aliases=["今日头条", "toutiao"], search_params=_COMMON_CONTENT_TYPE, supports_suggest=True),
+    PlatformDef(id="douyin",  name="抖音",     aliases=["TikTok"], search_params=_COMMON_CONTENT_TYPE, supports_suggest=True),
+    PlatformDef(id="bili",    name="B站",      aliases=["哔哩哔哩", "bilibili"], search_params=_COMMON_CONTENT_TYPE, supports_suggest=True),
+    PlatformDef(id="zhihu",   name="知乎",     aliases=[], search_params=_COMMON_CONTENT_TYPE, supports_suggest=True),
+    PlatformDef(id="weibo",   name="微博",     aliases=["sina"], search_params=_COMMON_CONTENT_TYPE),
+]
+
+# suggest API 额外支持 wx(微信搜一搜),但它不是搜索平台
+_SUGGEST_ONLY_CHANNELS = {"wx": "微信"}
+
+
+# ── 搜索实现 ──
+
+async def search(
+    platform_id: str,
+    keyword: str,
+    max_count: int = 20,
+    cursor: str = "",
+    extras: Optional[Dict[str, Any]] = None,
+) -> ToolResult:
+    """AIGC-Channel 统一搜索"""
+    extras = extras or {}
+
+    if platform_id == "xhs":
+        payload = {
+            "type": platform_id,
+            "keyword": keyword,
+            "cursor": cursor,
+            "content_type": extras.get("content_type", "不限"),
+            "sort_type": extras.get("sort_type", "综合排序"),
+            "publish_time": extras.get("publish_time", "不限"),
+            "filter_note_range": extras.get("filter_note_range", "不限"),
+        }
+    else:
+        payload = {
+            "type": platform_id,
+            "keyword": keyword,
+            "cursor": cursor or "0",
+            "max_count": max_count,
+            "content_type": extras.get("content_type", ""),
+        }
+
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(
+                f"{BASE_URL}/data",
+                json=payload,
+                headers={"Content-Type": "application/json"},
+            )
+            response.raise_for_status()
+            data = response.json()
+    except httpx.HTTPStatusError as e:
+        return ToolResult(title="搜索失败", output="", error=f"HTTP {e.response.status_code}: {e.response.text}")
+    except Exception as e:
+        return ToolResult(title="搜索失败", output="", error=str(e))
+
+    posts = data.get("data", [])
+
+    # sph 字段 normalization:title 太长(后端把 caption 塞进 title),
+    # 把它搬到 body_text,title 取前 20 字。在评分 / summary / cache 之前做。
+    if platform_id == "sph":
+        for p in posts:
+            _normalize_sph_post(p)
+
+    # 构建概览摘要
+    summary_list = []
+
+    # 动态导入评价模块
+    try:
+        from examples.process_pipeline.script.evaluate_source_quality import SourceQualityEvaluator
+        evaluator = SourceQualityEvaluator()
+    except ImportError:
+        evaluator = None
+
+    # 视频帖在评分前先并发探测 mp4 duration(HTTP Range,不下载视频流),
+    # 让 evaluator 用真实时长替代 body 长度作为内容信号。
+    if evaluator and posts:
+        try:
+            from agent.tools.builtin.content.transcription import probe_durations_for_posts
+            await probe_durations_for_posts(platform_id, posts, concurrency=8)
+        except Exception as e:
+            import logging
+            logging.getLogger(__name__).info("duration probe failed: %s", e)
+
+    for idx, post in enumerate(posts, 1):
+        body = post.get("body_text", "") or ""
+        title = post.get("title") or body[:20] or ""
+        
+        score_info = {}
+        if evaluator:
+            try:
+                eval_res = evaluator.evaluate_post(post)
+                score_info = {
+                    "quality_score": eval_res["total_score"],
+                    "quality_grade": eval_res["grade"]
+                }
+                post["_quality_score"] = eval_res["total_score"]
+                post["_quality_grade"] = eval_res["grade"]
+            except Exception:
+                pass
+                
+        summary_item = {
+            "index": idx,
+            "title": title,
+            "body_text": body[:100] + ("..." if len(body) > 100 else ""),
+            "like_count": post.get("like_count"),
+            "comment_count": post.get("comment_count"),
+            "channel": post.get("channel"),
+            "link": post.get("link"),
+            "content_type": post.get("content_type"),
+        }
+        summary_item.update(score_info)
+        summary_list.append(summary_item)
+
+    # 封面拼图
+    images = []
+    try:
+        collage_obj = await _build_collage(posts)
+        if collage_obj:
+            images.append(collage_obj)
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning("Error generating collage: %s", e)
+
+    return ToolResult(
+        title=f"搜索: {keyword} ({platform_id})",
+        output=json.dumps({"data": summary_list}, ensure_ascii=False, indent=2),
+        long_term_memory=f"Searched '{keyword}' on {platform_id}, {len(posts)} results. Use content_detail to view full details.",
+        images=images,
+        metadata={"posts": posts},  # 完整数据传给上层缓存
+    )
+
+
+# ── 详情实现(从缓存获取,不需要额外 HTTP) ──
+
+MAX_DETAIL_IMAGES = 10  # detail 中保留的图片总数上限(含拼图)
+KEEP_INDIVIDUAL = 8     # 单张图片保留数量;剩余图片合并为 1 张拼图
+
+
+async def _build_images_collage(urls: List[str]) -> Optional[Dict[str, Any]]:
+    """将一组图片 URL 拼成单张网格图"""
+    if not urls:
+        return None
+
+    loaded = await load_images(urls)
+    valid_images = [img for (_, img) in loaded if img is not None]
+    if not valid_images:
+        return None
+
+    grid = build_image_grid(images=valid_images, labels=None)
+    import io
+    buf = io.BytesIO()
+    grid.save(buf, format="PNG")
+    img_bytes = buf.getvalue()
+
+    try:
+        from agent.tools.builtin.file.image_cdn import _upload_bytes_to_oss
+        import hashlib
+
+        md5_hash = hashlib.md5(img_bytes).hexdigest()[:12]
+        filename = f"collage_detail_{md5_hash}.png"
+        cdn_url = await _upload_bytes_to_oss(img_bytes, filename)
+        return {"type": "url", "url": cdn_url}
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning("Failed to upload detail collage to CDN: %s", e)
+        b64, _ = encode_base64(grid, format="PNG")
+        return {"type": "base64", "media_type": "image/png", "data": b64}
+
+
+async def detail(
+    post: Dict[str, Any],
+    extras: Optional[Dict[str, Any]] = None,
+    platform_id: str = "",
+) -> ToolResult:
+    """返回单条帖子的完整内容;sph/douyin 视频会通过 Deepgram 自动转写。"""
+    title = post.get("title") or post.get("body_text", "")[:30] or "无标题"
+
+    img_urls = [u for u in post.get("images", []) if u]
+    images = []
+    if len(img_urls) > MAX_DETAIL_IMAGES:
+        # 保留前 KEEP_INDIVIDUAL 张原图,剩余拼成 1 张网格图
+        for u in img_urls[:KEEP_INDIVIDUAL]:
+            images.append({"type": "url", "url": u})
+        collage = await _build_images_collage(img_urls[KEEP_INDIVIDUAL:])
+        if collage:
+            images.append(collage)
+    else:
+        for u in img_urls:
+            images.append({"type": "url", "url": u})
+
+    # 视频字幕:任何 aigc-channel 平台只要 post.videos 字段非空就触发 Deepgram 转写。
+    # 下载策略在 transcription._download_video 里按 platform 分支,未指定的平台走
+    # "yt-dlp on page URL → httpx direct" 两步兜底。
+    #
+    # 三态语义(跟 extract_sources._needs_transcribe 对齐):
+    #   字段缺失     → 没尝试过,跑 Deepgram
+    #   字段 = ""    → 尝试过但失败,跳过(保护 Deepgram 额度)
+    #   字段 = text  → 已成功,复用
+    extras_d = extras or {}
+    has_video = bool(post.get("videos"))
+    field_present = "video_transcript" in post
+    transcript_text: Optional[str] = post.get("video_transcript") or None
+
+    if (
+        not field_present
+        and has_video
+        and extras_d.get("include_transcript", True)
+    ):
+        from agent.tools.builtin.content.transcription import transcribe_video_from_post
+        transcribe_error: Optional[str] = None
+        try:
+            transcript_text = await transcribe_video_from_post(platform_id, post)
+        except Exception as e:
+            transcript_text = None
+            transcribe_error = f"{type(e).__name__}: {e}"
+            import logging as _logging
+            _logging.getLogger(__name__).warning(
+                "transcribe_video_from_post raised for %s: %s", platform_id, e
+            )
+
+        # 三态写回:成功 = text;失败/None = "" 作为"已尝试"标记,下次 cache hit 直接短路。
+        final_value = transcript_text or ""
+        post["video_transcript"] = final_value
+        if not final_value:
+            # 失败原因暴露到 output JSON,方便 agent/用户判断要不要重试或换平台
+            post["_transcribe_error"] = (
+                transcribe_error
+                or "transcribe returned None (下载/抽音/Deepgram 任一步失败,见 logger.warning)"
+            )
+
+        # cache writeback 不再以"成功"为前提:失败的 "" 也写回,让下次 cache hit 短路掉
+        import os as _os
+        from agent.tools.builtin.content import cache as _cache
+        trace_id = extras_d.get("__trace_id__") or _os.getenv("TRACE_ID")
+        content_id = (
+            post.get("channel_content_id")
+            or post.get("content_id")
+            or post.get("video_id")
+        )
+        if trace_id and content_id:
+            _cache.update_post_field(
+                trace_id, platform_id, content_id, "video_transcript", final_value
+            )
+
+    # transcript already embedded as post["video_transcript"] inside the JSON dump;
+    # no need to repeat as a separate section.
+    output_text = json.dumps(post, ensure_ascii=False, indent=2)
+
+    memory_suffix = " +transcript" if transcript_text else ""
+    return ToolResult(
+        title=f"详情: {title}",
+        output=output_text,
+        long_term_memory=f"Viewed detail: {title}{memory_suffix}",
+        images=images,
+    )
+
+
+# ── 建议词实现 ──
+
+async def suggest(channel: str, keyword: str) -> ToolResult:
+    """获取搜索建议词"""
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(
+                f"{BASE_URL}/suggest",
+                json={"type": channel, "keyword": keyword},
+                headers={"Content-Type": "application/json"},
+            )
+            response.raise_for_status()
+            data = response.json()
+    except Exception as e:
+        return ToolResult(title="建议词获取失败", output="", error=str(e))
+
+    suggestion_count = sum(len(item.get("list", [])) for item in data.get("data", []))
+    return ToolResult(
+        title=f"建议词: {keyword} ({channel})",
+        output=json.dumps(data, ensure_ascii=False, indent=2),
+        long_term_memory=f"Got {suggestion_count} suggestions for '{keyword}' on {channel}",
+    )
+
+
+# ── 拼图辅助 ──
+
+async def _build_collage(posts: List[Dict[str, Any]]) -> Optional[str]:
+    """封面图网格拼图"""
+    urls, titles = [], []
+    for post in posts:
+        imgs = post.get("images", [])
+        if imgs and imgs[0]:
+            urls.append(imgs[0])
+            base_title = _strip_html(post.get("title", ""))
+            score = post.get("_quality_score")
+            if score is not None:
+                title_with_score = f"[{score}分] {base_title}"
+            else:
+                title_with_score = base_title
+            titles.append(title_with_score)
+
+    if not urls:
+        return None
+
+    loaded = await load_images(urls)
+    valid_images, valid_labels = [], []
+    for (_, img), title in zip(loaded, titles):
+        if img is not None:
+            valid_images.append(img)
+            valid_labels.append(title)
+
+    if not valid_images:
+        return None
+
+    grid = build_image_grid(images=valid_images, labels=valid_labels)
+    import io
+    buf = io.BytesIO()
+    grid.save(buf, format="PNG")
+    img_bytes = buf.getvalue()
+    
+    # 尝试上传到 CDN,替换冗长的 base64
+    try:
+        from agent.tools.builtin.file.image_cdn import _upload_bytes_to_oss
+        import hashlib
+        
+        md5_hash = hashlib.md5(img_bytes).hexdigest()[:12]
+        filename = f"collage_search_{md5_hash}.png"
+        cdn_url = await _upload_bytes_to_oss(img_bytes, filename)
+        return {"type": "url", "url": cdn_url}
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning("Failed to upload collage to CDN: %s", e)
+        # 降级:还是用 base64 但可能会超长
+        b64, _ = encode_base64(grid, format="PNG")
+        return {"type": "base64", "media_type": "image/png", "data": b64}
+
+
+# ── 注册所有 AIGC 平台 ──
+
+def _register_all():
+    for p in _AIGC_PLATFORMS:
+        p.search_impl = search
+        # Bind each platform's id into detail_impl so the shared detail() knows
+        # whether to trigger video transcription (only for sph/douyin).
+        p.detail_impl = (
+            lambda post, extras, _pid=p.id: detail(post, extras, _pid)  # noqa: B023 (default-arg captures pid)
+        )
+        if p.supports_suggest:
+            p.suggest_impl = suggest
+            p.suggest_channels = [p.id]
+        register_platform(p)
+
+    # wx 只有 suggest,没有搜索
+    # suggest 调用时 channel 传 "wx",但不注册为独立平台
+
+_register_all()

+ 315 - 0
agent/agent/tools/builtin/content/platforms/x.py

@@ -0,0 +1,315 @@
+"""
+X (Twitter) 平台实现
+
+后端:crawler.aiddit.com/crawler/x
+"""
+
+import json
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+from agent.tools.models import ToolResult
+from agent.tools.utils.image import build_image_grid, encode_base64, load_images
+from agent.tools.builtin.content.registry import PlatformDef, register_platform
+
+CRAWLER_URL = "http://crawler.aiddit.com/crawler/x/keyword"
+COMMENT_URL = "http://crawler.aiddit.com/crawler/x/comment"
+DEFAULT_TIMEOUT = 60.0
+AUTHOR_COMMENT_TOP_N = 10
+
+
+async def search(
+    platform_id: str,
+    keyword: str,
+    max_count: int = 20,
+    cursor: str = "",
+    extras: Optional[Dict[str, Any]] = None,
+) -> ToolResult:
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(CRAWLER_URL, json={"keyword": keyword})
+            response.raise_for_status()
+            data = response.json()
+
+        if data.get("code") != 0:
+            return ToolResult(title="X 搜索失败", output="", error=data.get("msg", "未知错误"))
+
+        result_data = data.get("data", {})
+        tweets = result_data.get("data", []) if isinstance(result_data, dict) else []
+
+        # 动态导入评价模块
+        try:
+            from examples.process_pipeline.script.evaluate_source_quality import SourceQualityEvaluator
+            evaluator = SourceQualityEvaluator()
+        except ImportError:
+            evaluator = None
+
+        # 视频帖在评分前先并发探测 mp4 duration(HTTP Range,不下载视频流),
+        # 让 evaluator 用真实时长替代 body 长度作为内容信号。
+        if evaluator and tweets:
+            try:
+                from agent.tools.builtin.content.transcription import probe_durations_for_posts
+                await probe_durations_for_posts("x", tweets[:max_count], concurrency=8)
+            except Exception as e:
+                import logging
+                logging.getLogger(__name__).info("duration probe failed for x: %s", e)
+
+        summary_list = []
+        for idx, tweet in enumerate(tweets[:max_count], 1):
+            text = tweet.get("body_text", "")
+
+            score_info = {}
+            if evaluator:
+                try:
+                    eval_res = evaluator.evaluate_post(tweet)
+                    score_info = {
+                        "quality_score": eval_res["total_score"],
+                        "quality_grade": eval_res["grade"]
+                    }
+                    tweet["_quality_score"] = eval_res["total_score"]
+                except Exception:
+                    pass
+
+            summary_item = {
+                "index": idx,
+                "author": tweet.get("channel_account_name", ""),
+                "body_text": text[:100] + ("..." if len(text) > 100 else ""),
+                "like_count": tweet.get("like_count"),
+                "comment_count": tweet.get("comment_count"),
+                "link": tweet.get("link"),
+            }
+            summary_item.update(score_info)
+            summary_list.append(summary_item)
+
+        # 拼图
+        images = []
+        collage_obj = await _build_tweet_collage(tweets[:max_count])
+        if collage_obj:
+            images.append(collage_obj)
+
+        return ToolResult(
+            title=f"X: {keyword}",
+            output=json.dumps({"data": summary_list}, ensure_ascii=False, indent=2),
+            long_term_memory=f"Searched X for '{keyword}', {len(tweets)} results.",
+            images=images,
+            metadata={"posts": tweets[:max_count]},
+        )
+
+    except Exception as e:
+        return ToolResult(title="X 搜索异常", output="", error=str(e))
+
+
+MAX_DETAIL_IMAGES = 10
+KEEP_INDIVIDUAL = 8
+
+
+async def _build_images_collage(urls: List[str]) -> Optional[Dict[str, Any]]:
+    """将一组图片 URL 拼成单张网格图"""
+    if not urls:
+        return None
+
+    loaded = await load_images(urls)
+    valid_images = [img for (_, img) in loaded if img is not None]
+    if not valid_images:
+        return None
+
+    grid = build_image_grid(images=valid_images, labels=None)
+    import io
+    buf = io.BytesIO()
+    grid.save(buf, format="PNG")
+    img_bytes = buf.getvalue()
+
+    try:
+        from agent.tools.builtin.file.image_cdn import _upload_bytes_to_oss
+        import hashlib
+
+        md5_hash = hashlib.md5(img_bytes).hexdigest()[:12]
+        filename = f"x_detail_collage_{md5_hash}.png"
+        cdn_url = await _upload_bytes_to_oss(img_bytes, filename)
+        return {"type": "url", "url": cdn_url}
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning("Failed to upload x detail collage to CDN: %s", e)
+        b64, _ = encode_base64(grid, format="PNG")
+        return {"type": "base64", "media_type": "image/png", "data": b64}
+
+
+async def _fetch_author_comments(content_id: str, author_id: str) -> List[Dict[str, Any]]:
+    """拉取该推文评论,仅保留原作者本人发布的回复,按点赞数降序取 Top N。"""
+    if not content_id or not author_id:
+        return []
+
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            resp = await client.post(COMMENT_URL, json={"content_id": content_id})
+            resp.raise_for_status()
+            payload = resp.json()
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning("Failed to fetch x comments for %s: %s", content_id, e)
+        return []
+
+    if payload.get("code") != 0:
+        return []
+
+    inner = payload.get("data", {})
+    raw_comments = inner.get("data", []) if isinstance(inner, dict) else []
+
+    author_id_str = str(author_id)
+    author_comments = []
+    for c in raw_comments:
+        author = c.get("author") or {}
+        if str(author.get("rest_id", "")) != author_id_str:
+            continue
+        author_comments.append({
+            "text": c.get("display_text") or c.get("text", ""),
+            "likes": c.get("likes", 0) or 0,
+            "replies": c.get("replies", 0) or 0,
+            "created_at": c.get("created_at", ""),
+        })
+
+    author_comments.sort(key=lambda x: x["likes"], reverse=True)
+    return author_comments[:AUTHOR_COMMENT_TOP_N]
+
+
+async def detail(post: Dict[str, Any], extras: Optional[Dict[str, Any]] = None) -> ToolResult:
+    """X 的详情直接从缓存的搜索结果取完整数据,并补拉作者本人的热门补充评论。"""
+    author = post.get("channel_account_name", "")
+    author_id = post.get("channel_account_id", "")
+    content_id = post.get("channel_content_id", "")
+    text = post.get("body_text", "")[:30]
+
+    img_urls = []
+    for img_item in post.get("image_url_list", []):
+        url = img_item.get("image_url") if isinstance(img_item, dict) else img_item
+        if url:
+            img_urls.append(url)
+
+    all_images = []
+    if len(img_urls) > MAX_DETAIL_IMAGES:
+        for u in img_urls[:KEEP_INDIVIDUAL]:
+            all_images.append({"type": "url", "url": u})
+        collage = await _build_images_collage(img_urls[KEEP_INDIVIDUAL:])
+        if collage:
+            all_images.append(collage)
+    else:
+        for u in img_urls:
+            all_images.append({"type": "url", "url": u})
+
+    author_comments = await _fetch_author_comments(content_id, author_id)
+
+    extras_d = extras or {}
+    trace_id = extras_d.get("__trace_id__")
+    if not trace_id:
+        import os as _os
+        trace_id = _os.getenv("TRACE_ID")
+
+    # 把作者评论写回 cache,让下游离线流程(如 extract_sources)也能拿到
+    if author_comments:
+        from agent.tools.builtin.content import cache as _cache
+        if trace_id and content_id:
+            _cache.update_post_field(trace_id, "x", content_id, "author_comments", author_comments)
+
+    # 视频字幕:检测到 video_url_list 时通过 Deepgram 转写 (default on, opt-out via extras)
+    transcript_text: Optional[str] = post.get("video_transcript")  # cache hit reuse
+    if not transcript_text and extras_d.get("include_transcript", True):
+        from agent.tools.builtin.content.transcription import transcribe_video_from_post
+        transcript_text = await transcribe_video_from_post("x", post)
+        if transcript_text:
+            post["video_transcript"] = transcript_text
+            from agent.tools.builtin.content import cache as _cache
+            if trace_id and content_id:
+                _cache.update_post_field(trace_id, "x", content_id, "video_transcript", transcript_text)
+
+    output_json = json.dumps(post, ensure_ascii=False, indent=2)
+
+    sections = [output_json]
+    if author_comments:
+        lines = [f"=== 作者 @{author} 在评论区的补充(按点赞 Top {len(author_comments)}) ==="]
+        for i, c in enumerate(author_comments, 1):
+            lines.append(f"{i}. [赞{c['likes']} · 回复{c['replies']}] {c['text']}")
+        sections.append("\n".join(lines))
+    # transcript already embedded as post["video_transcript"] inside output_json above;
+    # no need to repeat as a separate section.
+    output_text = "\n\n".join(sections)
+
+    memory_extras = []
+    if author_comments:
+        memory_extras.append(f"{len(author_comments)} author replies")
+    if transcript_text:
+        memory_extras.append("+transcript")
+    memory_suffix = " + " + ", ".join(memory_extras) if memory_extras else ""
+    return ToolResult(
+        title=f"X 详情: @{author}",
+        output=output_text,
+        long_term_memory=f"Viewed X post by @{author}: {text}{memory_suffix}",
+        images=all_images,
+    )
+
+
+async def _build_tweet_collage(tweets: List[Dict[str, Any]]) -> Optional[str]:
+    urls, titles = [], []
+    for tweet in tweets:
+        thumb = None
+        for img_item in tweet.get("image_url_list", []):
+            url = img_item.get("image_url") if isinstance(img_item, dict) else img_item
+            if url:
+                thumb = url
+                break
+        if not thumb:
+            thumb = tweet.get("cover_url")
+        if thumb:
+            urls.append(thumb)
+            base_title = f"@{tweet.get('channel_account_name', '')}"
+            score = tweet.get("_quality_score")
+            if score is not None:
+                title_with_score = f"[{score}分] {base_title}"
+            else:
+                title_with_score = base_title
+            titles.append(title_with_score)
+
+    if not urls:
+        return None
+
+    loaded = await load_images(urls)
+    valid_images, valid_labels = [], []
+    for (_, img), title in zip(loaded, titles):
+        if img is not None:
+            valid_images.append(img)
+            valid_labels.append(title)
+
+    if not valid_images:
+        return None
+
+    grid = build_image_grid(images=valid_images, labels=valid_labels)
+    import io
+    buf = io.BytesIO()
+    grid.save(buf, format="PNG")
+    img_bytes = buf.getvalue()
+    
+    try:
+        from agent.tools.builtin.file.image_cdn import _upload_bytes_to_oss
+        import hashlib
+        
+        md5_hash = hashlib.md5(img_bytes).hexdigest()[:12]
+        filename = f"x_collage_{md5_hash}.png"
+        cdn_url = await _upload_bytes_to_oss(img_bytes, filename)
+        return {"type": "url", "url": cdn_url}
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning("Failed to upload x collage to CDN: %s", e)
+        b64, _ = encode_base64(grid, format="PNG")
+        return {"type": "base64", "media_type": "image/png", "data": b64}
+
+
+# ── 注册 ──
+
+_X = PlatformDef(
+    id="x",
+    name="X (Twitter)",
+    aliases=["twitter", "推特"],
+)
+_X.search_impl = search
+_X.detail_impl = detail
+register_platform(_X)

+ 463 - 0
agent/agent/tools/builtin/content/platforms/youtube.py

@@ -0,0 +1,463 @@
+"""
+YouTube 平台实现
+
+后端:crawler.aiddit.com/crawler/youtube
+"""
+
+import json
+import re
+import time
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+from agent.tools.models import ToolResult
+from agent.tools.utils.image import build_image_grid, encode_base64, load_images
+from agent.tools.builtin.content.registry import (
+    PlatformDef, ParamSpec, register_platform,
+)
+
+CRAWLER_BASE_URL = "http://crawler.aiddit.com/crawler"
+DEFAULT_TIMEOUT = 60.0
+
+
+# ── 字段 normalization:YouTube 后端字段名跟 evaluator/其他平台不一致 ──
+#
+# evaluator 期待的字段     | YouTube 后端返回的字段
+# ------------------------+---------------------------
+# channel_content_id      | video_id
+# body_text               | description_snippet
+# like_count (int)        | view_count ("130,461 views")
+# publish_timestamp (ms)  | published_time ("6 months ago")
+# link                    | url
+# duration_sec (float)    | duration ("6:15" or "1:23:45")
+# images (list[str])      | thumbnails (list[dict])
+# content_type=="video"   | (缺失)
+# videos                  | (缺失)
+#
+# 不做 normalization 的话 evaluator 会走 article 路径 + 8 个字段全找不到,
+# 视频拿 15 分 F。
+
+
+def _parse_duration(s: Any) -> Optional[float]:
+    """Parse 'MM:SS' or 'HH:MM:SS' to seconds (float)."""
+    if not isinstance(s, str):
+        return None
+    parts = s.strip().split(":")
+    try:
+        nums = [int(p) for p in parts]
+    except ValueError:
+        return None
+    if len(nums) == 2:
+        return float(nums[0] * 60 + nums[1])
+    if len(nums) == 3:
+        return float(nums[0] * 3600 + nums[1] * 60 + nums[2])
+    return None
+
+
+def _parse_view_count(s: Any) -> Optional[int]:
+    """Parse '130,461 views' (or '1.2M views') to int."""
+    if not isinstance(s, str):
+        return None
+    s = s.strip()
+    # "1.2M views" / "3.5K views"
+    m = re.match(r"([\d.]+)\s*([KMBkmb])\b", s)
+    if m:
+        try:
+            num = float(m.group(1))
+        except ValueError:
+            return None
+        mult = {"K": 1_000, "M": 1_000_000, "B": 1_000_000_000}[m.group(2).upper()]
+        return int(num * mult)
+    # "130,461 views"
+    m = re.search(r"([\d,]+)", s)
+    if m:
+        try:
+            return int(m.group(1).replace(",", ""))
+        except ValueError:
+            return None
+    return None
+
+
+_RELATIVE_TIME_RE = re.compile(
+    r"(\d+)\s+(minute|hour|day|week|month|year)s?\s+ago", re.IGNORECASE
+)
+_SECONDS_PER = {
+    "minute": 60, "hour": 3600, "day": 86400,
+    "week": 86400 * 7, "month": 86400 * 30, "year": 86400 * 365,
+}
+
+
+def _parse_relative_time(s: Any) -> Optional[int]:
+    """Parse '6 months ago' -> UTC milliseconds timestamp."""
+    if not isinstance(s, str):
+        return None
+    m = _RELATIVE_TIME_RE.search(s.lower())
+    if not m:
+        return None
+    n = int(m.group(1))
+    delta = n * _SECONDS_PER.get(m.group(2).lower(), 0)
+    if not delta:
+        return None
+    return int((time.time() - delta) * 1000)
+
+
+def _normalize_youtube_post(post: Dict[str, Any]) -> None:
+    """In-place: rewrite YouTube post fields onto the schema evaluator/transcription expect.
+
+    Idempotent — only fills missing fields, never overwrites existing values.
+    """
+    if not isinstance(post, dict):
+        return
+
+    if post.get("video_id") and not post.get("channel_content_id"):
+        post["channel_content_id"] = post["video_id"]
+
+    if post.get("description_snippet") and not post.get("body_text"):
+        post["body_text"] = post["description_snippet"]
+
+    if post.get("view_count") and not isinstance(post.get("like_count"), (int, float)):
+        n = _parse_view_count(post["view_count"])
+        if n is not None:
+            post["like_count"] = n
+
+    if post.get("published_time") and not post.get("publish_timestamp"):
+        ts = _parse_relative_time(post["published_time"])
+        if ts:
+            post["publish_timestamp"] = ts
+
+    if post.get("url") and not post.get("link"):
+        post["link"] = post["url"]
+
+    if post.get("duration") and not isinstance(post.get("duration_sec"), (int, float)):
+        sec = _parse_duration(post["duration"])
+        if sec:
+            post["duration_sec"] = sec
+
+    if post.get("thumbnails") and not post.get("images"):
+        imgs = []
+        for t in post["thumbnails"]:
+            if isinstance(t, dict) and t.get("url"):
+                imgs.append(t["url"])
+        if imgs:
+            post["images"] = imgs
+
+    if not post.get("content_type"):
+        post["content_type"] = "video"
+
+    if not post.get("videos"):
+        # transcription.extract_video_url for "youtube" uses video_id directly,
+        # so this `videos` field is just for evaluator.is_video detection.
+        url = post.get("url")
+        if url:
+            post["videos"] = [url]
+
+
+# ── 搜索 ──
+
+async def search(
+    platform_id: str,
+    keyword: str,
+    max_count: int = 20,
+    cursor: str = "",
+    extras: Optional[Dict[str, Any]] = None,
+) -> ToolResult:
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            response = await client.post(
+                f"{CRAWLER_BASE_URL}/youtube/keyword",
+                json={"keyword": keyword},
+            )
+            response.raise_for_status()
+            data = response.json()
+
+        if data.get("code") != 0:
+            return ToolResult(title="YouTube 搜索失败", output="", error=data.get("msg", "未知错误"))
+
+        result_data = data.get("data", {})
+        videos = result_data.get("data", []) if isinstance(result_data, dict) else []
+
+        # YouTube 字段名跟其他平台不一致,先 normalize 让 evaluator 能正确评分
+        # (并且让 duration_sec / publish_timestamp 等被解析出来,复用 video-mode 评分)
+        for v in videos:
+            _normalize_youtube_post(v)
+
+        # 动态导入评价模块
+        try:
+            from examples.process_pipeline.script.evaluate_source_quality import SourceQualityEvaluator
+            evaluator = SourceQualityEvaluator()
+        except ImportError:
+            evaluator = None
+
+        # 概览
+        summary_list = []
+        for idx, video in enumerate(videos[:max_count], 1):
+            score_info = {}
+            if evaluator:
+                try:
+                    eval_res = evaluator.evaluate_post(video)
+                    score_info = {
+                        "quality_score": eval_res["total_score"],
+                        "quality_grade": eval_res["grade"]
+                    }
+                    video["_quality_score"] = eval_res["total_score"]
+                    video["_quality_grade"] = eval_res["grade"]
+                except Exception:
+                    pass
+            
+            summary_item = {
+                "index": idx,
+                "title": video.get("title", ""),
+                "author": video.get("author", ""),
+                "video_id": video.get("video_id", ""),
+            }
+            summary_item.update(score_info)
+            summary_list.append(summary_item)
+
+        # 拼图
+        images = []
+        collage_obj = await _build_video_collage(videos[:max_count])
+        if collage_obj:
+            images.append(collage_obj)
+
+        return ToolResult(
+            title=f"YouTube: {keyword}",
+            output=json.dumps({"data": summary_list}, ensure_ascii=False, indent=2),
+            long_term_memory=f"Searched YouTube for '{keyword}', {len(videos)} results.",
+            images=images,
+            metadata={"posts": videos[:max_count]},
+        )
+
+    except Exception as e:
+        return ToolResult(title="YouTube 搜索异常", output="", error=str(e))
+
+
+# ── 详情 ──
+
+async def detail(post: Dict[str, Any], extras: Optional[Dict[str, Any]] = None) -> ToolResult:
+    """
+    YouTube 详情:需要额外 HTTP 调用获取字幕/下载等。
+    post 来自搜索缓存,extras 支持 include_captions / download_video。
+
+    Graceful degrade: 三条数据通路(/youtube/detail 增强元数据、/youtube/captions 官方字幕、
+    Deepgram 自研转写)独立进行,任何一条失败都不影响其他。特别是 Deepgram 走的是
+    yt-dlp 下载 watch URL → ffmpeg → Deepgram API,跟 crawler.aiddit.com 后端无关,
+    后端宕机时仍应自动跑 transcript。
+    """
+    extras = extras or {}
+    content_id = post.get("video_id") or post.get("channel_content_id", "")
+    include_captions = extras.get("include_captions", True)
+    download_video = extras.get("download_video", False)
+    include_transcript = extras.get("include_transcript", True)
+
+    # ── 1) /youtube/detail:拿增强元数据(标题/描述/点赞等)。失败时用 search post 兜底 ──
+    video_info: Dict[str, Any] = {}
+    detail_error: Optional[str] = None
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            resp = await client.post(
+                f"{CRAWLER_BASE_URL}/youtube/detail",
+                json={"content_id": content_id},
+            )
+            resp.raise_for_status()
+            detail_data = resp.json()
+        if detail_data.get("code") == 0:
+            result_data = detail_data.get("data", {})
+            video_info = result_data.get("data", {}) if isinstance(result_data, dict) else {}
+        else:
+            detail_error = detail_data.get("msg") or "未知错误"
+    except Exception as e:
+        detail_error = str(e)
+
+    # ── 2) /youtube/captions:官方字幕(也走 crawler 后端,同样可能挂) ──
+    captions_text: Optional[str] = None
+    if include_captions or download_video:
+        try:
+            async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+                cap_resp = await client.post(
+                    f"{CRAWLER_BASE_URL}/youtube/captions",
+                    json={"content_id": content_id},
+                )
+                cap_resp.raise_for_status()
+                cap_data = cap_resp.json()
+                if cap_data.get("code") == 0:
+                    inner = cap_data.get("data", {})
+                    if isinstance(inner, dict):
+                        inner2 = inner.get("data", {})
+                        if isinstance(inner2, dict):
+                            captions_text = inner2.get("content")
+        except Exception:
+            pass
+
+    # ── 3) 视频文件下载(用户显式 extras.download_video=True 时才跑) ──
+    video_path = None
+    video_outline = None
+    if download_video:
+        import asyncio
+        try:
+            from agent.tools.builtin.content.media import download_youtube_video, parse_srt_to_outline
+            video_path = await asyncio.to_thread(download_youtube_video, content_id)
+            if captions_text:
+                video_outline = parse_srt_to_outline(captions_text)
+        except Exception as e:
+            import logging
+            logging.getLogger(__name__).warning("youtube download_video failed: %s", e)
+
+    # ── 4) Deepgram 转写:独立于 1)/2),走 yt-dlp+Deepgram,不依赖 crawler 后端 ──
+    #
+    # 三态语义(跟 extract_sources / aigc_channel.detail 对齐):
+    #   字段缺失     → 没尝试过,跑 Deepgram
+    #   字段 = ""    → 尝试过但失败,跳过(保护 Deepgram 额度)
+    #   字段 = text  → 已成功,复用
+    transcript_text: Optional[str] = post.get("video_transcript") or None
+    field_present = "video_transcript" in post
+    transcribe_error: Optional[str] = None
+    if not field_present and include_transcript:
+        from agent.tools.builtin.content.transcription import transcribe_video_from_post
+        if not post.get("video_id"):
+            post["video_id"] = content_id
+        try:
+            transcript_text = await transcribe_video_from_post("youtube", post)
+        except Exception as e:
+            import logging
+            logging.getLogger(__name__).warning("youtube transcribe failed: %s", e)
+            transcript_text = None
+            transcribe_error = f"{type(e).__name__}: {e}"
+
+        # 三态写回:成功 = text;失败/None = "" 作为"已尝试"标记
+        final_value = transcript_text or ""
+        post["video_transcript"] = final_value
+        if not final_value:
+            post["_transcribe_error"] = (
+                transcribe_error
+                or "transcribe returned None (yt-dlp/Deepgram 任一步失败,见 logger.warning)"
+            )
+
+        # cache writeback 失败的 "" 也写,下次 cache hit 短路
+        import os as _os
+        from agent.tools.builtin.content import cache as _cache
+        trace_id = extras.get("__trace_id__") or _os.getenv("TRACE_ID")
+        if trace_id and content_id:
+            _cache.update_post_field(trace_id, "youtube", content_id, "video_transcript", final_value)
+
+    # ── 5) 组装输出:detail 接口的字段优先,缺失时用 search post 兜底 ──
+    output_data = {
+        "video_id": content_id,
+        "title": video_info.get("title") or post.get("title", ""),
+        "channel": video_info.get("channel_account_name") or post.get("author", ""),
+        "description": (
+            video_info.get("body_text")
+            or post.get("body_text")
+            or post.get("description_snippet", "")
+        ),
+        "like_count": (
+            video_info.get("like_count")
+            if video_info.get("like_count") is not None
+            else post.get("like_count")
+        ),
+        "comment_count": video_info.get("comment_count"),
+        "content_link": video_info.get("content_link") or post.get("link", ""),
+        "captions": captions_text,           # YouTube 官方字幕(可能为空)
+        # Deepgram 转写:读 post 字段,三态语义自然透出("" = 已尝试失败)
+        "video_transcript": post.get("video_transcript", ""),
+    }
+    if detail_error:
+        # 显式标记 graceful degrade 状态,让上层知道这次走的是 fallback
+        output_data["_detail_backend_error"] = detail_error
+    if post.get("_transcribe_error"):
+        # Deepgram 这一路失败原因透到 output,方便 agent/用户判断要不要重试
+        output_data["_transcribe_error"] = post["_transcribe_error"]
+    if download_video:
+        output_data["video_path"] = video_path
+        output_data["video_outline"] = video_outline
+
+    output_text = json.dumps(output_data, ensure_ascii=False, indent=2)
+
+    memory_parts = []
+    if captions_text:
+        memory_parts.append("captions")
+    if transcript_text and transcript_text != captions_text:
+        memory_parts.append("transcript")
+    if detail_error:
+        memory_parts.append(f"degraded(detail backend down)")
+    memory_extra = f" with {'+'.join(memory_parts)}" if memory_parts else ""
+
+    title = video_info.get("title") or post.get("title") or content_id
+    return ToolResult(
+        title=f"YouTube 详情: {title}",
+        output=output_text,
+        long_term_memory=f"YouTube detail for {content_id}{memory_extra}",
+    )
+
+
+# ── 拼图 ──
+
+async def _build_video_collage(videos: List[Dict[str, Any]]) -> Optional[str]:
+    urls, titles = [], []
+    for video in videos:
+        thumb = None
+        if "thumbnails" in video and isinstance(video["thumbnails"], list) and video["thumbnails"]:
+            thumb = video["thumbnails"][0].get("url")
+        elif "thumbnail" in video:
+            thumb = video.get("thumbnail")
+        elif "cover_url" in video:
+            thumb = video.get("cover_url")
+
+        if thumb:
+            urls.append(thumb)
+            base_title = video.get("title", "")
+            score = video.get("_quality_score")
+            if score is not None:
+                title_with_score = f"[{score}分] {base_title}"
+            else:
+                title_with_score = base_title
+            titles.append(title_with_score)
+
+    if not urls:
+        return None
+
+    loaded = await load_images(urls)
+    valid_images, valid_labels = [], []
+    for (_, img), title in zip(loaded, titles):
+        if img is not None:
+            valid_images.append(img)
+            valid_labels.append(title)
+
+    if not valid_images:
+        return None
+
+    grid = build_image_grid(images=valid_images, labels=valid_labels)
+    import io
+    buf = io.BytesIO()
+    grid.save(buf, format="PNG")
+    img_bytes = buf.getvalue()
+    
+    try:
+        from agent.tools.builtin.file.image_cdn import _upload_bytes_to_oss
+        import hashlib
+        
+        md5_hash = hashlib.md5(img_bytes).hexdigest()[:12]
+        filename = f"youtube_collage_{md5_hash}.png"
+        cdn_url = await _upload_bytes_to_oss(img_bytes, filename)
+        return {"type": "url", "url": cdn_url}
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning("Failed to upload youtube collage to CDN: %s", e)
+        b64, _ = encode_base64(grid, format="PNG")
+        return {"type": "base64", "media_type": "image/png", "data": b64}
+
+
+# ── 注册 ──
+
+_YOUTUBE = PlatformDef(
+    id="youtube",
+    name="YouTube",
+    aliases=["yt", "油管"],
+    detail_extras={
+        "include_captions": ParamSpec(note="是否获取字幕,默认 True"),
+        "download_video": ParamSpec(note="是否下载视频到本地,默认 False"),
+    },
+)
+_YOUTUBE.search_impl = search
+_YOUTUBE.detail_impl = detail
+register_platform(_YOUTUBE)

+ 125 - 0
agent/agent/tools/builtin/content/registry.py

@@ -0,0 +1,125 @@
+"""
+内容平台注册表
+
+定义所有支持的内容平台及其搜索参数 schema。
+供 content_platforms / content_search / content_detail 路由使用。
+"""
+
+from dataclasses import dataclass, field
+from typing import Any, Callable, Coroutine, Dict, List, Optional
+
+from agent.tools.models import ToolResult
+
+
+# ── 类型定义 ──
+
+@dataclass
+class ParamSpec:
+    """平台专属参数的描述"""
+    values: Optional[List[str]] = None   # 枚举值(None 表示自由文本)
+    default: Optional[str] = None
+    note: str = ""                       # 额外说明
+
+    def to_dict(self) -> dict:
+        d: dict = {}
+        if self.values is not None:
+            d["values"] = self.values
+            d["default"] = self.default
+        if self.note:
+            d["note"] = self.note
+        return d
+
+
+# 平台实现函数的签名
+SearchFunc = Callable[..., Coroutine[Any, Any, ToolResult]]
+DetailFunc = Callable[..., Coroutine[Any, Any, ToolResult]]
+SuggestFunc = Callable[..., Coroutine[Any, Any, ToolResult]]
+
+
+@dataclass
+class PlatformDef:
+    """一个内容平台的完整定义"""
+    id: str                                         # 唯一标识,如 "xhs"
+    name: str                                       # 显示名,如 "小红书"
+    aliases: List[str] = field(default_factory=list) # 模糊匹配别名,如 ["小红书", "RED"]
+    search_params: Dict[str, ParamSpec] = field(default_factory=dict)
+    detail_extras: Dict[str, ParamSpec] = field(default_factory=dict)
+    supports_suggest: bool = False
+    suggest_channels: Optional[List[str]] = None     # suggest API 的 channel 值(可能与 id 不同)
+
+    # 平台实现函数(运行时由 platforms/ 模块设置)
+    search_impl: Optional[SearchFunc] = None
+    detail_impl: Optional[DetailFunc] = None
+    suggest_impl: Optional[SuggestFunc] = None
+
+    def summary(self) -> dict:
+        """概要信息(不含参数细节)"""
+        d = {"id": self.id, "name": self.name}
+        if self.search_params:
+            d["has_search_params"] = True
+        if self.detail_extras:
+            d["has_detail_extras"] = True
+        if self.supports_suggest:
+            d["supports_suggest"] = True
+        return d
+
+    def detail(self) -> dict:
+        """完整参数说明"""
+        d = self.summary()
+        if self.search_params:
+            d["search_params"] = {k: v.to_dict() for k, v in self.search_params.items()}
+        if self.detail_extras:
+            d["detail_extras"] = {k: v.to_dict() for k, v in self.detail_extras.items()}
+        return d
+
+
+# ── 平台注册表 ──
+
+_PLATFORMS: Dict[str, PlatformDef] = {}
+
+
+def register_platform(p: PlatformDef) -> None:
+    _PLATFORMS[p.id] = p
+
+
+def get_platform(platform_id: str) -> Optional[PlatformDef]:
+    return _PLATFORMS.get(platform_id)
+
+
+def all_platforms() -> List[PlatformDef]:
+    return list(_PLATFORMS.values())
+
+
+def match_platforms(query: str) -> List[PlatformDef]:
+    """
+    模糊匹配平台:精确 ID > 别名包含 > token 交集。
+    空 query 返回全部。
+    """
+    if not query:
+        return all_platforms()
+
+    q = query.strip().lower()
+
+    # 1) 精确 ID 匹配
+    if q in _PLATFORMS:
+        return [_PLATFORMS[q]]
+
+    # 2) 别名 / 名称包含匹配
+    alias_hits = [
+        p for p in _PLATFORMS.values()
+        if q in p.name.lower() or any(q in a.lower() for a in p.aliases)
+    ]
+    if alias_hits:
+        return alias_hits
+
+    # 3) token 交集(把 query 拆成字符/词,看命中率)
+    q_tokens = set(q.replace("_", " ").replace("-", " ").split())
+    scored = []
+    for p in _PLATFORMS.values():
+        pool = {p.id, p.name.lower()} | {a.lower() for a in p.aliases}
+        pool_text = " ".join(pool)
+        hits = sum(1 for t in q_tokens if t in pool_text)
+        if hits > 0:
+            scored.append((hits, p))
+    scored.sort(key=lambda x: -x[0])
+    return [p for _, p in scored]

+ 305 - 0
agent/agent/tools/builtin/content/tools.py

@@ -0,0 +1,305 @@
+"""
+内容工具族 —— 统一入口
+
+4 个 @tool 注册给 LLM:
+  - content_platforms: 列出/查询平台及其参数
+  - content_search:    跨平台搜索
+  - content_detail:    查看详情
+  - content_suggest:   搜索建议词
+
+所有平台的具体实现在 platforms/ 子目录,按模块自注册到 registry。
+"""
+
+import json
+import os
+import uuid
+from typing import Any, Dict, Optional
+
+from agent.tools import tool, ToolResult, ToolContext
+from agent.tools.builtin.content.registry import (
+    all_platforms, get_platform, match_platforms,
+)
+from agent.tools.builtin.content import cache as _cache
+
+# 导入平台模块以触发自注册(副作用导入)
+import agent.tools.builtin.content.platforms.aigc_channel  # noqa: F401
+import agent.tools.builtin.content.platforms.youtube       # noqa: F401
+import agent.tools.builtin.content.platforms.x             # noqa: F401
+
+
+def _get_trace_id(context: Optional[Dict[str, Any]]) -> str:
+    """从 context 取 trace_id,回退到环境变量或自动生成"""
+    if isinstance(context, dict) and context.get("trace_id"):
+        return context["trace_id"]
+    if context and hasattr(context, "trace_id") and context.trace_id:
+        return context.trace_id
+
+    return os.getenv("TRACE_ID") or f"anon-{uuid.uuid4().hex[:8]}"
+
+
+def _convert_post_timestamps(post: dict) -> None:
+    """将 post 中的时间戳字段替换为可读格式"""
+    from datetime import datetime
+
+    for field in ("publish_timestamp", "modify_timestamp", "update_timestamp"):
+        ts = post.get(field)
+        if not ts or ts == 0:
+            continue
+        try:
+            ts = int(ts)
+            if ts > 1000000000000:
+                ts = ts / 1000
+            post[field] = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
+        except Exception:
+            pass
+
+
+# ── content_platforms ──
+
+@tool(hidden_params=["context"], groups=["content"])
+async def content_platforms(
+    platform: str = "",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    列出支持的内容平台及其搜索参数。
+
+    不传 platform 时返回所有平台的概要列表(仅名称和 ID)。
+    传入 platform 时模糊匹配并返回匹配平台的详细参数说明(支持 ID、中文名、别名)。
+
+    建议在不熟悉平台参数时先调用此工具查看,再构造 content_search / content_detail 的参数。
+
+    Args:
+        platform: 可选,平台名称或关键词。支持模糊匹配(如 "xhs"、"小红书"、"youtube")。
+                  留空返回全部平台概要。
+        context: 工具上下文(自动注入)
+    """
+    hits = match_platforms(platform)
+
+    if not hits:
+        all_ids = [p.id for p in all_platforms()]
+        return ToolResult(
+            title="未找到匹配平台",
+            output=f"没有匹配 '{platform}' 的平台。可用平台: {', '.join(all_ids)}",
+        )
+
+    if platform:
+        # 有 query:返回匹配平台的详细参数
+        result = [p.detail() for p in hits]
+    else:
+        # 无 query:返回概要列表
+        result = [p.summary() for p in hits]
+
+    return ToolResult(
+        title=f"内容平台" + (f" ({platform})" if platform else ""),
+        output=json.dumps(result, ensure_ascii=False, indent=2),
+    )
+
+
+# ── content_search ──
+
+@tool(hidden_params=["context"], groups=["content"])
+async def content_search(
+    platform: str,
+    keyword: str,
+    max_count: int = 20,
+    cursor: str = "",
+    extras: Optional[Dict[str, Any]] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    跨平台内容搜索,返回带索引编号的封面拼图 + 概览列表。
+
+    返回的是摘要信息(标题 + 正文截断 + 互动数据),不含完整正文和所有图片。
+    如需查看某条内容的完整信息,请使用 content_detail。
+
+    Args:
+        platform: 平台标识,如 'xhs'、'youtube'、'x'。完整列表见 content_platforms。
+        keyword: 搜索关键词。
+        max_count: 返回条数上限,默认 20。
+        cursor: 分页游标,首次搜索留空,翻页时传入上次返回值。
+        extras: 平台专用参数(dict)。不同平台支持不同参数,
+                如 xhs 支持 sort_type / publish_time / content_type / filter_note_range。
+                不清楚可先调 content_platforms(platform) 查看。
+        context: 工具上下文(自动注入)
+    """
+    pdef = get_platform(platform)
+    if not pdef:
+        # 尝试模糊匹配
+        hits = match_platforms(platform)
+        if hits:
+            suggestions = ", ".join(f"{p.id}({p.name})" for p in hits[:3])
+            return ToolResult(title="平台不存在", output=f"未找到平台 '{platform}'。你是否想要: {suggestions}")
+        all_ids = [p.id for p in all_platforms()]
+        return ToolResult(title="平台不存在", output=f"未找到平台 '{platform}'。可用: {', '.join(all_ids)}")
+
+    if not pdef.search_impl:
+        return ToolResult(title="不支持搜索", output=f"平台 {pdef.name} 暂不支持搜索")
+
+    try:
+        max_count = int(max_count)
+    except (ValueError, TypeError):
+        max_count = 20
+
+    result = await pdef.search_impl(
+        platform_id=pdef.id,
+        keyword=keyword,
+        max_count=max_count,
+        cursor=cursor,
+        extras=extras,
+    )
+
+    # 持久化搜索结果到磁盘缓存
+    if not result.error:
+        posts = result.metadata.pop("posts", [])
+        trace_id = _get_trace_id(context)
+        _cache.save_search_results(trace_id, pdef.id, keyword, posts)
+
+    return result
+
+
+# ── content_detail ──
+
+@tool(hidden_params=["context"], groups=["content"])
+async def content_detail(
+    platform: str,
+    index: int,
+    extras: Optional[Dict[str, Any]] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    查看内容详情。从最近一次 content_search 的结果中按索引取完整记录。
+
+    Args:
+        platform: 平台标识(必须和之前 content_search 用的一致)。
+        index: 内容序号(1-based),来自 content_search 返回的 index 字段。
+        extras: 平台专用详情参数。YouTube 支持 include_captions / download_video。
+                其他平台通常不需要。
+        context: 工具上下文(自动注入)
+    """
+    pdef = get_platform(platform)
+    if not pdef:
+        return ToolResult(title="平台不存在", output=f"未找到平台 '{platform}'")
+
+    try:
+        index = int(index)
+    except (ValueError, TypeError):
+        return ToolResult(title="参数错误", output="参数 index 必须是整数", error="Invalid index parameter")
+
+    trace_id = _get_trace_id(context)
+    post = _cache.get_cached_post(trace_id, pdef.id, index)
+
+    if not post:
+        info = _cache.get_cached_search_info(trace_id, pdef.id)
+        if info:
+            return ToolResult(
+                title="索引无效",
+                output=f"平台 {pdef.name} 上次搜索 '{info['keyword']}' 共 {info['total']} 条,"
+                       f"有效索引 1-{info['total']},你传入了 {index}。",
+                error="Invalid index",
+            )
+        return ToolResult(
+            title="缓存未命中",
+            output=f"没有 {pdef.name} 的搜索缓存。请先调用 content_search(platform='{pdef.id}', keyword=...) 搜索。",
+            error="No cache",
+        )
+
+    # 将时间戳转换为可读格式
+    _convert_post_timestamps(post)
+
+    if pdef.detail_impl:
+        # 透传 trace_id 给 detail_impl,便于其异步补充数据后回写 cache
+        # (之前依赖 os.getenv("TRACE_ID"),只在 CLI 路径有效,agent 路径下 silently 失效)
+        extras = dict(extras or {})
+        extras["__trace_id__"] = trace_id
+        return await pdef.detail_impl(post, extras)
+
+    # fallback:直接返回缓存的完整数据
+    return ToolResult(
+        title=f"详情 #{index}",
+        output=json.dumps(post, ensure_ascii=False, indent=2),
+    )
+
+
+# ── content_suggest ──
+
+@tool(hidden_params=["context"], groups=["content"])
+async def content_suggest(
+    platform: str,
+    keyword: str,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    获取搜索关键词补全建议。
+
+    仅部分平台支持(xhs、toutiao、douyin、bili、zhihu)。
+    用于辅助用户发现更精准的搜索词。
+
+    Args:
+        platform: 平台标识。
+        keyword: 搜索关键词(输入中的部分词即可)。
+        context: 工具上下文(自动注入)
+    """
+    pdef = get_platform(platform)
+    if not pdef:
+        return ToolResult(title="平台不存在", output=f"未找到平台 '{platform}'")
+
+    if not pdef.suggest_impl:
+        supported = [p.id for p in all_platforms() if p.supports_suggest]
+        return ToolResult(
+            title="不支持建议词",
+            output=f"平台 {pdef.name} 不支持建议词。支持的平台: {', '.join(supported)}",
+        )
+
+    channel = (pdef.suggest_channels or [pdef.id])[0]
+    return await pdef.suggest_impl(channel, keyword)
+
+
+# ── CLI 入口 ──
+
+def _parse_args(argv: list) -> dict:
+    """解析 --key=value 格式的 CLI 参数"""
+    kwargs = {}
+    for arg in argv:
+        if arg.startswith("--") and "=" in arg:
+            key, val = arg[2:].split("=", 1)
+            # 尝试 JSON 解析(dict / int / bool)
+            try:
+                val = json.loads(val)
+            except (json.JSONDecodeError, ValueError):
+                pass
+            kwargs[key] = val
+    return kwargs
+
+
+def cli_main(argv: Optional[list] = None) -> None:
+    """CLI 入口:通过 `python -m agent.tools.builtin.content` 调用(见 __main__.py)"""
+    import sys
+    import asyncio
+
+    argv = sys.argv if argv is None else argv
+
+    commands = {
+        "platforms": content_platforms,
+        "search": content_search,
+        "detail": content_detail,
+        "suggest": content_suggest,
+    }
+
+    if len(argv) < 2 or argv[1] not in commands:
+        print(f"Usage: python -m agent.tools.builtin.content <{'|'.join(commands)}> [--key=value ...]")
+        sys.exit(1)
+
+    cmd = argv[1]
+    kwargs = _parse_args(argv[2:])
+
+    # trace_id:CLI 参数 > 环境变量 > 自动生成
+    trace_id = kwargs.pop("trace_id", None) or os.getenv("TRACE_ID") or f"cli-{uuid.uuid4().hex[:8]}"
+    os.environ["TRACE_ID"] = trace_id
+
+    result = asyncio.run(commands[cmd](**kwargs))
+
+    out = {"trace_id": trace_id, "output": result.output, "error": result.error}
+    if result.metadata:
+        out["metadata"] = result.metadata
+    print(json.dumps(out, ensure_ascii=False, indent=2))

+ 353 - 0
agent/agent/tools/builtin/content/transcription.py

@@ -0,0 +1,353 @@
+"""Download a post's source video, extract audio, transcribe via Deepgram.
+
+Used by platform detail() implementations whose posts ship raw video URLs
+(X, sph, douyin) and don't already supply captions. YouTube has its own
+captions endpoint and bypasses this module.
+
+Pipeline per video:
+  1. extract_video_url(platform, post)  -> source url (page or direct)
+  2. download to %TEMP%/content_transcribe/<platform>/<stem>.mp4
+     - X     : yt-dlp on the page URL (most robust against rotating video URLs)
+     - douyin: httpx + Referer https://www.douyin.com/
+     - sph   : httpx + Referer https://channels.weixin.qq.com/
+  3. ffmpeg -> 16kHz mono AAC 64kbps m4a (~3% the size of the source mp4)
+  4. POST to Deepgram /v1/listen, model=whisper-large by default
+  5. Strip spaces inserted by Deepgram between consecutive CJK characters
+
+Returns transcript text on success, None on any failure (silent fallback).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import re
+import subprocess
+from pathlib import Path
+from typing import Any, Optional
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+DEEPGRAM_URL = "https://api.deepgram.com/v1/listen"
+DEEPGRAM_MODEL_DEFAULT = "whisper-large"
+DEEPGRAM_REQUEST_TIMEOUT = 600.0
+DOWNLOAD_TIMEOUT = 300
+FFMPEG_TIMEOUT = 600
+UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+      "(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
+
+# 项目根目录 / .cache / content_videos —— 不再用系统 %TEMP%,避免被 Windows 偶发清理
+# 也避免 8GB+ 视频堆在 AppData\Local\Temp 看不见。
+# parents[4]: transcription.py → content/ → builtin/ → tools/ → agent/ → project root
+_CACHE_ROOT = Path(__file__).resolve().parents[4] / ".cache" / "content_videos"
+_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
+# Zero-width lookbehind/lookahead: remove whitespace strictly between CJK chars,
+# preserve CJK<->ASCII boundaries (e.g. "Remotion 是工具" stays intact).
+_CJK_SPACE_RE = re.compile(r"(?<=[一-鿿])\s+(?=[一-鿿])")
+
+# Referer headers required by some CDNs for ffprobe / yt-dlp / httpx to access video URLs.
+_PLATFORM_REFERERS = {
+    "douyin": "https://www.douyin.com/",
+    "sph": "https://channels.weixin.qq.com/",
+    "xhs": "https://www.xiaohongshu.com/",
+    "bili": "https://www.bilibili.com/",
+    "weibo": "https://weibo.com/",
+}
+_DURATION_PROBE_TIMEOUT = 15
+
+
+def extract_video_url(platform: str, post: dict[str, Any]) -> Optional[str]:
+    """Pluck a video URL (page or direct) out of a platform's raw post dict."""
+    if platform == "x":
+        vlist = post.get("video_url_list") or []
+        if vlist:
+            head = vlist[0]
+            return head.get("video_url") if isinstance(head, dict) else head
+        return None
+    if platform == "youtube":
+        vid = post.get("video_id") or post.get("content_id")
+        return f"https://www.youtube.com/watch?v={vid}" if vid else None
+    # Generic: aigc-channel platforms (xhs / gzh / sph / douyin / bili / zhihu /
+    # weibo / toutiao / github) all expose video URLs under `videos[0]`.
+    videos = post.get("videos") or []
+    if videos:
+        return videos[0]
+    return None
+
+
+def _safe_stem(platform: str, post: dict[str, Any]) -> str:
+    raw_id = (
+        post.get("channel_content_id")
+        or post.get("video_id")
+        or post.get("content_id")
+        or "item"
+    )
+    return f"{platform}_{_SAFE_RE.sub('_', str(raw_id))[:60]}"
+
+
+def _yt_dlp_download(url: str, target: Path) -> Optional[Path]:
+    if target.exists() and target.stat().st_size > 0:
+        return target
+    # Format chain: 优先 muxed mp4(YouTube/X/douyin 通常命中,最快),
+    # fallback 到 bestvideo+bestaudio + ffmpeg merge(bili 等 DASH-only 平台),
+    # 最后兜底 best。
+    cmd = ["yt-dlp", "-f", "best[ext=mp4]/bestvideo+bestaudio/best",
+           "-o", str(target),
+           "--no-playlist", "--quiet", "--no-warnings", url]
+    try:
+        r = subprocess.run(cmd, capture_output=True, text=True, timeout=DOWNLOAD_TIMEOUT)
+    except (subprocess.TimeoutExpired, FileNotFoundError) as e:
+        logger.warning("yt-dlp failed for %s: %s", url, e)
+        return None
+    if r.returncode != 0:
+        logger.warning("yt-dlp non-zero for %s: %s", url, (r.stderr or r.stdout)[:200])
+        return None
+    if target.exists() and target.stat().st_size > 0:
+        return target
+    # yt-dlp may have written with a different extension
+    for f in target.parent.glob(target.stem + ".*"):
+        if f.is_file() and f.stat().st_size > 0:
+            return f
+    return None
+
+
+async def _httpx_download(url: str, target: Path, referer: Optional[str] = None) -> Optional[Path]:
+    if target.exists() and target.stat().st_size > 0:
+        return target
+    headers = {"User-Agent": UA}
+    if referer:
+        headers["Referer"] = referer
+    try:
+        async with httpx.AsyncClient(
+            timeout=DOWNLOAD_TIMEOUT, follow_redirects=True, headers=headers
+        ) as client:
+            async with client.stream("GET", url) as resp:
+                if resp.status_code != 200:
+                    logger.warning("download HTTP %s for %s", resp.status_code, url)
+                    return None
+                with target.open("wb") as f:
+                    async for chunk in resp.aiter_bytes(chunk_size=64 * 1024):
+                        f.write(chunk)
+    except Exception as e:
+        logger.warning("httpx download failed for %s: %s", url, e)
+        return None
+    return target if target.exists() and target.stat().st_size > 0 else None
+
+
+async def _download_video(
+    platform: str, post: dict[str, Any], video_url: str, target: Path
+) -> Optional[Path]:
+    """Dispatch to the right downloader per platform.
+
+    Per-platform strategies:
+      x      : yt-dlp on the tweet page URL (video URLs are signed/rotating)
+      douyin : httpx direct with douyin.com Referer (video URL is a play API)
+      sph    : httpx direct with channels.weixin.qq.com Referer (stodownload link)
+      youtube: yt-dlp on the watch URL
+
+    For everything else (xhs / bili / weibo / zhihu / gzh / toutiao / github / ...):
+    try yt-dlp on the post's page URL first (yt-dlp supports 1000+ sites including
+    most aigc-channel platforms via cookies-free extractors), and fall back to
+    plain httpx on `videos[0]` if yt-dlp can't handle it.
+    """
+    if platform == "x":
+        page_url = post.get("link") or video_url
+        return await asyncio.to_thread(_yt_dlp_download, page_url, target)
+    if platform == "douyin":
+        return await _httpx_download(video_url, target, referer="https://www.douyin.com/")
+    if platform == "sph":
+        return await _httpx_download(video_url, target, referer="https://channels.weixin.qq.com/")
+    if platform == "youtube":
+        return await asyncio.to_thread(_yt_dlp_download, video_url, target)
+
+    # Generic two-step fallback for any other platform with a `videos` field.
+    page_url = post.get("link")
+    if page_url:
+        result = await asyncio.to_thread(_yt_dlp_download, page_url, target)
+        if result:
+            return result
+        logger.info("yt-dlp didn't handle %s page URL; falling back to httpx", platform)
+    return await _httpx_download(video_url, target)
+
+
+def _extract_m4a(video_path: Path, audio_path: Path) -> bool:
+    """ffmpeg: video -> 16kHz mono AAC 64kbps m4a. Returns True if file written."""
+    audio_path.parent.mkdir(parents=True, exist_ok=True)
+    if audio_path.exists() and audio_path.stat().st_size > 0:
+        return True
+    cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
+           "-i", str(video_path),
+           "-vn", "-ac", "1", "-ar", "16000",
+           "-c:a", "aac", "-b:a", "64k",
+           str(audio_path)]
+    try:
+        subprocess.run(cmd, check=True, timeout=FFMPEG_TIMEOUT,
+                       capture_output=True)
+    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
+        logger.warning("ffmpeg failed for %s: %s", video_path, e)
+        return False
+    return audio_path.exists() and audio_path.stat().st_size > 0
+
+
+async def _transcribe_deepgram(
+    audio_path: Path,
+    api_key: str,
+    model: str = DEEPGRAM_MODEL_DEFAULT,
+    language: Optional[str] = None,
+) -> Optional[str]:
+    params: dict[str, str] = {
+        "model": model,
+        "smart_format": "true",
+        "punctuate": "true",
+    }
+    if language:
+        params["language"] = language
+    else:
+        params["detect_language"] = "true"
+    headers = {
+        "Authorization": f"Token {api_key}",
+        "Content-Type": "audio/mp4",
+    }
+    try:
+        audio_bytes = audio_path.read_bytes()
+        async with httpx.AsyncClient(timeout=DEEPGRAM_REQUEST_TIMEOUT) as client:
+            r = await client.post(DEEPGRAM_URL, params=params, headers=headers,
+                                  content=audio_bytes)
+    except Exception as e:
+        logger.warning("Deepgram request failed for %s: %s", audio_path.name, e)
+        return None
+    if r.status_code != 200:
+        logger.warning("Deepgram HTTP %s: %s", r.status_code, r.text[:200])
+        return None
+    try:
+        data = r.json()
+        alt = data["results"]["channels"][0]["alternatives"][0]
+        return alt.get("transcript") or None
+    except (KeyError, IndexError, ValueError) as e:
+        logger.warning("Deepgram response malformed: %s", e)
+        return None
+
+
+def _clean_chinese_spaces(text: str) -> str:
+    """Drop whitespace strictly between two CJK characters."""
+    return _CJK_SPACE_RE.sub("", text)
+
+
+def _ffprobe_duration_sync(video_url: str, referer: Optional[str] = None) -> Optional[float]:
+    """Read mp4 moov box over HTTP Range; returns duration (seconds) or None.
+
+    Does NOT download the video stream — typically pulls only a few KB even for
+    multi-GB files. Designed to be called from search() to enrich posts with
+    duration before scoring, without paying the cost of a full download.
+    """
+    cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration",
+           "-of", "default=nw=1:nk=1"]
+    if referer:
+        cmd += ["-headers", f"Referer: {referer}\r\n"]
+    cmd += [video_url]
+    try:
+        r = subprocess.run(cmd, capture_output=True, text=True, timeout=_DURATION_PROBE_TIMEOUT)
+    except (subprocess.TimeoutExpired, FileNotFoundError) as e:
+        logger.info("ffprobe duration probe failed for %s: %s", video_url[:80], e)
+        return None
+    out = (r.stdout or "").strip()
+    if not out:
+        return None
+    try:
+        d = float(out)
+    except ValueError:
+        return None
+    return d if d > 0 else None
+
+
+async def probe_video_duration(
+    video_url: str, platform: Optional[str] = None
+) -> Optional[float]:
+    """Async wrapper. Probes mp4 duration via HTTP Range; returns seconds or None.
+
+    Pass `platform` to auto-inject the right Referer header (douyin / sph / xhs / bili
+    require it). Safe to call concurrently — uses asyncio.to_thread so subprocesses
+    don't block the event loop. Each call is one ffprobe subprocess; cap parallelism
+    at the call site if probing many URLs.
+    """
+    if not video_url:
+        return None
+    referer = _PLATFORM_REFERERS.get(platform) if platform else None
+    return await asyncio.to_thread(_ffprobe_duration_sync, video_url, referer)
+
+
+async def probe_durations_for_posts(
+    platform: str, posts: list, concurrency: int = 8
+) -> None:
+    """In-place: probe each post's video URL and set post["duration_sec"] if found.
+
+    Skips posts with no video URL (image-only posts). Probes happen concurrently
+    bounded by `concurrency` to avoid spawning a flood of ffprobe subprocesses.
+    Failures are silent (post just won't have duration_sec — evaluator handles).
+    """
+    sem = asyncio.Semaphore(concurrency)
+
+    async def _one(post: dict) -> None:
+        url = extract_video_url(platform, post)
+        if not url:
+            return
+        async with sem:
+            d = await probe_video_duration(url, platform=platform)
+        if d is not None:
+            post["duration_sec"] = d
+
+    await asyncio.gather(*[_one(p) for p in posts if isinstance(p, dict)])
+
+
+def _get_api_key() -> Optional[str]:
+    key = os.environ.get("DEEPGRAM_KEY") or os.environ.get("DEEPGRAM_API_KEY")
+    if key:
+        return key
+    try:
+        from dotenv import load_dotenv
+        load_dotenv()
+    except ImportError:
+        return None
+    return os.environ.get("DEEPGRAM_KEY") or os.environ.get("DEEPGRAM_API_KEY")
+
+
+async def transcribe_video_from_post(
+    platform: str,
+    post: dict[str, Any],
+    *,
+    model: str = DEEPGRAM_MODEL_DEFAULT,
+    language: Optional[str] = None,
+) -> Optional[str]:
+    """End-to-end: locate video, download, extract m4a, STT, clean spaces.
+
+    Returns transcript text or None if any step fails (logged at WARNING level).
+    Caller can safely ignore None and fall back to whatever body text it has.
+    """
+    url = extract_video_url(platform, post)
+    if not url:
+        return None
+    api_key = _get_api_key()
+    if not api_key:
+        logger.warning("DEEPGRAM_KEY not set; skipping transcription for %s", platform)
+        return None
+
+    stem = _safe_stem(platform, post)
+    work_dir = _CACHE_ROOT / platform
+    work_dir.mkdir(parents=True, exist_ok=True)
+    video_path = work_dir / f"{stem}.mp4"
+    audio_path = work_dir / f"{stem}.m4a"
+
+    video = await _download_video(platform, post, url, video_path)
+    if not video:
+        return None
+
+    if not await asyncio.to_thread(_extract_m4a, video, audio_path):
+        return None
+
+    transcript = await _transcribe_deepgram(audio_path, api_key, model=model, language=language)
+    if not transcript:
+        return None
+    return _clean_chinese_spaces(transcript).strip()

+ 85 - 0
agent/agent/tools/builtin/context.py

@@ -0,0 +1,85 @@
+"""
+上下文工具 - 获取当前执行上下文
+
+提供 get_current_context 工具,让 Agent 可以主动获取:
+- 当前计划(GoalTree)
+- 焦点提醒
+- 协作者状态
+
+框架也会在特定轮次自动调用此工具进行周期性上下文刷新。
+"""
+
+from agent.tools import tool, ToolResult, ToolContext
+
+
+@tool(
+    description="获取当前执行上下文,包括计划状态、焦点提醒、协作者信息等。当你感到困惑或需要回顾当前任务状态时调用。",
+    hidden_params=["context"],
+    groups=["core"],
+)
+async def get_current_context(
+    context: ToolContext,
+) -> ToolResult:
+    """
+    获取当前执行上下文
+
+    Returns:
+        ToolResult: 包含 GoalTree、焦点提醒、协作者状态等信息
+    """
+    runner = context.get("runner")
+    goal_tree = context.get("goal_tree")
+    trace_id = context.get("trace_id")
+
+    if not runner:
+        return ToolResult(
+            title="❌ 无法获取上下文",
+            output="Runner 未初始化",
+            error="Runner not available"
+        )
+
+    # 获取 trace 对象
+    trace = None
+    if runner.trace_store and trace_id:
+        trace = await runner.trace_store.get_trace(trace_id)
+
+    # 构建上下文内容(复用 runner 的 _build_context_injection 方法)
+    if hasattr(runner, '_build_context_injection'):
+        context_content = runner._build_context_injection(trace, goal_tree)
+    else:
+        # Fallback:只返回 GoalTree
+        if goal_tree and goal_tree.goals:
+            context_content = f"## Current Plan\n\n{goal_tree.to_prompt()}"
+        else:
+            context_content = "暂无计划信息"
+
+        # Fallback 也检查 IM 通知
+        im_config = trace.context.get("im_config") if trace else None
+        if im_config:
+            contact_id = im_config.get("contact_id")
+            chat_id = im_config.get("chat_id")
+            if contact_id and chat_id:
+                try:
+                    from agent.tools.builtin.im import chat as im_chat
+                    notification = im_chat._notifications.get((contact_id, chat_id))
+                    if notification:
+                        count = notification.get("count", 0)
+                        senders = notification.get("from", [])
+                        senders_str = ", ".join(senders)
+                        context_content += (
+                            f"\n\n## IM 消息通知\n\n"
+                            f"你有 {count} 条新消息,来自: {senders_str}\n"
+                            f"使用 `im_receive_messages(contact_id=\"{contact_id}\", chat_id=\"{chat_id}\")` 查看消息内容。"
+                        )
+                    else:
+                        context_content += "\n\n## IM 消息通知\n\n暂无新消息"
+                except (ImportError, AttributeError):
+                    pass
+
+    if not context_content:
+        context_content = "当前无需要刷新的上下文信息"
+
+    return ToolResult(
+        title="📋 当前执行上下文",
+        output=context_content,
+        long_term_memory="已刷新执行上下文",
+    )

+ 90 - 0
agent/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/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"
+]

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

@@ -0,0 +1,505 @@
+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"],
+    groups=["feishu"],
+    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"],
+    groups=["feishu"],
+    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}")
+                    elif url.startswith("http://") or url.startswith("https://"):
+                        # 处理网络 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}")
+                    else:
+                        # 处理本地文件路径
+                        try:
+                            local_path = os.path.abspath(url)
+                            if os.path.isfile(local_path):
+                                last_res = client.send_image(to=receive_id, image=local_path)
+                            else:
+                                print(f"本地图片文件不存在: {local_path}")
+                        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"],
+    groups=["feishu"],
+    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"],
+    groups=["feishu"],
+    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/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}")

+ 892 - 0
agent/agent/tools/builtin/feishu/feishu_agent.py

@@ -0,0 +1,892 @@
+"""
+飞书实时对话 Agent
+
+通过飞书 WebSocket 监听消息,调用 Qwen LLM 生成回复,实现实时对话。
+支持工具调用:浏览目录、读取文件、执行 bash 命令。
+
+用法:
+    python -m agent.tools.builtin.feishu.feishu_agent
+
+环境变量:
+    FEISHU_APP_ID / FEISHU_APP_SECRET: 飞书应用凭证
+    QWEN_API_KEY: 通义千问 API Key
+    QWEN_BASE_URL: 通义千问 API 地址(可选,默认阿里云)
+    FEISHU_AGENT_MODEL: 模型名称(默认 qwen-plus)
+    FEISHU_AGENT_SYSTEM_PROMPT: 自定义 system prompt(可选)
+"""
+
+import os
+import sys
+import json
+import asyncio
+import logging
+import subprocess
+import threading
+import base64
+import zipfile
+from typing import Dict, List, Any, Optional
+from collections import defaultdict
+
+PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
+if PROJECT_ROOT not in sys.path:
+    sys.path.insert(0, PROJECT_ROOT)
+
+from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuMessageEvent, FeishuDomain, ReceiveIdType
+from agent.tools.builtin.feishu.chat import (
+    FEISHU_APP_ID,
+    FEISHU_APP_SECRET,
+    get_contact_by_id,
+    load_chat_history,
+    save_chat_history,
+)
+from agent.llm.qwen import qwen_llm_call
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(levelname)s %(message)s')
+logger = logging.getLogger("FeishuAgent")
+
+# ===== 配置 =====
+
+MODEL = os.getenv("FEISHU_AGENT_MODEL", "qwen3.5-397b-a17b")
+MAX_HISTORY = 50
+MAX_TOOL_ROUNDS = 10  # 工具调用最大循环次数
+ALLOWED_CONTACTS = {"关涛"}
+
+DEFAULT_SYSTEM_PROMPT = """你是一个友好、有帮助的 AI 助手,正在通过飞书和用户对话。
+你可以使用工具来浏览本地目录、读取文件内容、执行 bash 命令。
+请用简洁清晰的中文回复。如果用户使用其他语言,请用对应语言回复。"""
+
+SYSTEM_PROMPT = os.getenv("FEISHU_AGENT_SYSTEM_PROMPT", DEFAULT_SYSTEM_PROMPT)
+
+# ===== 工具定义 =====
+
+TOOLS = [
+    {
+        "type": "function",
+        "function": {
+            "name": "list_directory",
+            "description": "列出指定目录下的文件和子目录",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "path": {"type": "string", "description": "目录路径,默认当前目录"}
+                },
+                "required": []
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "read_file",
+            "description": "读取指定文件的内容",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "path": {"type": "string", "description": "文件路径"},
+                    "max_lines": {"type": "integer", "description": "最多读取行数,默认200"}
+                },
+                "required": ["path"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "run_bash",
+            "description": "执行 bash/shell 命令并返回输出",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "command": {"type": "string", "description": "要执行的命令"}
+                },
+                "required": ["command"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "send_image",
+            "description": "发送图片到飞书",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "path": {"type": "string", "description": "本地图片文件路径"}
+                },
+                "required": ["path"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "send_file",
+            "description": "发送文件到飞书",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "path": {"type": "string", "description": "本地文件路径"}
+                },
+                "required": ["path"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "zip_and_send",
+            "description": "将本地目录打包成 zip 并发送到飞书",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "path": {"type": "string", "description": "要打包的目录路径"}
+                },
+                "required": ["path"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "run_background",
+            "description": "在后台启动一个长期运行的命令(如服务器、agent),返回进程 ID。不会等待命令结束。",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "command": {"type": "string", "description": "要执行的命令"},
+                    "name": {"type": "string", "description": "给这个后台进程起个名字,方便后续管理"}
+                },
+                "required": ["command"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "stop_process",
+            "description": "停止一个后台运行的进程",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "pid": {"type": "integer", "description": "进程 ID"}
+                },
+                "required": ["pid"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "list_processes",
+            "description": "列出所有通过 run_background 启动的后台进程及其状态",
+            "parameters": {
+                "type": "object",
+                "properties": {},
+                "required": []
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "get_process_output",
+            "description": "获取后台进程的最新输出日志",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "pid": {"type": "integer", "description": "进程 ID"},
+                    "tail": {"type": "integer", "description": "获取最后 N 行,默认50"}
+                },
+                "required": ["pid"]
+            }
+        }
+    },
+    {
+        "type": "function",
+        "function": {
+            "name": "send_input",
+            "description": "向后台进程发送输入(如交互式命令)",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "pid": {"type": "integer", "description": "进程 ID"},
+                    "text": {"type": "string", "description": "要发送的文本"}
+                },
+                "required": ["pid", "text"]
+            }
+        }
+    }
+]
+
+
+# ===== 工具执行 =====
+
+# 全局变量:保存当前会话的 chat_id,供工具使用
+_current_chat_id = None
+
+# 后台进程管理
+_background_processes: Dict[int, Dict[str, Any]] = {}  # pid → {proc, name, command, output_lines, started_at}
+
+
+def execute_tool(name: str, arguments: Dict[str, Any]) -> str:
+    """执行工具调用,返回结果字符串"""
+    try:
+        if name == "list_directory":
+            return _tool_list_directory(arguments.get("path", "."))
+        elif name == "read_file":
+            return _tool_read_file(arguments["path"], arguments.get("max_lines", 200))
+        elif name == "run_bash":
+            return _tool_run_bash(arguments["command"])
+        elif name == "send_image":
+            return _tool_send_image(arguments["path"])
+        elif name == "send_file":
+            return _tool_send_file(arguments["path"])
+        elif name == "zip_and_send":
+            return _tool_zip_and_send(arguments["path"])
+        elif name == "run_background":
+            return _tool_run_background(arguments["command"], arguments.get("name", ""))
+        elif name == "stop_process":
+            return _tool_stop_process(arguments["pid"])
+        elif name == "list_processes":
+            return _tool_list_processes()
+        elif name == "get_process_output":
+            return _tool_get_process_output(arguments["pid"], arguments.get("tail", 50))
+        elif name == "send_input":
+            return _tool_send_input(arguments["pid"], arguments["text"])
+        else:
+            return f"未知工具: {name}"
+    except Exception as e:
+        return f"工具执行出错: {type(e).__name__}: {e}"
+
+
+def _tool_list_directory(path: str) -> str:
+    path = os.path.abspath(path)
+    if not os.path.isdir(path):
+        return f"目录不存在: {path}"
+    entries = []
+    for name in sorted(os.listdir(path)):
+        full = os.path.join(path, name)
+        suffix = "/" if os.path.isdir(full) else ""
+        entries.append(f"  {name}{suffix}")
+    header = f"目录: {path} ({len(entries)} 项)\n"
+    return header + "\n".join(entries) if entries else header + "  (空目录)"
+
+
+def _tool_read_file(path: str, max_lines: int = 200) -> str:
+    path = os.path.abspath(path)
+    if not os.path.isfile(path):
+        return f"文件不存在: {path}"
+    try:
+        with open(path, "r", encoding="utf-8", errors="replace") as f:
+            lines = []
+            for i, line in enumerate(f):
+                if i >= max_lines:
+                    lines.append(f"\n... (截断,共读取 {max_lines} 行)")
+                    break
+                lines.append(line.rstrip())
+        return "\n".join(lines)
+    except Exception as e:
+        return f"读取失败: {e}"
+
+
+def _tool_run_bash(command: str) -> str:
+    # 将项目 .venv 的 Scripts/bin 目录加到 PATH 最前面,确保 python 指向虚拟环境
+    env = os.environ.copy()
+    venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "Scripts")  # Windows
+    if not os.path.isdir(venv_scripts):
+        venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "bin")  # Linux/Mac
+    env["PATH"] = venv_scripts + os.pathsep + env.get("PATH", "")
+    env["VIRTUAL_ENV"] = os.path.join(PROJECT_ROOT, ".venv")
+    env["PYTHONIOENCODING"] = "utf-8"
+
+    try:
+        proc = subprocess.Popen(
+            command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+            text=True, cwd=PROJECT_ROOT, env=env, encoding="utf-8", errors="replace",
+        )
+        try:
+            stdout, stderr = proc.communicate(timeout=30)
+        except subprocess.TimeoutExpired:
+            # 超时:杀掉整个进程树(Windows 需要 taskkill)
+            try:
+                if sys.platform == "win32":
+                    subprocess.run(
+                        f"taskkill /F /T /PID {proc.pid}",
+                        shell=True, capture_output=True, timeout=5,
+                    )
+                else:
+                    import signal
+                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
+            except Exception:
+                proc.kill()
+            proc.wait(timeout=5)
+            return "命令执行超时(30秒),已终止进程"
+
+        output = ""
+        if stdout:
+            output += stdout
+        if stderr:
+            output += ("\n--- stderr ---\n" + stderr) if output else stderr
+        if proc.returncode != 0:
+            output += f"\n(exit code: {proc.returncode})"
+        return output.strip() or "(无输出)"
+    except Exception as e:
+        return f"执行失败: {e}"
+
+
+def _tool_send_image(path: str) -> str:
+    """发送图片到飞书"""
+    from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
+    from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain, ReceiveIdType
+
+    if not _current_chat_id:
+        return "错误:无法获取当前会话 ID"
+
+    path = os.path.abspath(path)
+    if not os.path.isfile(path):
+        return f"文件不存在: {path}"
+
+    try:
+        client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, domain=FeishuDomain.FEISHU)
+        client.send_image(to=_current_chat_id, image=path)
+        return f"图片已发送: {os.path.basename(path)}"
+    except Exception as e:
+        return f"发送图片失败: {e}"
+
+
+def _tool_send_file(path: str) -> str:
+    """发送文件到飞书"""
+    from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
+    from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain
+
+    if not _current_chat_id:
+        return "错误:无法获取当前会话 ID"
+
+    path = os.path.abspath(path)
+    if not os.path.isfile(path):
+        return f"文件不存在: {path}"
+
+    try:
+        client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, domain=FeishuDomain.FEISHU)
+        client.send_file(to=_current_chat_id, file=path, file_name=os.path.basename(path))
+        return f"文件已发送: {os.path.basename(path)}"
+    except Exception as e:
+        return f"发送文件失败: {e}"
+
+
+def _tool_zip_and_send(path: str) -> str:
+    """打包目录并发送到飞书"""
+    from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
+    from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain
+
+    if not _current_chat_id:
+        return "错误:无法获取当前会话 ID"
+
+    path = os.path.abspath(path)
+    if not os.path.isdir(path):
+        return f"目录不存在: {path}"
+
+    try:
+        # 创建临时 zip 文件
+        zip_name = os.path.basename(path.rstrip(os.sep)) + ".zip"
+        zip_path = os.path.join(PROJECT_ROOT, zip_name)
+
+        with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
+            for root, dirs, files in os.walk(path):
+                for file in files:
+                    file_path = os.path.join(root, file)
+                    arcname = os.path.relpath(file_path, path)
+                    zipf.write(file_path, arcname)
+
+        client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, domain=FeishuDomain.FEISHU)
+        client.send_file(to=_current_chat_id, file=zip_path, file_name=zip_name)
+
+        # 删除临时文件
+        os.remove(zip_path)
+
+        return f"目录已打包并发送: {zip_name}"
+    except Exception as e:
+        return f"打包发送失败: {e}"
+
+
+# ===== 后台进程管理 =====
+
+def _output_reader(proc: subprocess.Popen, pid: int):
+    """后台线程:持续读取进程输出并存入缓冲区"""
+    try:
+        for line in iter(proc.stdout.readline, ''):
+            if pid not in _background_processes:
+                break
+            _background_processes[pid]["output_lines"].append(line.rstrip())
+            # 只保留最近 500 行
+            if len(_background_processes[pid]["output_lines"]) > 500:
+                _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
+    except Exception:
+        pass
+
+    # stderr 也读
+    try:
+        for line in iter(proc.stderr.readline, ''):
+            if pid not in _background_processes:
+                break
+            _background_processes[pid]["output_lines"].append(f"[stderr] {line.rstrip()}")
+            if len(_background_processes[pid]["output_lines"]) > 500:
+                _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
+    except Exception:
+        pass
+
+
+def _tool_run_background(command: str, name: str = "") -> str:
+    """后台启动命令"""
+    env = os.environ.copy()
+    venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "Scripts")
+    if not os.path.isdir(venv_scripts):
+        venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "bin")
+    env["PATH"] = venv_scripts + os.pathsep + env.get("PATH", "")
+    env["VIRTUAL_ENV"] = os.path.join(PROJECT_ROOT, ".venv")
+    env["PYTHONIOENCODING"] = "utf-8"
+    env["PYTHONUNBUFFERED"] = "1"  # 强制 Python 无缓冲输出
+    env["PYTHONIOENCODING"] = "utf-8"  # 强制 Python IO 编码为 utf-8
+
+    try:
+        proc = subprocess.Popen(
+            command, shell=True,
+            stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+            text=True, cwd=PROJECT_ROOT, env=env, encoding="utf-8", errors="replace",
+            bufsize=1,  # 行缓冲
+        )
+
+        from datetime import datetime
+        _background_processes[proc.pid] = {
+            "proc": proc,
+            "name": name or command[:40],
+            "command": command,
+            "output_lines": [],
+            "started_at": datetime.now().strftime("%H:%M:%S"),
+        }
+
+        # 启动输出读取线程
+        t = threading.Thread(target=_output_reader, args=(proc, proc.pid), daemon=True)
+        t.start()
+
+        return f"后台进程已启动: PID={proc.pid}, name={name or command[:40]}"
+    except Exception as e:
+        return f"启动失败: {e}"
+
+
+def _tool_stop_process(pid: int) -> str:
+    """停止后台进程"""
+    info = _background_processes.get(pid)
+    if not info:
+        return f"未找到 PID={pid} 的后台进程"
+
+    proc = info["proc"]
+    name = info["name"]
+    try:
+        if sys.platform == "win32":
+            subprocess.run(f"taskkill /F /T /PID {pid}", shell=True, capture_output=True, timeout=5)
+        else:
+            import signal
+            os.killpg(os.getpgid(pid), signal.SIGKILL)
+    except Exception:
+        proc.kill()
+
+    try:
+        proc.wait(timeout=5)
+    except Exception:
+        pass
+
+    _background_processes.pop(pid, None)
+    return f"已停止进程: PID={pid} ({name})"
+
+
+def _tool_list_processes() -> str:
+    """列出所有后台进程"""
+    if not _background_processes:
+        return "当前没有后台进程"
+
+    lines = []
+    for pid, info in _background_processes.items():
+        proc = info["proc"]
+        status = "运行中" if proc.poll() is None else f"已退出(code={proc.returncode})"
+        lines.append(f"  PID={pid} | {status} | {info['name']} | 启动于 {info['started_at']}")
+    return f"后台进程 ({len(lines)} 个):\n" + "\n".join(lines)
+
+
+def _tool_get_process_output(pid: int, tail: int = 50) -> str:
+    """获取后台进程的最新输出"""
+    info = _background_processes.get(pid)
+    if not info:
+        return f"未找到 PID={pid} 的后台进程"
+
+    output_lines = info["output_lines"]
+    proc = info["proc"]
+    status = "运行中" if proc.poll() is None else f"已退出(code={proc.returncode})"
+
+    if not output_lines:
+        return f"PID={pid} ({info['name']}) [{status}]: 暂无输出"
+
+    recent = output_lines[-tail:]
+    header = f"PID={pid} ({info['name']}) [{status}] 最近 {len(recent)} 行:\n"
+    return header + "\n".join(recent)
+
+
+def _tool_send_input(pid: int, text: str) -> str:
+    """向后台进程发送输入(通过控制文件)"""
+    info = _background_processes.get(pid)
+    if not info:
+        return f"未找到 PID={pid} 的后台进程"
+
+    proc = info["proc"]
+    if proc.poll() is not None:
+        return f"进程已退出,无法发送输入"
+
+    try:
+        # 使用控制文件方式(更可靠)
+        control_file = os.path.join(PROJECT_ROOT, ".agent_control")
+        with open(control_file, "w", encoding="utf-8") as f:
+            f.write(text.strip())
+        return f"已向 PID={pid} 发送控制指令: {text.strip()}"
+    except Exception as e:
+        return f"发送输入失败: {e}"
+        return f"发送输入失败: {e}"
+
+
+class FeishuAgent:
+    """飞书实时对话 Agent"""
+
+    def __init__(self):
+        self.client = FeishuClient(
+            app_id=FEISHU_APP_ID,
+            app_secret=FEISHU_APP_SECRET,
+            domain=FeishuDomain.FEISHU,
+        )
+        self.conversations: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
+        self._history_loaded: set = set()
+        self.loop = asyncio.new_event_loop()
+        self._loop_thread = threading.Thread(target=self._run_loop, daemon=True)
+        self._loop_thread.start()
+
+    def _run_loop(self):
+        asyncio.set_event_loop(self.loop)
+        self.loop.run_forever()
+
+    def _get_conversation_key(self, event: FeishuMessageEvent) -> str:
+        if event.chat_type and event.chat_type.value == "p2p":
+            return event.sender_open_id
+        return event.chat_id
+
+    def _build_messages(self, conv_key: str) -> List[Dict[str, Any]]:
+        messages = [{"role": "system", "content": SYSTEM_PROMPT}]
+        conv = self.conversations[conv_key]
+
+        # 验证并清理 tool_calls:确保每个 assistant+tool_calls 后面都有对应的 tool response
+        clean = []
+        i = 0
+        while i < len(conv):
+            msg = conv[i]
+            if msg.get("role") == "assistant" and msg.get("tool_calls"):
+                # 收集这个 assistant 消息的所有 tool_call_id
+                expected_ids = {tc.get("id") for tc in msg.get("tool_calls", [])}
+                # 检查后续消息是否有对应的 tool response
+                j = i + 1
+                found_ids = set()
+                while j < len(conv) and conv[j].get("role") == "tool":
+                    found_ids.add(conv[j].get("tool_call_id"))
+                    j += 1
+
+                # 只有所有 tool_call_id 都有对应 response 才保留
+                if expected_ids == found_ids:
+                    clean.append(msg)
+                    # 添加对应的 tool response
+                    for k in range(i + 1, j):
+                        clean.append(conv[k])
+                    i = j
+                else:
+                    # 不完整,跳过这组
+                    i = j
+            elif msg.get("role") == "tool":
+                # 孤立的 tool response,跳过
+                i += 1
+            else:
+                clean.append(msg)
+                i += 1
+
+        messages.extend(clean)
+        return messages
+
+    def _append_message(self, conv_key: str, role: str, content: Any, **extra):
+        msg = {"role": role, "content": content, **extra}
+        self.conversations[conv_key].append(msg)
+        if len(self.conversations[conv_key]) > MAX_HISTORY:
+            self.conversations[conv_key] = self.conversations[conv_key][-MAX_HISTORY:]
+
+    def _extract_content(self, event: FeishuMessageEvent) -> Optional[Any]:
+        """从飞书消息中提取内容(文本/图片/文件),返回 OpenAI 多模态格式"""
+        if event.content_type == "text":
+            try:
+                parsed = json.loads(event.content)
+                return parsed.get("text", event.content)
+            except (json.JSONDecodeError, TypeError):
+                return event.content
+
+        elif event.content_type == "image":
+            # 下载图片并转成 base64
+            try:
+                content_dict = json.loads(event.content)
+                image_key = content_dict.get("image_key")
+                if image_key and event.message_id:
+                    img_bytes = self.client.download_message_resource(
+                        message_id=event.message_id,
+                        file_key=image_key,
+                        resource_type="image"
+                    )
+                    b64_str = base64.b64encode(img_bytes).decode('utf-8')
+                    # 返回 OpenAI 多模态格式
+                    return [
+                        {"type": "text", "text": "[用户发送了一张图片]"},
+                        {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_str}"}}
+                    ]
+            except Exception as e:
+                logger.error(f"下载图片失败: {e}")
+                return "[图片下载失败]"
+
+        elif event.content_type == "file":
+            # 下载文件到本地
+            try:
+                content_dict = json.loads(event.content)
+                file_key = content_dict.get("file_key")
+                file_name = content_dict.get("file_name", "unknown_file")
+                if file_key and event.message_id:
+                    file_bytes = self.client.download_message_resource(
+                        message_id=event.message_id,
+                        file_key=file_key,
+                        resource_type="file"
+                    )
+                    # 保存到项目根目录的 downloads 文件夹
+                    download_dir = os.path.join(PROJECT_ROOT, "downloads")
+                    os.makedirs(download_dir, exist_ok=True)
+                    save_path = os.path.join(download_dir, file_name)
+                    with open(save_path, "wb") as f:
+                        f.write(file_bytes)
+                    return f"[用户发送了文件: {file_name},已保存到 {save_path}]"
+            except Exception as e:
+                logger.error(f"下载文件失败: {e}")
+                return "[文件下载失败]"
+
+        return None
+
+    def _load_history_from_disk(self, contact_name: str, conv_key: str):
+        if conv_key in self._history_loaded:
+            return
+        self._history_loaded.add(conv_key)
+        history = load_chat_history(contact_name)
+        for msg in history[-MAX_HISTORY:]:
+            role = msg.get("role", "user")
+            content = msg.get("content", "")
+            if isinstance(content, list):
+                text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
+                content = "\n".join(text_parts)
+            if isinstance(content, str) and content.strip():
+                self.conversations[conv_key].append({"role": role, "content": content})
+
+    def _save_to_disk(self, contact_name: str, conv_key: str):
+        # 保存完整对话历史,包括工具调用和结果
+        saveable = []
+        for m in self.conversations[conv_key]:
+            role = m.get("role")
+            content = m.get("content")
+
+            # 保存所有消息类型
+            if role in ("user", "assistant", "tool"):
+                msg = {"role": role}
+
+                # 处理 content
+                if isinstance(content, str):
+                    msg["content"] = content
+                elif isinstance(content, list):
+                    # 多模态消息:提取文本部分保存
+                    text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
+                    if text_parts:
+                        msg["content"] = "\n".join(text_parts)
+                    else:
+                        msg["content"] = "[多模态内容]"
+                else:
+                    msg["content"] = str(content) if content else ""
+
+                # 保存 tool_calls(如果有)
+                if "tool_calls" in m:
+                    msg["tool_calls"] = m["tool_calls"]
+
+                # 保存 tool_call_id(如果有)
+                if "tool_call_id" in m:
+                    msg["tool_call_id"] = m["tool_call_id"]
+
+                saveable.append(msg)
+
+        save_chat_history(contact_name, saveable)
+
+    def handle_message(self, event: FeishuMessageEvent):
+        global _current_chat_id
+        contact = get_contact_by_id(event.sender_open_id) or get_contact_by_id(event.chat_id)
+        if not contact:
+            logger.debug(f"忽略非白名单消息: {event.sender_open_id}")
+            return
+
+        sender_name = contact.get("name", "未知")
+        if sender_name not in ALLOWED_CONTACTS:
+            logger.debug(f"忽略非白名单联系人: {sender_name}")
+            return
+
+        user_content = self._extract_content(event)
+        if not user_content:
+            logger.info(f"[{sender_name}] 发送了不支持的消息类型,跳过")
+            return
+
+        # 记录消息类型
+        if isinstance(user_content, str):
+            logger.info(f"收到 [{sender_name}]: {user_content[:80]}")
+        else:
+            logger.info(f"收到 [{sender_name}]: [多模态消息]")
+
+        conv_key = self._get_conversation_key(event)
+        _current_chat_id = event.chat_id  # 设置全局变量供工具使用
+
+        self._load_history_from_disk(sender_name, conv_key)
+        self._append_message(conv_key, "user", user_content)
+        self._save_to_disk(sender_name, conv_key)
+
+        future = asyncio.run_coroutine_threadsafe(
+            self._generate_and_reply(conv_key, event, sender_name),
+            self.loop,
+        )
+        future.add_done_callback(self._on_reply_done)
+
+    async def _generate_and_reply(self, conv_key: str, event: FeishuMessageEvent, sender_name: str):
+        """调用 Qwen LLM,支持多轮工具调用循环"""
+        try:
+            last_tool_calls = []  # 记录最近的工具调用,用于检测循环
+            for round_i in range(MAX_TOOL_ROUNDS):
+                messages = self._build_messages(conv_key)
+
+                result = await qwen_llm_call(
+                    messages=messages,
+                    model=MODEL,
+                    tools=TOOLS,
+                    temperature=0.7,
+                    max_tokens=4096,
+                )
+
+                tool_calls = result.get("tool_calls")
+
+                if not tool_calls:
+                    # 没有工具调用,直接回复
+                    reply_text = result.get("content", "").strip()
+                    if not reply_text:
+                        reply_text = "(抱歉,我暂时无法生成回复)"
+
+                    self._append_message(conv_key, "assistant", reply_text)
+                    self._save_to_disk(sender_name, conv_key)
+
+                    self.client.send_message(
+                        to=event.chat_id,
+                        text=reply_text,
+                        receive_id_type=ReceiveIdType.CHAT_ID,
+                    )
+
+                    tokens_in = result.get("prompt_tokens", 0)
+                    tokens_out = result.get("completion_tokens", 0)
+                    cost = result.get("cost", 0)
+                    logger.info(
+                        f"回复 [{sender_name}]: {reply_text[:80]}... "
+                        f"(tokens: {tokens_in}+{tokens_out}, cost: ¥{cost:.4f})"
+                    )
+                    return
+
+                # 检测循环:如果连续 2 次调用相同工具(仅比较工具名),强制退出
+                current_call_names = [tc.get("function", {}).get("name") for tc in tool_calls]
+                last_tool_calls.append(current_call_names)
+                if len(last_tool_calls) >= 2 and last_tool_calls[-1] == last_tool_calls[-2]:
+                    logger.warning(f"[{sender_name}] 检测到工具调用循环: {current_call_names}")
+                    self._append_message(conv_key, "assistant", "(检测到重复调用,已停止)")
+                    self._save_to_disk(sender_name, conv_key)
+                    self.client.send_message(
+                        to=event.chat_id,
+                        text="⚠️ 检测到重复调用相同工具,已停止。请换一个方式或直接告诉我结果。",
+                        receive_id_type=ReceiveIdType.CHAT_ID,
+                    )
+                    return
+
+                # 有工具调用:追加 assistant 消息(含 tool_calls),然后执行
+                assistant_msg = {"role": "assistant", "content": result.get("content", "") or ""}
+                assistant_msg["tool_calls"] = tool_calls
+                self.conversations[conv_key].append(assistant_msg)
+
+                for tc in tool_calls:
+                    func = tc.get("function", {})
+                    tool_name = func.get("name", "")
+                    try:
+                        tool_args = json.loads(func.get("arguments", "{}"))
+                    except json.JSONDecodeError:
+                        tool_args = {}
+
+                    logger.info(f"[{sender_name}] 调用工具: {tool_name}({tool_args})")
+                    # 在线程池中执行工具,避免阻塞 event loop
+                    loop = asyncio.get_event_loop()
+                    tool_result = await loop.run_in_executor(
+                        None, execute_tool, tool_name, tool_args
+                    )
+
+                    # 截断过长的工具结果
+                    if len(tool_result) > 4000:
+                        tool_result = tool_result[:4000] + "\n... (结果已截断)"
+
+                    self.conversations[conv_key].append({
+                        "role": "tool",
+                        "tool_call_id": tc.get("id", ""),
+                        "content": tool_result,
+                    })
+
+            # 超过最大轮次
+            self._append_message(conv_key, "assistant", "(工具调用轮次过多,已停止)")
+            self._save_to_disk(sender_name, conv_key)
+            self.client.send_message(
+                to=event.chat_id,
+                text="⚠️ 工具调用轮次过多,已停止",
+                receive_id_type=ReceiveIdType.CHAT_ID,
+            )
+
+        except Exception as e:
+            logger.error(f"生成回复失败: {e}", exc_info=True)
+            try:
+                self.client.send_message(
+                    to=event.chat_id,
+                    text=f"⚠️ 生成回复时出错: {type(e).__name__}",
+                    receive_id_type=ReceiveIdType.CHAT_ID,
+                )
+            except Exception:
+                logger.error("发送错误消息也失败了", exc_info=True)
+
+    def _on_reply_done(self, future):
+        exc = future.exception()
+        if exc:
+            logger.error(f"回复任务异常: {exc}", exc_info=exc)
+
+    def start(self):
+        logger.info(f"启动飞书对话 Agent (model={MODEL})")
+        logger.info("等待飞书消息... 按 Ctrl+C 退出")
+
+        try:
+            self.client.start_websocket(
+                on_message=self.handle_message,
+                blocking=True,
+            )
+        except KeyboardInterrupt:
+            logger.info("Agent 已停止")
+        finally:
+            self.loop.call_soon_threadsafe(self.loop.stop)
+
+
+if __name__ == "__main__":
+    agent = FeishuAgent()
+    agent.start()

+ 945 - 0
agent/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/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()

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

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

+ 531 - 0
agent/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"], groups=["core"])
+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/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/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"], groups=["core"])
+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

+ 125 - 0
agent/agent/tools/builtin/file/image_cdn.py

@@ -0,0 +1,125 @@
+"""
+image_cdn.py - 将内容中的外站图片 URL 转换为自有 CDN 链接
+
+使用场景:在 write_json / write_file 落盘前调用,自动把
+  小红书、B站、知乎、微博等平台的图片链接替换为 res.cybertogether.net CDN 链接,
+  防止外站图片过期/防盗链导致后续流程无法访问。
+"""
+
+import hashlib
+import json
+import logging
+import re
+from typing import Any, Union
+import httpx
+
+logger = logging.getLogger(__name__)
+
+# ── 匹配需要转存的外站图片 URL(只抓图片后缀或明显的图床域名)──────────────────
+_IMG_URL_RE = re.compile(
+    r'https?://(?!'  # 排除自有 CDN,不重复上传
+    r'res\.cybertogether\.net'
+    r')'
+    r'[^\s"\'<>]+'  # URL 主体
+    r'(?:'
+        r'\.(?:jpg|jpeg|png|gif|webp|avif|bmp|svg)'  # 明确图片后缀
+        r'|'
+        r'(?:xhscdn\.com|bdimg\.com|hdslb\.com|zhihu\.com/p/[^/]+\.(?:jpg|png)'
+        r'|sinaimg\.cn|wx\d+\.sinaimg\.cn|mmbiz\.qpic\.cn'  # 微博/微信图床
+        r'|imagev[12]\.meitudata\.com'  # 美图
+        r'|p[0-9]\.douyinpic\.com'  # 抖音封面
+        r'|i0\.hdslb\.com|article\.biliimg\.com'  # B站
+        r')'
+    r')',
+    re.IGNORECASE,
+)
+
+BUCKET_NAME = "aigc-admin"
+BUCKET_PATH = "crawler/image"
+CDN_BASE = "https://res.cybertogether.net"
+
+
+async def _upload_bytes_to_oss(data: bytes, filename: str) -> str:
+    """上传 bytes 到 OSS 并返回 CDN URL,失败时抛出异常"""
+    from cyber_sdk.ali_oss import _upload_v2
+    result = await _upload_v2(
+        file_name=filename,
+        file_content=data,
+        bucket_path=BUCKET_PATH,
+        bucket_name=BUCKET_NAME,
+    )
+    oss_key = result.get("oss_object_key")
+    if not oss_key:
+        raise ValueError(f"OSS response missing oss_object_key: {result}")
+    return f"{CDN_BASE}/{oss_key}"
+
+
+async def _download_image(url: str) -> bytes:
+    """下载图片 bytes,失败抛出异常"""
+    async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
+        resp = await client.get(url, headers={"Referer": url, "User-Agent": "Mozilla/5.0"})
+        resp.raise_for_status()
+        return resp.content
+
+
+def _ext_from_url(url: str) -> str:
+    """从 URL 猜测文件扩展名,默认 jpg"""
+    path = url.split("?")[0].lower()
+    for ext in ("png", "gif", "webp", "avif", "bmp", "svg", "jpg", "jpeg"):
+        if path.endswith(f".{ext}"):
+            return ext
+    return "jpg"
+
+
+async def replace_image_urls(text: str) -> str:
+    """
+    扫描 text 中所有外站图片 URL,下载并上传到自有 OSS,原地替换为 CDN 链接。
+
+    - 已是 res.cybertogether.net 的 URL 直接跳过
+    - 下载/上传失败的 URL 保留原值,只打 WARNING 日志
+    - 同一 URL 使用 MD5 去重(同一次调用内)
+    """
+    urls = list(dict.fromkeys(_IMG_URL_RE.findall(text)))  # 去重但保留顺序
+    if not urls:
+        return text
+
+    import asyncio
+
+    async def _process_single_url(url: str) -> tuple[str, str]:
+        url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
+        ext = _ext_from_url(url)
+        filename = f"{url_hash}.{ext}"
+        try:
+            data = await _download_image(url)
+            cdn_url = await _upload_bytes_to_oss(data, filename)
+            logger.info("[ImageCDN] %s → %s", url[:60], cdn_url)
+            return url, cdn_url
+        except Exception as e:
+            logger.warning("[ImageCDN] Failed to mirror %s: %s (%s)", url[:60], str(e) or repr(e), type(e).__name__)
+            return url, url
+
+    tasks = [_process_single_url(u) for u in urls]
+    results = await asyncio.gather(*tasks)
+    url_map: dict[str, str] = dict(results)
+
+    for orig, cdn in url_map.items():
+        if orig != cdn:
+            text = text.replace(orig, cdn)
+
+    replaced = sum(1 for o, c in url_map.items() if o != c)
+    if replaced:
+        logger.info("[ImageCDN] Replaced %d/%d image URLs with CDN links", replaced, len(urls))
+
+    return text
+
+
+async def replace_image_urls_in_obj(obj: Any) -> Any:
+    """
+    递归扫描 dict/list 中所有字符串值,替换其中的外站图片 URL。
+    先整体 JSON 序列化再做替换,再反序列化,效率高且避免遗漏嵌套字段。
+    """
+    raw = json.dumps(obj, ensure_ascii=False)
+    replaced = await replace_image_urls(raw)
+    if replaced == raw:
+        return obj
+    return json.loads(replaced)

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

@@ -0,0 +1,322 @@
+"""
+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"], groups=["core"])
+async def read_file(
+    file_path: str,
+    offset: int = 0,
+    limit: int = DEFAULT_READ_LIMIT,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    读取单个文件内容
+
+    用于读取一个文本文件、PDF 或一张图片。如需批量读取多张图片(2 张以上)
+    并做对比/选图,请使用 read_images 工具,它支持自动降采样和网格拼图。
+
+    参考 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)
+        )

+ 321 - 0
agent/agent/tools/builtin/file/read_images.py

@@ -0,0 +1,321 @@
+"""
+Read Images Tool - 批量读取图片工具
+
+为"批量读取 + 多图分析"场景设计的工具,与单文件的 read_file 分工:
+- read_file: 单个文件(文本 / PDF / 单张图片)
+- read_images: 2 张以上图片,支持网格拼图和降采样
+
+核心能力:
+1. 并发批量加载本地路径或 URL
+2. 自动降采样,防止 token 爆炸
+3. 可选拼图(grid 模式),把 N 张图合成一张带索引编号的网格图,
+   适合 LLM 横向对比、选图、批量判断场景
+4. 自适应布局 + 硬上限,保证拼图即使经过 LLM 内部缩放也能保持可辨
+"""
+
+from typing import Any, Dict, List, Literal, Optional, Tuple
+
+from agent.tools import tool, ToolResult, ToolContext
+from agent.tools.utils.image import (
+    build_image_grid,
+    downscale,
+    encode_base64,
+    load_images,
+)
+
+
+# Grid 模式的硬上限:超过此数量必须分批调用
+# 理由:12 张可排成 4x3 网格,每格 ~320px,人物/场景细节清晰可辨。
+# 再多格子就太小,分辨不出内容,失去对比价值。
+MAX_GRID_IMAGES = 12
+
+
+def _adaptive_layout(count: int) -> Tuple[int, int]:
+    """根据图片数量自动选择 (columns, thumb_size)。
+
+    目标:拼图最终边长不超过 ~1400px,同时每格缩略图保持 >= 320px 以保证可辨认。
+
+    Returns:
+        (columns, thumb_size)
+    """
+    if count <= 2:
+        return 2, 500   # 2x1
+    if count <= 4:
+        return 2, 450   # 2x2
+    if count <= 6:
+        return 3, 400   # 3x2
+    if count <= 9:
+        return 3, 380   # 3x3
+    # 10-12
+    return 4, 320       # 4x3
+
+
+@tool(
+    description="批量读取多张图片,支持自动降采样和网格拼图(用于横向对比/选图场景)",
+    hidden_params=["context"],
+    display={
+        "zh": {
+            "name": "批量读取图片",
+            "params": {
+                "paths": "图片路径列表",
+                "layout": "布局模式",
+                "max_dimension": "每张图最大边长",
+            },
+        },
+        "en": {
+            "name": "Read Images",
+            "params": {
+                "paths": "Image paths",
+                "layout": "Layout mode",
+                "max_dimension": "Max dimension per image",
+            },
+        },
+    },
+    groups=["core"],
+)
+async def read_images(
+    paths: List[str],
+    layout: Literal["grid", "separate"] = "grid",
+    max_dimension: int = 1024,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """批量读取图片并返回给 LLM,支持自动降采样和网格拼图
+
+    为 **2 张以上** 的图片批量分析场景设计。单张图片请用 `read_file`。
+
+    ⚠️ **grid 模式最多 12 张**。超过请分批调用:第一次传前 12 张,第二次传后续,
+    以此类推。再多每格就太小,分辨不出内容。
+
+    两种布局模式:
+
+    - **grid**(默认):把所有图片拼成一张只带索引编号的网格图(1,2,3…)。
+      LLM 只看到 1 张拼图,大幅减少结构开销 token。索引对应的原始路径见
+      返回文本的对照表,LLM 可以用"第 3 张"来引用具体图片。
+      **自适应布局**:根据图片数量自动选择列数和缩略图尺寸,小批量时每张图更清晰:
+        * 1-2 张:2 列 × 500px
+        * 3-4 张:2 列 × 450px
+        * 5-6 张:3 列 × 400px
+        * 7-9 张:3 列 × 380px
+        * 10-12 张:4 列 × 320px
+      适合:从多张候选图中挑选、横向对比质量/风格、批量判断。
+
+    - **separate**:把每张图独立返回(仍然降采样)。无数量限制,但每张图都有
+      独立的结构开销 token。适合:
+        * 需要逐张做独立的精细分析
+        * 每张图之间没有对比关系
+
+    自动降采样:无论哪种模式,每张图都会先降采样到 max_dimension(默认 1024px)
+    的最大边长,防止高分辨率图片炸掉 token 预算。
+
+    Args:
+        paths: 图片路径列表,支持本地路径和 HTTP(S) URL,可混用。
+               grid 模式下不超过 12 张,超过必须分批调用。
+        layout: 布局模式,"grid" 拼图(默认)/ "separate" 多张独立
+        max_dimension: 每张图的最大边长(等比降采样到不超过此值),默认 1024
+        context: 工具上下文(框架注入,无需手动传)
+
+    Returns:
+        ToolResult:images 字段包含图片数据(grid 模式 1 张拼图,separate 模式 N 张),
+        output 字段包含每张图的索引和来源路径对照表
+    """
+    if not paths:
+        return ToolResult(
+            title="批量读图失败",
+            output="",
+            error="paths 不能为空",
+        )
+
+    # 硬上限检查(仅对 grid 模式)
+    if layout == "grid" and len(paths) > MAX_GRID_IMAGES:
+        return ToolResult(
+            title="批量读图失败",
+            output="",
+            error=(
+                f"grid 模式最多支持 {MAX_GRID_IMAGES} 张图片,当前传入 {len(paths)} 张。"
+                f"请分批调用:每次最多 {MAX_GRID_IMAGES} 张。"
+                f"或者使用 layout='separate' 模式(无数量限制但 token 开销更高)。"
+            ),
+        )
+
+    if len(paths) == 1:
+        hint = "(只有 1 张图片,建议用 read_file 更合适)"
+    else:
+        hint = ""
+
+    # 1. 并发加载所有图片
+    loaded = await load_images(paths)
+
+    # 2. 分离成功和失败
+    successes: List[tuple] = []  # [(path, PIL.Image), ...]
+    failures: List[str] = []     # [path, ...]
+    for source, img in loaded:
+        if img is None:
+            failures.append(source)
+        else:
+            successes.append((source, img))
+
+    if not successes:
+        return ToolResult(
+            title="批量读图失败",
+            output="",
+            error=f"所有 {len(paths)} 张图片均加载失败",
+            metadata={"failed": failures},
+        )
+
+    # 3. 每张图降采样
+    processed = [(src, downscale(img, max_dimension)) for src, img in successes]
+
+    # 4. 构建索引 → 路径对照表(用完整路径,方便 LLM 后续引用或调用)
+    index_lines = [f"{i}. {src}" for i, (src, _) in enumerate(processed, start=1)]
+    summary_parts = [f"共加载 {len(processed)}/{len(paths)} 张图片"]
+    if hint:
+        summary_parts.append(hint)
+    if failures:
+        summary_parts.append(f",失败 {len(failures)} 张")
+    summary = "".join(summary_parts)
+
+    output_lines = [summary, ""] + index_lines
+    if failures:
+        output_lines.append("")
+        output_lines.append("加载失败的路径:")
+        output_lines.extend(f"  - {p}" for p in failures)
+    output_text = "\n".join(output_lines)
+
+    # 5. 根据 layout 生成 images 字段
+    images_for_llm = []
+    if layout == "grid":
+        cols, thumb_size = _adaptive_layout(len(processed))
+        # 网格只显示序号,不写文件名 —— 索引对应的路径见上方 output 文本
+        grid = build_image_grid(
+            images=[img for _, img in processed],
+            labels=None,
+            columns=cols,
+            thumb_size=thumb_size,
+        )
+        # 网格拼图固定用 JPEG 节省 token
+        b64, media_type = encode_base64(grid, format="JPEG", quality=80)
+        images_for_llm.append({
+            "type": "base64",
+            "media_type": media_type,
+            "data": b64,
+        })
+    else:  # separate
+        for _, img in processed:
+            b64, media_type = encode_base64(img, format="JPEG", quality=80)
+            images_for_llm.append({
+                "type": "base64",
+                "media_type": media_type,
+                "data": b64,
+            })
+
+    return ToolResult(
+        title=f"批量读图成功({layout} 模式,{len(processed)} 张)",
+        output=output_text,
+        long_term_memory=f"Read {len(processed)} images via {layout} layout",
+        images=images_for_llm,
+        metadata={
+            "count": len(processed),
+            "failed_count": len(failures),
+            "layout": layout,
+        },
+    )
+
+
+# ── CLI 入口:图片拼图工具 ──
+#
+# 这个 CLI 的语义是**拼图工具**,不是"读图工具"——Claude Code 这样的调用方
+# 本身就能读单张图(用 Read 工具),真正稀缺的能力是把 N 张图合成一张
+# 带索引编号的网格图,让一次 Read 就能横向对比多张。
+#
+# 因此 CLI 只支持 grid 模式;如果你需要单张图,直接用 Read 工具即可。
+#
+# 用法:
+#   python agent/tools/builtin/file/read_images.py --out=<path> <img1> <img2> ...
+#
+# 必填参数:
+#   --out=/path/grid.jpg     拼图保存路径(必须显式指定,避免污染 /tmp)
+#
+# 可选参数:
+#   --max_dimension=1024     每张图预先降采样的最大边长(默认 1024)
+#
+# 示例:
+#   python agent/tools/builtin/file/read_images.py \
+#     --out=/tmp/compare.jpg \
+#     ~/Downloads/a.jpg ~/Downloads/b.jpg ~/Downloads/c.jpg
+#
+# 输出:一行 JSON,包含 out_path、index_map(索引→原始路径对照表)、
+# text(文字摘要)。调用方拿到 out_path 后用 Read 工具查看拼图即可。
+
+if __name__ == "__main__":
+    import base64
+    import json
+    import sys
+    from pathlib import Path as _Path
+
+    def _print_usage():
+        print("用法: python read_images.py --out=<path> <img1> <img2> ...")
+        print("     --out=/path/grid.jpg   拼图输出路径(必填)")
+        print("     --max_dimension=1024   每张图最大边长(可选,默认 1024)")
+        print(f"最多 {MAX_GRID_IMAGES} 张图片")
+
+    if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
+        _print_usage()
+        sys.exit(0)
+
+    # 解析参数
+    cli_paths: List[str] = []
+    cli_out: Optional[str] = None
+    cli_max_dim: int = 1024
+    for arg in sys.argv[1:]:
+        if arg.startswith("--") and "=" in arg:
+            k, v = arg.split("=", 1)
+            k = k.lstrip("-").replace("-", "_")
+            if k == "out":
+                cli_out = v
+            elif k == "max_dimension":
+                cli_max_dim = int(v)
+            else:
+                print(f"警告: 未知参数 {k}", file=sys.stderr)
+        else:
+            cli_paths.append(arg)
+
+    if not cli_paths:
+        print("错误: 至少提供一个图片路径", file=sys.stderr)
+        _print_usage()
+        sys.exit(1)
+
+    if not cli_out:
+        print("错误: 必须显式指定 --out=<path>", file=sys.stderr)
+        _print_usage()
+        sys.exit(1)
+
+    import asyncio
+    result = asyncio.run(read_images(
+        paths=cli_paths,
+        layout="grid",
+        max_dimension=cli_max_dim,
+    ))
+
+    if result.error:
+        print(json.dumps({"error": result.error}, ensure_ascii=False, indent=2))
+        sys.exit(1)
+
+    # 写入拼图文件
+    out_p = _Path(cli_out)
+    out_p.parent.mkdir(parents=True, exist_ok=True)
+    out_p.write_bytes(base64.b64decode(result.images[0]["data"]))
+
+    # 解析索引 → 原始路径对照表
+    index_map: List[Dict[str, Any]] = []
+    for line in result.output.split("\n"):
+        if line and line[0].isdigit() and ". " in line:
+            idx_str, src = line.split(". ", 1)
+            if idx_str.isdigit():
+                index_map.append({"index": int(idx_str), "source": src})
+
+    print(json.dumps({
+        "out_path": str(out_p.resolve()),
+        "count": result.metadata.get("count", 0) if result.metadata else 0,
+        "index_map": index_map,
+        "text": result.output,
+    }, ensure_ascii=False, indent=2))

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

@@ -0,0 +1,138 @@
+"""
+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"], groups=["core"])
+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)
+
+    # 落盘前自动将内容中的外站图片 URL 替换为自有 CDN 链接(仅处理文本文件)
+    if not append:  # 追加模式不做替换,避免重复处理
+        try:
+            from agent.tools.builtin.file.image_cdn import replace_image_urls
+            new_content = await replace_image_urls(new_content)
+        except Exception as cdn_err:
+            import logging
+            logging.getLogger(__name__).warning("[write_file] CDN mirror step failed, writing original: %s", cdn_err)
+
+    # 写入文件
+    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)

+ 99 - 0
agent/agent/tools/builtin/file/write_json.py

@@ -0,0 +1,99 @@
+"""
+Write JSON Tool - 用于直接安全写入结构化 JSON 数据,避免大模型生成超长转义文本导致的截断和报错
+"""
+
+import json
+from pathlib import Path
+from typing import Optional, Dict, Any
+
+from agent.tools import tool, ToolResult, ToolContext
+
+
+@tool(description="专门且唯一安全的 JSON 数据文件写入工具。传入 Python Dict/Object,自动为你生成格式化和转义无误的 JSON 文件。严禁使用普通的 write_file 写 JSON。参数 file_path 是文件绝对路径字符串,json_data 是要写入的原生 JSON 对象(直接传 dict,无需提前序列化)", hidden_params=["context"], groups=["core"])
+async def write_json(
+    file_path: str = "",
+    json_data: dict = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    # 参数防空保护 - 给出明确错误而非空报错
+    if not file_path:
+        return ToolResult(
+            title="参数错误",
+            output="请提供 file_path 参数:file_path 必须是一个有效的绝对文件路径字符串,例如 '/path/to/output.json'",
+            error="Missing required argument: file_path"
+        )
+    if json_data is None:
+        return ToolResult(
+            title="参数错误",
+            output="请提供 json_data 参数:json_data 必须是一个原生的 Python dict 对象,请将要写入的 JSON 结构直接作为对象传入,无需序列化为字符串",
+            error="Missing required argument: json_data"
+        )
+    """
+    专门安全地写入结构化 JSON 数据到文件。
+
+    Args:
+        file_path: 要写入的绝对或相对文件路径。
+        json_data: 要写入的数据对象 (object/dict)。
+        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"
+        )
+
+    path.parent.mkdir(parents=True, exist_ok=True)
+
+    # 自动检测并修正类型:如果模型传进来的是 JSON 字符串,先反序列化再写入
+    # 这解决了模型把 dict 当成 JSON 字符串传入导致的双重序列化问题
+    if isinstance(json_data, str):
+        try:
+            json_data = json.loads(json_data)
+        except json.JSONDecodeError as e:
+            return ToolResult(
+                title="JSON 解析失败",
+                output=f"json_data 参数作为 JSON 字符串解析失败,错误在这附近:{e}\n\n"
+                       f"[系统强制警告] 你的 JSON 生成存在语法错误(很可能是 description 中的未转义双引号、换行符,或者缺少逗号)。\n"
+                       f"⚠️ 严禁使用 write_file 回退硬写!用 write_file 强行写入坏 JSON 会导致整个流水线后续崩溃死锁!\n"
+                       f"你必须立即检查并修复字符串内的转义问题,然后重新调用 write_json!",
+                error=str(e)
+            )
+
+    try:
+        # 落盘前自动将 JSON 数据中的外站图片 URL 替换为自有 CDN 链接
+        try:
+            from agent.tools.builtin.file.image_cdn import replace_image_urls_in_obj
+            json_data = await replace_image_urls_in_obj(json_data)
+        except Exception as cdn_err:
+            import logging
+            logging.getLogger(__name__).warning("[write_json] CDN mirror step failed, writing original: %s", cdn_err)
+
+        with open(path, 'w', encoding='utf-8') as f:
+            json.dump(json_data, f, ensure_ascii=False, indent=2)
+            
+        json_str = json.dumps(json_data, ensure_ascii=False, indent=2)
+        lines = len(json_str.split('\n'))
+
+        return ToolResult(
+            title=path.name,
+            output=f"JSON 数据已安全且完美地格式化写入: {path.name}",
+            metadata={
+                "lines": lines,
+                "existed": path.exists()
+            },
+            long_term_memory=f"安全覆盖写入了结构化 JSON 文件 {path.name}"
+        )
+    except Exception as e:
+        return ToolResult(
+            title="JSON 写入失败",
+            output=f"无法写入 JSON 到文件: {str(e)}",
+            error=str(e)
+        )

+ 108 - 0
agent/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"], groups=["core"])
+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)
+        )

+ 17 - 0
agent/agent/tools/builtin/im/__init__.py

@@ -0,0 +1,17 @@
+from agent.tools.builtin.im.chat import (
+    im_setup,
+    im_check_notification,
+    im_receive_messages,
+    im_send_message,
+    im_get_contacts,
+    im_get_chat_history,
+)
+
+__all__ = [
+    "im_setup",
+    "im_check_notification",
+    "im_receive_messages",
+    "im_send_message",
+    "im_get_contacts",
+    "im_get_chat_history",
+]

+ 337 - 0
agent/agent/tools/builtin/im/chat.py

@@ -0,0 +1,337 @@
+"""IM 工具 — 将 im-client 接入 Agent 框架。
+
+新架构:一个 Agent (contact_id) = 一个 IMClient 实例,该实例管理多个窗口 (chat_id)。
+"""
+
+import asyncio
+import json
+import logging
+import os
+import sys
+from typing import Optional
+
+from agent.tools import tool, ToolResult, ToolContext
+
+# 将 im-client 目录加入 sys.path
+_IM_CLIENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "im-client"))
+if _IM_CLIENT_DIR not in sys.path:
+    sys.path.insert(0, _IM_CLIENT_DIR)
+
+from client import IMClient  # noqa: E402
+from notifier import AgentNotifier  # noqa: E402
+
+logger = logging.getLogger(__name__)
+
+# ── 全局状态 ──
+
+_clients: dict[str, IMClient] = {}
+_tasks: dict[str, asyncio.Task] = {}
+_notifications: dict[tuple[str, str], dict] = {}  # (contact_id, chat_id) -> 通知
+
+
+class _ToolNotifier(AgentNotifier):
+    """内部通知器:按 (contact_id, chat_id) 分发通知。"""
+
+    def __init__(self, contact_id: str, chat_id: str):
+        self._key = (contact_id, chat_id)
+
+    async def notify(self, count: int, from_contacts: list[str]):
+        _notifications[self._key] = {"count": count, "from": from_contacts}
+
+
+# ── Tool 1: 初始化连接 ──
+
+@tool(
+    hidden_params=["context"],
+    groups=["im"],
+    display={
+        "zh": {"name": "初始化 IM 连接", "params": {"contact_id": "你的身份 ID", "server_url": "服务器地址"}},
+        "en": {"name": "Setup IM Connection", "params": {"contact_id": "Your identity ID", "server_url": "Server URL"}},
+    }
+)
+async def im_setup(
+    contact_id: str,
+    server_url: str = "ws://localhost:8000",
+    notify_interval: float = 10.0,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """初始化 IM Client 并连接 Server。
+
+    Args:
+        contact_id: 你在 IM 系统中的身份 ID
+        server_url: Server 的 WebSocket 地址
+        notify_interval: 检查新消息的间隔秒数
+    """
+    if contact_id in _clients:
+        return ToolResult(title="IM 已连接", output=f"已连接: {contact_id}")
+
+    client = IMClient(contact_id=contact_id, server_url=server_url, notify_interval=notify_interval)
+    _clients[contact_id] = client
+
+    loop = asyncio.get_event_loop()
+    _tasks[contact_id] = loop.create_task(client.run())
+
+    # 等待一小段时间,让连接尝试开始(非阻塞)
+    await asyncio.sleep(0.5)
+
+    return ToolResult(title="IM 连接成功", output=f"已启动 IM Client: {contact_id},后台连接中...")
+
+
+# ── Tool 2: 窗口管理 ──
+
+@tool(
+    hidden_params=["context"],
+    groups=["im"],
+    display={
+        "zh": {"name": "打开 IM 窗口", "params": {"contact_id": "Agent ID", "chat_id": "窗口 ID"}},
+        "en": {"name": "Open IM Window", "params": {"contact_id": "Agent ID", "chat_id": "Window ID"}},
+    }
+)
+async def im_open_window(
+    contact_id: str,
+    chat_id: str | None = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """打开一个新的聊天窗口。
+
+    Args:
+        contact_id: Agent ID
+        chat_id: 窗口 ID(留空自动生成)
+    """
+    client = _clients.get(contact_id)
+    if client is None:
+        return ToolResult(title="未连接", output=f"错误: {contact_id} 未初始化", error="未初始化")
+
+    actual_chat_id = client.open_window(chat_id=chat_id, notifier=_ToolNotifier(contact_id, chat_id or ""))
+    if chat_id is None:
+        client._notifiers[actual_chat_id] = _ToolNotifier(contact_id, actual_chat_id)
+
+    return ToolResult(title="窗口已打开", output=f"窗口 ID: {actual_chat_id}")
+
+
+@tool(
+    hidden_params=["context"],
+    groups=["im"],
+    display={
+        "zh": {"name": "关闭 IM 窗口", "params": {"contact_id": "Agent ID", "chat_id": "窗口 ID"}},
+        "en": {"name": "Close IM Window", "params": {"contact_id": "Agent ID", "chat_id": "Window ID"}},
+    }
+)
+async def im_close_window(
+    contact_id: str,
+    chat_id: str,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """关闭一个聊天窗口。
+
+    Args:
+        contact_id: Agent ID
+        chat_id: 窗口 ID
+    """
+    client = _clients.get(contact_id)
+    if client is None:
+        return ToolResult(title="未连接", output=f"错误: {contact_id} 未初始化", error="未初始化")
+
+    client.close_window(chat_id)
+    _notifications.pop((contact_id, chat_id), None)
+    return ToolResult(title="窗口已关闭", output=f"已关闭窗口: {chat_id}")
+
+
+# ── Tool 3: 检查通知 ──
+
+@tool(
+    hidden_params=["context"],
+    groups=["im"],
+    display={
+        "zh": {"name": "检查 IM 新消息通知", "params": {"contact_id": "Agent ID", "chat_id": "窗口 ID"}},
+        "en": {"name": "Check IM Notifications", "params": {"contact_id": "Agent ID", "chat_id": "Window ID"}},
+    }
+)
+async def im_check_notification(
+    contact_id: str,
+    chat_id: str,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """检查某个窗口是否有新消息通知。
+
+    Args:
+        contact_id: Agent ID
+        chat_id: 窗口 ID
+    """
+    notification = _notifications.pop((contact_id, chat_id), None)
+    if notification is None:
+        return ToolResult(title="无新消息", output="当前没有新消息通知")
+
+    return ToolResult(
+        title=f"有 {notification['count']} 条新消息",
+        output=json.dumps(notification, ensure_ascii=False),
+        metadata=notification,
+    )
+
+
+# ── Tool 4: 接收消息 ──
+
+@tool(
+    hidden_params=["context"],
+    groups=["im"],
+    display={
+        "zh": {"name": "接收 IM 消息", "params": {"contact_id": "Agent ID", "chat_id": "窗口 ID"}},
+        "en": {"name": "Receive IM Messages", "params": {"contact_id": "Agent ID", "chat_id": "Window ID"}},
+    }
+)
+async def im_receive_messages(
+    contact_id: str,
+    chat_id: str,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """读取某个窗口的待处理消息。
+
+    Args:
+        contact_id: Agent ID
+        chat_id: 窗口 ID
+    """
+    client = _clients.get(contact_id)
+    if client is None:
+        return ToolResult(title="未连接", output=f"错误: {contact_id} 未初始化", error="未初始化")
+
+    raw = client.read_pending(chat_id)
+    if not raw:
+        return ToolResult(title="无待处理消息", output="[]")
+
+    messages = [
+        {
+            "sender": m.get("sender", "unknown"),
+            "sender_chat_id": m.get("sender_chat_id"),
+            "content": m.get("content", ""),
+            "msg_type": m.get("msg_type", "chat"),
+        }
+        for m in raw
+    ]
+    return ToolResult(
+        title=f"收到 {len(messages)} 条消息",
+        output=json.dumps(messages, ensure_ascii=False, indent=2),
+        metadata={"messages": messages},
+    )
+
+
+# ── Tool 5: 发送消息 ──
+
+@tool(
+    hidden_params=["context"],
+    groups=["im"],
+    display={
+        "zh": {"name": "发送 IM 消息", "params": {"contact_id": "Agent ID", "chat_id": "窗口 ID", "receiver": "接收者 ID", "content": "消息内容"}},
+        "en": {"name": "Send IM Message", "params": {"contact_id": "Agent ID", "chat_id": "Window ID", "receiver": "Receiver ID", "content": "Message content"}},
+    }
+)
+async def im_send_message(
+    contact_id: str,
+    chat_id: str,
+    receiver: str,
+    content: str,
+    msg_type: str = "chat",
+    receiver_chat_id: str | None = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """从某个窗口发送消息。
+
+    Args:
+        contact_id: 发送方 Agent ID
+        chat_id: 发送方窗口 ID
+        receiver: 接收方 contact_id
+        content: 消息内容
+        msg_type: 消息类型
+        receiver_chat_id: 接收方窗口 ID(不指定则广播)
+    """
+    client = _clients.get(contact_id)
+    if client is None:
+        return ToolResult(title="未连接", output=f"错误: {contact_id} 未初始化", error="未初始化")
+
+    client.send_message(chat_id, receiver, content, msg_type, receiver_chat_id)
+    target = f"{receiver}:{receiver_chat_id}" if receiver_chat_id else f"{receiver}:*"
+    return ToolResult(
+        title=f"已发送给 {target}",
+        output=f"[{contact_id}:{chat_id}] 已发送给 {target}: {content[:50]}",
+    )
+
+
+# ── Tool 6: 查询联系人 ──
+
+@tool(
+    hidden_params=["context"],
+    groups=["im"],
+    display={
+        "zh": {"name": "查询 IM 联系人", "params": {"contact_id": "Agent ID", "server_http_url": "服务器 HTTP 地址"}},
+        "en": {"name": "Get IM Contacts", "params": {"contact_id": "Agent ID", "server_http_url": "Server HTTP URL"}},
+    }
+)
+async def im_get_contacts(
+    contact_id: str,
+    server_http_url: str = "http://localhost:8000",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """查询联系人列表和当前在线用户。
+
+    Args:
+        contact_id: Agent ID
+        server_http_url: Server 的 HTTP 地址
+    """
+    if contact_id not in _clients:
+        return ToolResult(title="未连接", output=f"错误: {contact_id} 未初始化", error="未初始化")
+
+    import httpx
+    result = {}
+    async with httpx.AsyncClient() as http:
+        try:
+            r = await http.get(f"{server_http_url}/contacts/{contact_id}")
+            result["contacts"] = r.json().get("contacts", [])
+        except Exception as e:
+            result["contacts_error"] = str(e)
+        try:
+            r = await http.get(f"{server_http_url}/health")
+            result["online"] = r.json().get("online", {})
+        except Exception as e:
+            result["online_error"] = str(e)
+
+    return ToolResult(
+        title="联系人查询完成",
+        output=json.dumps(result, ensure_ascii=False, indent=2),
+        metadata=result,
+    )
+
+
+# ── Tool 7: 查询聊天历史 ──
+
+@tool(
+    hidden_params=["context"],
+    groups=["im"],
+    display={
+        "zh": {"name": "查询 IM 聊天历史", "params": {"contact_id": "Agent ID", "chat_id": "窗口 ID", "peer_id": "联系人 ID", "limit": "最大条数"}},
+        "en": {"name": "Get IM Chat History", "params": {"contact_id": "Agent ID", "chat_id": "Window ID", "peer_id": "Contact ID", "limit": "Max records"}},
+    }
+)
+async def im_get_chat_history(
+    contact_id: str,
+    chat_id: str,
+    peer_id: str | None = None,
+    limit: int = 20,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """查询某个窗口的聊天历史。
+
+    Args:
+        contact_id: Agent ID
+        chat_id: 窗口 ID
+        peer_id: 筛选与某个联系人的聊天(留空返回所有)
+        limit: 最多返回条数
+    """
+    client = _clients.get(contact_id)
+    if client is None:
+        return ToolResult(title="未连接", output=f"错误: {contact_id} 未初始化", error="未初始化")
+
+    messages = client.get_chat_history(chat_id, peer_id, limit)
+    return ToolResult(
+        title=f"查到 {len(messages)} 条记录",
+        output=json.dumps(messages, ensure_ascii=False, indent=2),
+        metadata={"messages": messages},
+    )

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

@@ -0,0 +1,977 @@
+"""
+知识管理工具 - KnowHub API 封装
+
+所有工具通过 HTTP API 调用 KnowHub Server,直接读写底层数据库。
+"""
+
+import os
+import json
+import logging
+import subprocess
+import uuid
+import httpx
+from dataclasses import dataclass
+from typing import List, Dict, Optional, Any
+from agent.tools import tool, ToolResult, ToolContext
+from agent.core.prompts import build_reflect_prompt, COMPLETION_REFLECT_PROMPT
+
+logger = logging.getLogger(__name__)
+
+# KnowHub Server API 地址(去除末尾斜杠)
+KNOWHUB_API = os.getenv("KNOWHUB_API", "http://localhost:8000").rstrip("/")
+
+
+# ===== 知识管理配置 =====
+
+@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/docs/memory.md 第三节)
+    reflect_auto_commit: bool = False
+    # False(默认): reflection 仅写 cognition_log: type="extraction_pending",
+    #               人工通过 CLI(agent/cli/extraction_review.py)review + commit 才进 KnowHub
+    # True         : reflection 直接 upload_knowledge(旧行为),适合无人值守的 example
+
+    # 知识注入(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 过滤(空则不过滤,支持多个owner用逗号分隔,如 "user1@example.com,user2@example.com")
+
+    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
+
+    @property
+    def resolved_owner(self) -> str:
+        """解析后的 owner(优先级:配置 > git email > 'agent')
+
+        供 inject_params key path 使用:knowledge_config.resolved_owner
+        """
+        if self.owner:
+            return self.owner
+
+        # 尝试从 git config 获取
+        try:
+            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 "agent"
+
+    def get_owner(self, agent_id: str = "agent") -> str:
+        """获取 owner(优先级:配置 > git email > agent:{agent_id})"""
+        owner = self.resolved_owner
+        if owner == "agent" and agent_id != "agent":
+            return f"agent:{agent_id}"
+        return owner
+
+
+@tool(groups=["knowledge_internal"], 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,
+    requirement_id: Optional[str] = None,
+    capability_id: Optional[str] = None,
+    tool_id: 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: 按所有者过滤(可选,支持多个owner用逗号分隔的字符串,如 "user1@example.com,user2@example.com")
+        requirement_id: 关系过滤 - 仅搜索关联到此需求ID的知识
+        capability_id: 关系过滤 - 仅搜索关联到此能力ID的知识
+        tool_id: 关系过滤 - 仅搜索关联到此工具ID的知识
+        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
+        if requirement_id:
+            params["requirement_id"] = requirement_id
+        if capability_id:
+            params["capability_id"] = capability_id
+        if tool_id:
+            params["tool_id"] = tool_id
+
+        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']}")
+            output_lines.append(f"**内容**: {item['content']}")
+
+        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(groups=["knowledge_internal"], hidden_params=["context"])
+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 = "",
+    capability_ids: Optional[List[str]] = None,
+    tool_ids: Optional[List[str]] = None,
+    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,
+            },
+            "capability_ids": capability_ids or [],
+            "tool_ids": tool_ids or []
+        }
+
+        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(groups=["core"], hidden_params=["context"])
+async def knowledge_save_pending(
+    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: Optional[List[str]] = None,
+    agent_id: str = "research_agent",
+    submitted_by: str = "",
+    score: int = 3,
+    capability_ids: Optional[List[str]] = None,
+    tool_ids: Optional[List[str]] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    暂存一条待审核的知识提取(不直接写入 KnowHub)。
+
+    写入 cognition_log: type="extraction_pending",等待人工通过 CLI
+    (agent/cli/extraction_review.py)review + commit 才会进入 KnowHub。
+    参数与 knowledge_save 对齐,review 通过后字段透传给 knowledge_save。
+
+    Args:
+        task: 任务描述(在什么情景下 + 要完成什么目标)
+        content: 核心内容
+        types: 知识类型 ["experience"] / ["tool"] / ["strategy"] / ["case"]
+        tags: 业务标签
+        scopes: 可见范围(默认 ["org:cybertogether"],commit 时应用)
+        owner: 所有者(commit 时应用)
+        resource_ids: 关联的资源 ID
+        source_name: 来源名称
+        source_category: 来源类别(paper/exp/skill/book)
+        urls: 参考来源链接
+        agent_id: 执行此调研的 agent ID
+        submitted_by: 提交者
+        score: 初始评分 1-5
+        capability_ids: 关联的能力 ID
+        tool_ids: 关联的工具 ID
+
+    Returns:
+        暂存结果(含 extraction_id,用于后续 review/commit)
+    """
+    try:
+        store = context.get("store") if context else None
+        trace_id = context.get("trace_id") if context else None
+        sequence = context.get("sequence") if context else None
+        goal_id = context.get("goal_id") if context else None
+        side_branch = context.get("side_branch") if context else None
+
+        if not store or not trace_id:
+            return ToolResult(
+                title="❌ 暂存失败",
+                output="缺少 store 或 trace_id,无法写入 cognition_log",
+                error="missing trace context"
+            )
+
+        extraction_id = f"pending-{uuid.uuid4().hex[:12]}"
+
+        payload = {
+            "task": task,
+            "content": content,
+            "types": types,
+            "tags": tags or {},
+            "scopes": scopes,
+            "owner": owner,
+            "resource_ids": resource_ids or [],
+            "source_name": source_name,
+            "source_category": source_category,
+            "urls": urls or [],
+            "agent_id": agent_id,
+            "submitted_by": submitted_by,
+            "score": score,
+            "capability_ids": capability_ids or [],
+            "tool_ids": tool_ids or [],
+        }
+
+        await store.append_cognition_event(
+            trace_id=trace_id,
+            event={
+                "type": "extraction_pending",
+                "extraction_id": extraction_id,
+                "sequence": sequence,
+                "goal_id": goal_id,
+                "branch_id": side_branch.get("branch_id") if side_branch else None,
+                "payload": payload,
+            }
+        )
+
+        return ToolResult(
+            title="✅ 已暂存待审核",
+            output=f"Extraction ID: {extraction_id}\n主题: {task[:80]}\n类型: {types}\n评分: {score}\n\n等待人工 review + commit 才会进入 KnowHub。",
+            long_term_memory=f"暂存知识提取: {extraction_id} - {task[:50]}",
+            metadata={"extraction_id": extraction_id}
+        )
+
+    except Exception as e:
+        logger.error(f"暂存待审核知识失败: {e}")
+        return ToolResult(
+            title="❌ 暂存失败",
+            output=f"错误: {str(e)}",
+            error=str(e)
+        )
+
+
+@tool(groups=["knowledge_internal"], 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(groups=["knowledge_internal"], 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(groups=["knowledge_internal"], hidden_params=["context"])
+async def knowledge_list(
+    limit: int = 10,
+    types: Optional[List[str]] = None,
+    scopes: Optional[List[str]] = None,
+    requirement_id: Optional[str] = None,
+    capability_id: Optional[str] = None,
+    tool_id: Optional[str] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    列出已保存的知识
+    
+    Args:
+        limit: 返回数量限制(默认 10)
+        types: 按类型过滤(可选)
+        scopes: 按范围过滤(可选)
+        requirement_id: 关系过滤 - 仅列出关联到此需求ID的知识
+        capability_id: 关系过滤 - 仅列出关联到此能力ID的知识
+        tool_id: 关系过滤 - 仅列出关联到此工具ID的知识
+        context: 工具上下文
+
+    Returns:
+        知识列表
+    """
+    try:
+        params = {"limit": limit}
+        if types:
+            params["types"] = ",".join(types)
+        if scopes:
+            params["scopes"] = ",".join(scopes)
+        if requirement_id:
+            params["requirement_id"] = requirement_id
+        if capability_id:
+            params["capability_id"] = capability_id
+        if tool_id:
+            params["tool_id"] = tool_id
+
+        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(groups=["knowledge_internal"], 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(groups=["knowledge_internal"], 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(groups=["knowledge_internal"], 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)
+        )
+
+
+# ==================== Tool 表查询工具 ====================
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def tool_search(
+    query: str,
+    top_k: int = 5,
+    status: Optional[str] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """向量检索工具 (Tool)
+    Args:
+        query: 检索词
+        top_k: 返回数量
+        status: 过滤状态 (如 '未接入', '已封装')
+    """
+    try:
+        params = {"q": query, "top_k": top_k}
+        if status: params["status"] = status
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.get(f"{KNOWHUB_API}/api/tool/search", params=params)
+            res.raise_for_status()
+            data = res.json()
+        return ToolResult(title="✅ 工具检索成功", output=json.dumps(data, ensure_ascii=False, indent=2))
+    except Exception as e:
+        return ToolResult(title="❌ 工具检索失败", output=str(e), error=str(e))
+
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def tool_list(
+    limit: int = 20,
+    offset: int = 0,
+    status: Optional[str] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """列出工具列表 (Tool)"""
+    try:
+        params = {"limit": limit, "offset": offset}
+        if status: params["status"] = status
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.get(f"{KNOWHUB_API}/api/tool", params=params)
+            res.raise_for_status()
+            data = res.json()
+        return ToolResult(title="✅ 工具列表获取成功", output=json.dumps(data, ensure_ascii=False, indent=2))
+    except Exception as e:
+        return ToolResult(title="❌ 工具列表失败", output=str(e), error=str(e))
+
+
+# ==================== Capability (原子能力) 表查询工具 ====================
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def capability_search(
+    query: str,
+    top_k: int = 5,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """向量检索原子能力 (Capability)
+    Args:
+        query: 检索词
+        top_k: 返回数量
+    """
+    try:
+        params = {"q": query, "top_k": top_k}
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.get(f"{KNOWHUB_API}/api/capability/search", params=params)
+            res.raise_for_status()
+            data = res.json()
+        return ToolResult(title="✅ 原子能力检索成功", output=json.dumps(data, ensure_ascii=False, indent=2))
+    except Exception as e:
+        return ToolResult(title="❌ 原子能力检索失败", output=str(e), error=str(e))
+
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def capability_list(
+    limit: int = 20,
+    offset: int = 0,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """列出原子能力列表 (Capability)"""
+    try:
+        params = {"limit": limit, "offset": offset}
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.get(f"{KNOWHUB_API}/api/capability", params=params)
+            res.raise_for_status()
+            data = res.json()
+        return ToolResult(title="✅ 原子能力列表获取成功", output=json.dumps(data, ensure_ascii=False, indent=2))
+    except Exception as e:
+        return ToolResult(title="❌ 原子能力列表失败", output=str(e), error=str(e))
+
+
+# ==================== Requirement (需求) 表查询工具 ====================
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def requirement_search(
+    query: str,
+    top_k: int = 5,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """向量检索需求 (Requirement)"""
+    try:
+        params = {"q": query, "top_k": top_k}
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.get(f"{KNOWHUB_API}/api/requirement/search", params=params)
+            res.raise_for_status()
+            data = res.json()
+        return ToolResult(title="✅ 需求检索成功", output=json.dumps(data, ensure_ascii=False, indent=2))
+    except Exception as e:
+        return ToolResult(title="❌ 需求检索失败", output=str(e), error=str(e))
+
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def requirement_list(
+    limit: int = 20,
+    offset: int = 0,
+    status: Optional[str] = None,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """列出需求列表 (Requirement)"""
+    try:
+        params = {"limit": limit, "offset": offset}
+        if status: params["status"] = status
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.get(f"{KNOWHUB_API}/api/requirement", params=params)
+            res.raise_for_status()
+            data = res.json()
+        return ToolResult(title="✅ 需求列表获取成功", output=json.dumps(data, ensure_ascii=False, indent=2))
+    except Exception as e:
+        return ToolResult(title="❌ 需求列表失败", output=str(e), error=str(e))
+
+# ==================== Relation (关系表) 检索工具 ====================
+
+@tool(groups=["knowledge_internal"],hidden_params=["context"])
+async def relation_search(
+    table_name: str,
+    filters: Optional[Dict[str, str]] = None,
+    limit: int = 100,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """通用关系表检索工具
+    Args:
+        table_name: 关系表名 (如 capability_knowledge, tool_provider 等)
+        filters: 查询条件字典 (如 {"capability_id": "xxx"})
+        limit: 返回数量限制
+    """
+    try:
+        params = {"limit": limit}
+        if filters:
+            params.update(filters)
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.get(f"{KNOWHUB_API}/api/relation/{table_name}", params=params)
+            res.raise_for_status()
+            data = res.json()
+        return ToolResult(title=f"✅ {table_name} 检索成功", output=json.dumps(data, ensure_ascii=False, indent=2))
+    except httpx.HTTPStatusError as e:
+        return ToolResult(title=f"❌ {table_name} 检索失败", output=f"HTTP Error: {e.response.text}", error=str(e))
+    except Exception as e:
+        return ToolResult(title=f"❌ {table_name} 检索失败", output=str(e), error=str(e))
+
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def tool_create(
+    id: str,
+    name: str = "",
+    version: Optional[str] = None,
+    introduction: str = "",
+    tutorial: str = "",
+    input: str = "",
+    output: str = "",
+    status: str = "未接入",
+    capability_ids: Optional[List[str]] = None,
+    knowledge_ids: Optional[List[str]] = None,
+    provider_ids: Optional[List[str]] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """创建或更新工具(直接存入数据库)"""
+    try:
+        payload = {
+            "id": id, "name": name, "version": version, "introduction": introduction,
+            "tutorial": tutorial, "input": input, "output": output, "status": status,
+            "capability_ids": capability_ids or [], "knowledge_ids": knowledge_ids or [],
+            "provider_ids": provider_ids or []
+        }
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.post(f"{KNOWHUB_API}/api/tool", json=payload)
+            res.raise_for_status()
+        return ToolResult(title="✅ 工具保存成功", output=f"成功创建/更新工具: {id}")
+    except Exception as e:
+        return ToolResult(title="❌ 工具保存失败", output=str(e), error=str(e))
+
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def capability_create(
+    id: str,
+    name: str = "",
+    criterion: str = "",
+    description: str = "",
+    requirement_ids: Optional[List[str]] = None,
+    implements: Optional[Dict[str, str]] = None,
+    tool_ids: Optional[List[str]] = None,
+    knowledge_ids: Optional[List[str]] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """创建或更新能力(直接存入数据库)"""
+    try:
+        payload = {
+            "id": id, "name": name, "criterion": criterion, "description": description,
+            "requirement_ids": requirement_ids or [], "implements": implements or {},
+            "tool_ids": tool_ids or [], "knowledge_ids": knowledge_ids or []
+        }
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.post(f"{KNOWHUB_API}/api/capability", json=payload)
+            res.raise_for_status()
+        return ToolResult(title="✅ 能力保存成功", output=f"成功创建/更新能力: {id}")
+    except Exception as e:
+        return ToolResult(title="❌ 能力保存失败", output=str(e), error=str(e))
+
+
+@tool(groups=["knowledge_internal"], hidden_params=["context"])
+async def requirement_create(
+    id: str,
+    description: str = "",
+    capability_ids: Optional[List[str]] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """创建或更新需求(直接存入数据库)"""
+    try:
+        payload = {
+            "id": id, "description": description, "capability_ids": capability_ids or []
+        }
+        async with httpx.AsyncClient(timeout=30.0) as client:
+            res = await client.post(f"{KNOWHUB_API}/api/requirement", json=payload)
+            res.raise_for_status()
+        return ToolResult(title="✅ 需求保存成功", output=f"成功创建/更新需求: {id}")
+    except Exception as e:
+        return ToolResult(title="❌ 需求保存失败", output=str(e), error=str(e))

+ 96 - 0
agent/agent/tools/builtin/memory.py

@@ -0,0 +1,96 @@
+"""
+Memory 相关工具 —— 目前只包含 dream 操作(见 agent/docs/memory.md 第四节)。
+
+dream 整理 Agent 身份的长期记忆:回顾最近 trace 的执行历史,
+逐个 trace 做反思,再跨 trace 整合写回记忆文件。
+
+设计要点:
+- 需要 config.memory(MemoryConfig)才可用;否则报错。
+- 不是 knowledge_save_pending 那样每 trace 都要用的日常工具 ——
+  所以放在独立 group "memory",通过 tool_groups 显式开启。
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+from agent.tools import tool, ToolResult, ToolContext
+
+logger = logging.getLogger(__name__)
+
+
+@tool(groups=["memory"], hidden_params=["context"])
+async def dream(
+    reflect_model: str = "",
+    dream_model: str = "",
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """整理长期记忆。回顾最近的执行历史,更新记忆文件。
+
+    本工具做两件事:
+        1. per-trace 反思:扫描未反思的 trace,为每个生成反思摘要
+        2. 跨 trace 整合:汇总未消化的反思 + 当前记忆,让 LLM 更新记忆文件
+
+    需要 RunConfig.memory(MemoryConfig)才可调用。
+
+    Args:
+        reflect_model: per-trace 反思用的模型(空则默认 gpt-4o-mini)
+        dream_model:   跨 trace 整合用的模型(空则默认 gpt-4o)
+    """
+    runner = context.get("runner") if context else None
+    if runner is None:
+        return ToolResult(
+            title="❌ dream 不可用",
+            output="缺少 runner(需要从 AgentRunner 上下文调用)",
+            error="runner not in context",
+        )
+
+    memory_config = getattr(runner, "_current_memory_config", None)
+    if memory_config is None:
+        return ToolResult(
+            title="❌ dream 不可用",
+            output="当前 Agent 未配置 MemoryConfig,不是 memory-bearing Agent",
+            error="memory not configured",
+        )
+
+    if not runner.trace_store or not runner.llm_call:
+        return ToolResult(
+            title="❌ dream 不可用",
+            output="runner 缺少 trace_store 或 llm_call",
+            error="runner dependencies missing",
+        )
+
+    from agent.core.dream import run_dream
+    report = await run_dream(
+        store=runner.trace_store,
+        llm_call=runner.llm_call,
+        memory_config=memory_config,
+        reflect_model=reflect_model or "gpt-4o-mini",
+        dream_model=dream_model or "gpt-4o",
+    )
+
+    lines = []
+    lines.append(f"per-trace 反思: {len(report.per_trace_summaries)} 条")
+    if report.skipped_traces:
+        lines.append(f"跳过: {len(report.skipped_traces)} 条 trace(日志详见 logger)")
+    lines.append(f"消化 reflection: {report.consumed_reflection_count} 条")
+    lines.append(f"更新记忆文件: {len(report.updated_files)} 个")
+    for p in report.updated_files:
+        lines.append(f"  - {p}")
+    if report.reasoning:
+        lines.append(f"\n整合理由: {report.reasoning}")
+
+    output = "\n".join(lines)
+    return ToolResult(
+        title="🧠 dream 完成",
+        output=output,
+        long_term_memory=f"dream: reflected={len(report.per_trace_summaries)}, "
+                         f"consumed={report.consumed_reflection_count}, "
+                         f"files_updated={len(report.updated_files)}",
+        metadata={
+            "per_trace_count": len(report.per_trace_summaries),
+            "consumed": report.consumed_reflection_count,
+            "updated_files": report.updated_files,
+        },
+    )

+ 66 - 0
agent/agent/tools/builtin/resource.py

@@ -0,0 +1,66 @@
+"""
+资源查询工具 - KnowHub 工具表 API 封装
+"""
+
+import os
+import httpx
+from typing import List, Dict, Optional, Any
+from agent.tools import tool, ToolResult
+
+KNOWHUB_API = os.getenv("KNOWHUB_API", "http://43.106.118.91:9999").rstrip("/")
+
+
+@tool(
+    description="列出知识库中的所有工具资源",
+    groups=["resource"],
+)
+def resource_list_tools(
+    category: Optional[str] = None,
+) -> ToolResult:
+    """列出所有工具资源,可选按分类过滤
+
+    Args:
+        category: 可选的分类过滤,如 "plugin", "model" 等
+    """
+    try:
+        resp = httpx.get(f"{KNOWHUB_API}/api/resource", params={"limit": 1000}, timeout=60.0)
+        resp.raise_for_status()
+        data = resp.json()
+
+        # 处理返回格式
+        if isinstance(data, dict):
+            data = data.get("results") or data.get("data") or []
+
+        # 过滤工具
+        tools = [r for r in data if isinstance(r, dict) and r.get("id", "").startswith("tools/")]
+
+        # 按分类过滤
+        if category:
+            tools = [t for t in tools if t.get("metadata", {}).get("category") == category]
+
+        result = {
+            "total": len(tools),
+            "tools": [{"id": t["id"], "title": t.get("title", ""), "category": t.get("metadata", {}).get("category")} for t in tools]
+        }
+
+        return ToolResult(title="工具列表", output=str(result))
+    except Exception as e:
+        return ToolResult(title="查询失败", output="", error=str(e))
+
+
+@tool(
+    description="获取指定工具的详细信息",
+    groups=["resource"],
+)
+def resource_get_tool(tool_id: str) -> ToolResult:
+    """获取工具详情
+
+    Args:
+        tool_id: 工具ID,如 "tools/image_gen/comfyui"
+    """
+    try:
+        resp = httpx.get(f"{KNOWHUB_API}/api/resource/{tool_id}", timeout=10.0)
+        resp.raise_for_status()
+        return ToolResult(title=f"工具详情: {tool_id}", output=str(resp.json()))
+    except Exception as e:
+        return ToolResult(title="获取工具详情失败", output="", error=str(e))

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.