# 设计决策

> 记录系统设计中的关键决策、权衡和理由。

---

## 1. Skills 通过工具加载 vs 预先注入

### 问题
Skills 包含大量能力描述，如何提供给 Agent？

### 方案对比

| 方案 | 优点 | 缺点 |
|------|------|------|
| **预先注入到 system prompt** | 实现简单 | 浪费 token，Agent 无法选择需要的 skill |
| **作为工具动态加载** | 按需加载，Agent 自主选择 | 需要实现 skill 工具 |

### 决策
**选择：作为工具动态加载**

**理由**：
1. **Token 效率**：只加载需要的 skill，避免浪费 context
2. **Agent 自主性**：LLM 根据任务决定需要哪些 skill
3. **可扩展性**：可以有数百个 skills，不影响单次调用的 token 消耗
4. **业界参考**：OpenCode 和 Claude API 文档都采用此方式

**参考**：
- OpenCode 的 skill 系统
- Claude API 文档中的工具使用模式

---

## 2. Skills 用文件系统 vs 数据库

### 问题
Skills 如何存储？

### 方案对比

| 方案 | 优点 | 缺点 |
|------|------|------|
| **文件系统（Markdown）** | 易于编辑，支持版本控制，零依赖 | 搜索能力弱 |
| **数据库** | 搜索强大，支持元数据 | 编辑困难，需要额外服务 |

### 决策
**选择：文件系统（Markdown）**

**理由**：
1. **易于编辑**：直接用文本编辑器或 IDE 编辑
2. **版本控制**：通过 Git 管理 skill 的历史变更
3. **零依赖**：不需要数据库服务
4. **人类可读**：Markdown 格式，便于人工审查和修改
5. **搜索需求低**：Skill 数量有限（几十到几百个），文件扫描足够快

**实现**：
```
~/.reson/skills/           # 全局 skills
└── error-handling/SKILL.md

./project/.reson/skills/   # 项目级 skills
└── api-integration/SKILL.md
```

---

## 3. Experiences 用数据库 vs 文件

### 问题
Experiences 如何存储？

### 方案对比

| 方案 | 优点 | 缺点 |
|------|------|------|
| **文件系统** | 简单，零依赖 | 搜索慢，不支持向量检索 |
| **数据库（PostgreSQL + pgvector）** | 向量检索，统计分析，高性能 | 需要数据库服务 |

### 决策
**选择：数据库（PostgreSQL + pgvector）**

**理由**：
1. **向量检索必需**：Experiences 需要根据任务语义匹配，文件系统无法支持
2. **统计分析**：需要追踪 success_rate, usage_count 等指标
3. **数量大**：Experiences 会随着使用不断增长（数千到数万条）
4. **动态更新**：每次执行后可能更新统计信息，数据库更适合

**实现**：
```sql
CREATE TABLE experiences (
    exp_id TEXT PRIMARY KEY,
    scope TEXT,
    condition TEXT,
    rule TEXT,
    evidence JSONB,

    confidence FLOAT,
    usage_count INT,
    success_rate FLOAT,

    embedding vector(1536),  -- 向量检索

    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

CREATE INDEX ON experiences USING ivfflat (embedding vector_cosine_ops);
```

---

## 4. 不需要事件系统

### 问题
是否需要事件总线（EventBus）来通知任务状态变化？

### 决策
**选择：不需要事件系统**

**理由**：
1. **后台场景**：Agent 主要在后台运行，不需要实时通知
2. **已有追踪**：Trace/Step 已完整记录所有信息
3. **按需查询**：需要监控时，查询 Trace 即可
4. **简化架构**：避免引入额外的复杂性

**替代方案**：
- 需要告警时，直接在 AgentRunner 中调用通知函数
- 需要实时监控时，轮询 TraceStore

---

## 5. Trace/Step 用文件系统 vs 数据库

### 问题
Trace 和 Step 如何存储？

### 方案对比

