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