| 方案 | 优点 | 缺点 |
|------|------|------|
| **文件系统（JSON）** | 简单，易于调试，可直接查看 | 搜索和分析能力弱 |
| **数据库** | 搜索强大，支持复杂查询 | 初期复杂，调试困难 |

### 决策
**选择：文件系统（JSON）用于 MVP，后期可选数据库**

**理由（MVP阶段）**：
1. **快速迭代**：JSON 文件易于查看和调试
2. **零依赖**：不需要数据库服务
3. **数据量小**：单个项目的 traces 数量有限

**后期迁移到数据库的时机**：
- Traces 数量超过 1 万条
- 需要复杂的查询和分析（如"查找所有失败的 traces"）
- 需要聚合统计（如"Agent 的平均成功率"）

**实现接口保持一致**：
```python
class TraceStore(Protocol):
    async def save(self, trace: Trace) -> None: ...
    async def get(self, trace_id: str) -> Trace: ...
    # ...
```

通过 Protocol 定义，可以无缝切换实现。

---

## 6. 工具系统的双层记忆管理

### 问题
工具返回的数据可能很大（如 Browser-Use 的 extract 返回 10K tokens），如何避免占用过多 context？

### 方案对比

| 方案 | 优点 | 缺点 |
|------|------|------|
| **单一输出** | 简单 | 大数据会持续占用 context |
| **双层记忆**（output + long_term_memory） | 节省 context，避免重复传输 | 稍微复杂 |

### 决策
**选择：双层记忆管理**

**设计**：
```python
@dataclass
class ToolResult:
    title: str
    output: str                           # 临时内容（可能很长）
    long_term_memory: Optional[str]       # 永久记忆（简短摘要）
    include_output_only_once: bool        # output 是否只给 LLM 看一次
```

**效果**：
```
[User] 提取 amazon.com 的商品价格
[Assistant] 调用 extract_page_data(url="amazon.com")
[Tool]
# Extracted page data

<完整的 10K tokens 数据...>

Summary: Extracted 10000 chars from amazon.com

[User] 现在保存到文件
[Assistant] 调用 write_file(content="...")
[Tool] (此时不再包含 10K tokens，只有摘要)
Summary: Extracted 10000 chars from amazon.com
```

**理由**：
1. **Context 效率**：大量数据只传输一次
2. **保留关键信息**：摘要永久保留在对话历史中
3. **Browser-Use 兼容**：直接映射到 Browser-Use 的 ActionResult 设计

**参考**：Browser-Use 的 ActionResult.extracted_content 和 long_term_memory

---

## 7. 工具的域名过滤

### 问题
某些工具只在特定网站可用（如 Google 搜索技巧），是否需要域名过滤？

### 决策
**选择：支持域名过滤（可选）**

**设计**：
```python
@tool(url_patterns=["*.google.com", "www.google.*"])
async def google_advanced_search(...):
    """仅在 Google 页面可用的工具"""
    ...
```

**理由**：
1. **减少 context**：在 Google 页面，35 工具 → 20 工具（节省 40%）
2. **减少 LLM 困惑**：工具数量少了，LLM 更容易选择正确工具
3. **灵活性**：默认 `url_patterns=None`，所有页面可用

**实现**：
- URL 模式匹配引擎（`tools/url_matcher.py`）
- 动态工具过滤（`registry.get_schemas_for_url()`）

---

## 8. 敏感数据处理

### 问题
浏览器自动化需要输入密码、Token，但不想在对话历史中显示明文，如何处理？

### 决策
**选择：占位符替换机制**

**设计**：
```python
# LLM 输出占位符
arguments = {
    "password": "<secret>github_password</secret>",
    "totp": "<secret>github_2fa_bu_2fa_code</secret>"
}

# 执行前自动替换
sensitive_data = {
    "*.github.com": {
        "github_password": "secret123",
        "github_2fa_bu_2fa_code": "JBSWY3DPEHPK3PXP"  # TOTP secret
    }
}
```

**理由**：
1. **保护隐私**：对话历史中只有占位符，不泄露实际密码
2. **域名匹配**：不同网站使用不同密钥，防止密钥泄露
3. **TOTP 支持**：自动生成 2FA 验证码，无需手动输入
4. **Browser-Use 兼容**：直接映射到 Browser-Use 的敏感数据处理

**实现**：
- 递归替换（`tools/sensitive.py`）
- 支持嵌套结构（dict, list, str）
- 自动 TOTP 生成（pyotp）

**参考**：Browser-Use 的 _replace_sensitive_data

---

## 9. 工具使用统计

### 问题
是否需要记录工具调用统计（调用次数、成功率、执行时间）？

### 决策
**选择：内建统计支持**

**设计**：
```python
class ToolStats:
    call_count: int
    success_count: int
    failure_count: int
    total_duration: float
    last_called: Optional[float]
```

**理由**：
1. **监控健康**：识别失败率高的工具
2. **性能优化**：识别执行慢的工具
3. **优化排序**：高频工具排前面，减少 LLM 选择时间
4. **零成本**：自动记录，性能影响 <0.01ms

**用途**：
- 监控工具健康状况（失败率、延迟）
- 优化工具顺序（高频工具排前面）
- 识别问题工具（低成功率、高延迟）

---

## 10. 工具参数的可编辑性

### 问题
LLM 生成的工具参数是否允许用户编辑？

### 决策
**选择：支持可选的参数编辑**

**设计**：
```python
@tool(editable_params=["query", "filters"])
async def advanced_search(
    query: str,
    filters: Optional[Dict] = None,
    uid: str = ""
) -> ToolResult:
    """高级搜索（用户可编辑 query 和 filters）"""
    ...
```

**理由**：
1. **人类监督**：Agent 生成的参数可能不准确，允许人工微调
2. **灵活性**：大多数工具不需要编辑（默认 `editable_params=[]`）
3. **UI 集成**：前端可以展示可编辑的参数供用户修改

**适用场景**：
- 搜索查询
- 内容创建
- 需要人工微调的参数

---

## 11. Context 管理方案选择

**日期**: 2026-02-04

### 问题
自主长程 Agent（非交互式工具）如何有效管理 Context？

### 决策
**选择：基于 OpenCode 方案，增强计划管理和回溯能力**

**核心设计**：
- 简单的工具接口（goal, explore）
- 复杂逻辑由系统处理（分支管理、context 压缩）

**工具**：
- `goal`：线性计划管理（add, done, abandon, focus）
- `explore`：并行探索-合并（系统管理分支 msg list 和结果汇总）

**回溯机制**：
- 未执行的步骤：直接修改 plan
- 已执行的步骤：移除原始信息，替换为简短 Summary

**详细设计**：见 [`docs/context-management.md`](./context-management.md)

---

## 12. 计划管理：独立 Goal Tree vs 统一到 Step

**日期**: 2026-02-04（更新）

### 决策
**选择：独立的 Goal Tree + 线性 Message List**

- **Goal Tree**：结构化的目标/计划（goal.json）
- **Message List**：线性的执行记录
- **关联**：每条 message 标记 goal_id

**理由**：
- 概念清晰：Plan 是"要做什么"，Message 是"怎么做的"
- 压缩精确：基于 goal 完成状态压缩对应的 messages

---

## 13. Summary 生成策略

**日期**: 2026-02-04（更新）

### 决策
**选择：Goal 完成或放弃时生成 summary**

- `goal(done="summary")` - 正常完成
- `goal(abandon="原因")` - 放弃（包含失败原因，避免重蹈覆辙）

---

## 14. Context 压缩策略

**日期**: 2026-02-04（更新）

### 决策
**选择：基于 Goal 状态的增量压缩**

- Message 关联 goal_id
- Goal 完成/放弃时，将详细 messages 替换为 summary message

---

## 15. 并行探索机制

**日期**: 2026-02-04

### 决策
**选择：explore 工具，基于 Sub-Trace 机制**

**设计**：
- `background`：LLM 概括的背景（可选，为空则继承全部历史）
- `branches`：具体探索方向列表（每个方向创建独立的 Sub-Trace）

**执行**：
- 为每个探索方向创建独立的 Sub-Trace（完整的 Trace 结构）
- 并行执行所有 Sub-Traces（使用 asyncio.gather）
- 汇总所有 Sub-Trace 的结果返回

---

## 16. Skill 分层：Core Skill vs 普通 Skill

### 问题
Step 工具等核心功能如何让 Agent 知道？

### 方案对比

| 方案 | 优点 | 缺点 |
|------|------|------|
| **写在 System Prompt** | 始终可见 | 每次消耗 token，内容膨胀 |
| **作为普通 Skill** | 按需加载 | 模型不知道存在就不会加载 |
| **分层：Core + 普通** | 核心功能始终可见，其他按需 | 需要区分两类 |

### 决策
**选择：Skill 分层**

**设计**：
- **Core Skill**：`agent/skills/core.md`，自动注入到 System Prompt
- **普通 Skill**：`agent/skills/{name}/`，通过 `skill` 工具加载到对话消息

**理由**：
1. **核心功能必须可见**：Step 管理等功能，模型需要始终知道
2. **避免 System Prompt 膨胀**：只有核心内容在 System Prompt
3. **普通 Skill 按需加载**：领域知识在需要时才加载，节省 token

**实现**：
- Core Skill：框架在 `build_system_prompt()` 时自动读取并拼接
- 普通 Skill：模型调用 `skill` 工具时返回内容到对话消息

---

## 10. 删除未使用的结构化错误功能

**日期**: 2026-02-03

### 问题
在 execution trace v2.0 开发中引入了 `ErrorCode`、`StepError` 和 feedback 机制，但代码审查发现这些功能完全未被使用。

### 决策
**选择：删除未使用的代码**

**理由**：
1. **YAGNI 原则**：不应该维护未使用的功能（You Aren't Gonna Need It）
2. **减少复杂度**：
   - ErrorCode 枚举难以穷举，且 Python 无法强制约束
   - StepError 与 ToolResult.error 存在设计重复
   - feedback 机制缺少使用场景和配套接口
3. **可恢复性**：需要时可以从 git 历史恢复
4. **向后兼容**：这些功能从未被使用，删除不影响现有代码

**影响**：
- ✅ 代码更简洁
- ✅ 减少维护负担
- ✅ 不影响现有功能
- ⚠️ 未来需要结构化错误时需要重新设计

---

---

## 11. Step 关联统一使用 parent_id

**日期**: 2026-02-03

### 问题
execution trace v2.0 设计中引入了多个跨节点关联字段（`tool_call_id`、`paired_action_id`、`span_id`），但实际分析后发现这些字段存在冗余。

### 现状分析

**字段使用情况**：
- `tool_call_id` - Step 对象中未使用，只在 messages 中使用
- `paired_action_id` - 完全未使用
- `span_id` - 完全未使用

**关联需求**：
1. **Action/Result 配对** - 通过 parent_id 已经建立（result.parent_id = action.step_id）
2. **与 messages 对应** - messages 中包含完整对话历史（含 tool_call_id）
3. **重试追踪** - 同一个 action 下的多个 result，通过 parent_id 即可

### 设计决策

**保留**：
- ✅ `parent_id` - 唯一的树结构关联字段

**删除**：
- ❌ `tool_call_id` - messages 中已包含，Step 不需要重复
- ❌ `paired_action_id` - 与 parent_id 冗余
- ❌ `span_id` - 分布式追踪功能，当前用不到

---

## 12. 删除 Blob 存储系统

**日期**: 2026-02-03

### 问题
execution trace v2.0 引入了 Blob 存储系统用于处理大输出和图片，但实际分析后发现该系统过度设计且与 Agent 现有架构冗余。

### 架构分析

**Agent 的文件处理方式**：
1. **用户输入**：提供文件路径（不是 base64）
2. **工具处理**：内置工具直接读写文件系统
3. **LLM 调用**：Runner 在调用 LLM 时才将文件路径转换为 base64
4. **Trace 存储**：Step 中存储的是 messages（已包含 base64）

**Blob 系统的问题**：
1. **冗余提取**：从 messages 中提取 base64 再存储，而 messages 已经在 Step.data 中
2. **功能重叠**：Agent 内置工具已经提供文件读写能力
3. **过度设计**：引入 content-addressed storage、deduplication 等复杂功能，但 Agent 场景不需要
4. **未被使用**：output_preview/blob_ref 字段从未在实际代码中使用

### 设计决策

**删除内容**：
- ❌ `agent/execution/blob_store.py` 整个文件
- ❌ `BlobStore` 协议及所有实现
- ❌ `extract_images_from_messages()` 方法
- ❌ `restore_images_in_messages()` 方法
- ❌ `store_large_output()` 方法
- ❌ Step 字段：`output_preview`、`blob_ref`

**保留方案**：
- ✅ Step 中的 `data` 字段直接存储 messages（包含 base64）
- ✅ Agent 内置工具处理文件操作
- ✅ 用户通过文件路径引用文件（不是 base64）

### 理由

1. **YAGNI 原则**：
   - 功能从未被使用
   - 未来需要时可以重新设计

2. **架构更简洁**：
   - 不需要额外的 blob 存储层
   - 文件处理统一走工具系统

3. **符合 Agent 场景**：
   - Agent 运行在本地，直接访问文件系统
   - 不需要像云服务那样做 blob 存储和 deduplication

4. **数据完整性**：
   - messages 中的 base64 已经足够
   - 不需要拆分成 preview + ref

---

## 13. 架构重组：统一 Trace 模型

**日期**: 2026-02-08

### 问题

原有架构存在以下问题：
1. `execution/` 和 `goal/` 目录职责边界模糊
2. `Trace.context` 命名与 `ToolContext` 等概念混淆
3. `subagents/` 目录与 Agent 预设概念重复
4. 文档散乱，多个重构相关文档过时

### 决策

**目录重组**：
- `execution/` + `goal/` → `trace/`（统一执行追踪和计划管理）
- 删除 `subagents/` 目录，逻辑整合到 `trace/task_tool.py`
- 删除 `core/config.py`，合并到 `runner.py` 和 `presets.py`

**命名调整**：
- `Trace.context` → `Trace.meta`（TraceMeta 数据类）

**统一 Agent 模型**：
- 所有 Agent（主、子、人类协助）都是 Trace
- 子 Agent = 通过 `task` 工具创建的子 Trace
- 人类协助 = 通过 `ask_human` 工具创建的阻塞式 Trace

**Agent 预设替代 Sub-Agent 配置**：
- `core/presets.py` 定义 Agent 类型（default, explore, analyst 等）
- 项目级 `.agent/presets.json` 可覆盖

**工具目录简化**：
- 仅对 `file/` 和 `browser/` 做子目录分类
- 其他工具直接放 `builtin/`
- `goal` 和 `task` 工具放 `trace/`（Agent 内部控制）

### 理由

1. **概念更清晰**：trace/ 统一管理"执行状态"，tools/ 专注"外部交互"
2. **减少冗余**：删除 subagents/ 目录，用统一的 Trace 机制
3. **命名准确**：meta 比 context 更准确表达"静态元信息"

---

## 14. Subagent 工具设计

### 决策

**统一工具，三种模式**：
- 单一工具 `subagent` 支持三种模式：`explore`（并行探索）、`delegate`（委托执行）、`evaluate`（结果评估）
- 实现位置：`agent/tools/builtin/subagent.py`

**Explore 模式的并行执行**：
- 使用 `asyncio.gather()` 实现真并行
- 每个 branch 创建独立的 Sub-Trace
- 仅允许只读工具（`read_file`, `grep_content`, `glob_files`, `goal`）

**权限隔离**：
- Explore 模式：文件系统只读权限 + goal 工具，防止副作用
- Delegate/Evaluate 模式：除 `subagent` 外的所有工具
- 子 Agent 不能调用 `subagent`，防止无限递归

**Sub-Trace 信息存储**：
- Sub-Trace 的元信息存储在自己的 `meta.json` 中
- 父 Trace 的 `events.jsonl` 记录 `sub_trace_started` 和 `sub_trace_completed` 事件
- Goal 的 `sub_trace_ids` 字段记录关联关系

### 理由

1. **统一接口**：三种模式共享相同的 Sub-Trace 创建和管理逻辑，减少代码重复
2. **真并行**：Explore 模式使用 `asyncio.gather()` 充分利用 I/O 等待时间，提升效率
3. **安全性**：只读权限确保探索不会修改系统状态；禁止递归调用防止资源耗尽
4. **可追溯**：事件记录和 Goal 关联确保完整的执行历史，支持可视化和调试

---

## 15. Goal 按需自动创建

**日期**: 2026-02-10

### 问题

Agent（含 sub-agent）有时不创建 goal 就直接执行工具调用，导致 message 的 goal_id 为 null。这造成：
1. 统计信息丢失（`_update_goal_stats` 跳过 null goal_id）
2. 可视化缺失结构（前端降级为合成 "START" 节点）
3. LLM 缺少 context 锚点（goals 为空时不注入计划）

### 方案对比

| 方案 | 优点 | 缺点 |
|------|------|------|
| **预创建 root goal** | 保证非 null | 复杂任务多一层无意义嵌套，需要溶解逻辑 |
| **全面接受 null** | 无改动 | 丢失统计、可视化、context 锚点 |
| **按需自动创建** | 仅在需要时兜底，不干扰正常规划 | 首轮含 goal() 调用时该轮消息仍为 null（可接受） |

### 决策

**选择：按需自动创建 + prompt 引导**

**触发条件**（三个 AND）：
1. `goal_tree.goals` 为空（尚无任何目标）
2. LLM 返回了 `tool_calls`（正在执行操作）
3. `tool_calls` 中不包含 `goal()` 调用（LLM 未自行创建目标）

**触发时机**：LLM 返回后、记录消息前（`runner.py` agent loop 中）

**行为**：从 `goal_tree.mission` 截取前 200 字符作为 root goal description，创建并 focus。

**Prompt 配合**：`core.md` 引导 LLM "先明确目标再行动"，但不强制。

**实现**：`agent/core/runner.py:AgentRunner.run`

### 理由

1. **不干扰 LLM 自主规划**：LLM 创建目标时，树结构完全由 LLM 控制，无多余嵌套
2. **兜底覆盖遗漏**：LLM 跳过目标直接行动时，系统自动补位
3. **实现简单**：无需 `is_auto_root` 标记、溶解逻辑或 display ID 特殊处理
4. **可接受的 gap**：首轮含 `goal()` 调用时该轮消息 goal_id 为 null，仅影响一轮，属于规划阶段的过渡消息

---

## 16. Runner 重新设计：参数分层与统一执行入口

**日期**: 2026-02-11

### 问题

原 `AgentRunner.run()` 存在以下问题：
1. 签名臃肿（13 个参数），运行参数与任务内容混在一起
2. `task` 字符串同时充当 user message、GoalTree mission、trace.task
3. 新建/续跑逻辑通过 `if trace_id:` 分支耦合在一起
4. `subagent.py` 越权管理 Trace 生命周期（创建 Trace、GoalTree 等本该由 Runner 处理的事务）
5. 不支持回溯重跑（rewind）
6. Agent 间通信的消息格式不统一

### 决策

**选择：参数分层 + 统一 `run(messages, config)` 入口**

**参数分三层**：
- **Infrastructure**（AgentRunner 构造时）：trace_store, llm_call 等基础设施
- **RunConfig**（每次 run 时）：model, temperature, trace_id, insert_after 等运行参数
- **Messages**（OpenAI SDK 格式）：`List[Dict]` 任务消息，支持多模态

**三种模式通过 RunConfig 区分**：
- 新建：`trace_id=None`
- 续跑：`trace_id=已有ID, insert_after=None`
- 回溯：`trace_id=已有ID, insert_after=N`

**回溯机制**：Message 新增 `status` 字段（`active`/`abandoned`），插入点之后的消息标记为 abandoned，goals 按规则 abandon/保留。

**任务命名**：RunConfig.name 可选指定，未指定时由 utility_llm（小模型）自动生成标题。

**活跃协作者注入**：GoalTree 和 collaborators 信息每 10 轮注入一次（非每轮），减少 context 开销。

**Subagent 简化**：subagent 工具仍负责创建 Sub-Trace 和 GoalTree（需要自定义元数据和命名规则），然后将 trace_id 传给 RunConfig，由 Runner 接管执行。工具同时维护 `trace.context["collaborators"]` 列表。

### 理由

1. **OpenAI 格式统一**：Agent 间传递消息用标准格式，兼容各种 LLM API
2. **职责清晰**：Runner 管 Trace 生命周期，工具只管业务逻辑
3. **可组合**：新建/续跑/回溯共享同一个执行流水线，差异仅在 Phase 1
4. **回溯能力**：支持从任意断点插入消息重新运行，原始数据保留（标记而非删除）

**实现**：`agent/core/runner.py`, `agent/trace/models.py`, `agent/tools/builtin/subagent.py`

---

## 17. Active Collaborators：活跃协作者机制

**日期**: 2026-02-11

### 问题

模型需要知道当前任务中有哪些可以交互的实体（子 Agent、正在对接的人类），但不应该把所有持久联系人都注入到 context。

### 决策

**选择：按任务关系分类，活跃协作者随 GoalTree 注入**

按"与当前任务的关系"（而非 human/agent）分两类：
- **持久存在**：通过工具按需查询（如 `feishu_get_contact_list`）
- **任务内活跃**：存 `trace.context["collaborators"]`，周期性注入到 LLM 上下文

各工具（subagent、feishu 等）负责维护 collaborators 列表，Runner 只负责读取和注入。

### 理由

1. **维度正确**：人和 Agent 都可能是持久或任务内活跃的，不应按类型一刀切
2. **开销可控**：只注入活跃协作者（通常 2-5 个），不浪费 context
3. **可扩展**：未来新增通信渠道只需在对应工具中更新 collaborators 即可

**实现**：`agent/core/runner.py:AgentRunner._build_context_injection`

---

## 18. 统一 Message 类型 + 重构 Agent/Evaluate 工具

**日期**: 2026-02-12

### 问题

原 `subagent` 工具存在几个问题：
1. **概念冗余**：单一工具通过 `mode` 参数区分三种行为（explore/delegate/evaluate），参数组合复杂，模型容易用错
2. **evaluate 的 criteria 参数多余**：模型既要在 `evaluation_input` 里放结果，又要在 `criteria` 里放标准，信息分散
3. **缺少消息线格式类型**：工具参数和 runner 接口使用裸 `Dict`/`List[Dict]`/`Any`，无语义类型
4. **SchemaGenerator 不支持 `Literal`/`Union`**：无法为新工具签名生成正确的 JSON Schema

### 决策

#### 18a. 拆分 `subagent` → `agent` + `evaluate` 两个独立工具

- `agent(task, messages, continue_from)` — 创建 Agent 执行任务
  - `task: str` → 单任务（delegate），全量工具（排除 agent/evaluate）
  - `task: List[str]` → 多任务并行（explore），只读工具
  - 通过 `isinstance(task, str)` 判断，无需 `mode` 参数
- `evaluate(messages, target_goal_id, continue_from)` — 评估目标执行结果
  - 代码自动从 GoalTree 注入目标描述，无 `criteria` 参数
  - 模型把所有上下文放在 `messages` 中

内部统一为 `_run_agents()` 函数，`single = len(tasks)==1` 区分 delegate/explore 行为。

#### 18b. 增加消息线格式类型别名（`agent/trace/models.py`）

```python
ChatMessage = Dict[str, Any]                          # 单条 OpenAI 格式消息
Messages = List[ChatMessage]                          # 消息列表
MessageContent = Union[str, List[Dict[str, str]]]     # content 字段（文本或多模态）
```

放在 `models.py` 而非新文件——与存储层 `Message` dataclass 描述同一概念的不同层次。

#### 18c. SchemaGenerator 支持 `Literal`/`Union`

`_type_to_schema()` 新增：
- `Literal["a", "b"]` → `{"type": "string", "enum": ["a", "b"]}`
- `Union[str, List[str]]` → `{"oneOf": [...]}`
- `Any` → `{}`（无约束）

### 理由

1. **最少概念**：两个单职责工具比一个多 mode 工具更易理解和使用
2. **最少参数**：evaluate 无需 criteria（GoalTree 已有目标描述），agent 的 messages 支持 1D/2D 避免额外参数
3. **模型/代码职责分离**：模型只管给 messages，代码自动注入 goal 上下文
4. **类型安全**：`Union[str, List[str]]` 在 Schema 中生成 `oneOf`，LLM 能正确理解参数格式

### 变更范围

- `agent/trace/models.py` — 类型别名
- `agent/tools/schema.py` — `Literal`/`Union` 支持
- `agent/tools/builtin/subagent.py` — `agent` + `evaluate` 工具，`_run_agents()` 统一函数
- `agent/tools/builtin/__init__.py`, `agent/core/runner.py` — 注册表更新
- `agent/tools/builtin/feishu/chat.py`, `agent/tools/builtin/browser/baseClass.py` — 类型注解修正
- `agent/__init__.py` — 导出新类型

**实现**：`agent/tools/builtin/subagent.py`, `agent/trace/models.py`, `agent/tools/schema.py`

---

## 19. 前端控制 API：统一 run + stop + reflect

**日期**: 2026-02-12

### 问题

需要从前端控制 Agent 的创建、启动（含从任意位置重放）、插入用户消息、打断运行。原有 API 将 `continue` 和 `rewind` 拆分为两个独立端点，但它们本质上是同一操作（在某个位置运行），仅 `insert_after` 是否为 null 的区别。此外，缺少停止和反思机制。

### 决策

#### 19a. 合并 `continue` + `rewind` → 统一 `run` 端点

```
POST /api/traces/{id}/run
{
  "messages": [...],
  "insert_after": null | int
}
```

- `insert_after: null` → 从末尾续跑（原 continue）
- `insert_after: N` → 回溯到 sequence N 后运行（原 rewind）
- `messages: []` + `insert_after: N` → 重新生成（从 N 处重跑，不插入新消息）

删除 `POST /{id}/continue` 和 `POST /{id}/rewind` 两个端点。

#### 19b. 新增 `stop` 端点 + Runner 取消机制

```
POST /api/traces/{id}/stop
```

Runner 内部维护 `_cancel_events: Dict[str, asyncio.Event]`，agent loop 在每次 LLM 调用前检查。`stop()` 方法设置事件，loop 退出，Trace 状态置为 `stopped`。

Trace.status 新增 `"stopped"` 值。

#### 19c. 新增 `reflect` 端点 — 追加反思 prompt 运行

```
POST /api/traces/{id}/reflect
{
  "focus": "optional, 反思重点"
}
```

在 trace 末尾追加一条内置反思 prompt 的 user message，以续跑方式运行 agent。Agent 回顾整个执行过程后生成知识总结，通过 `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`

---