# 子 Agent 设计

本文说明 PicoBot 具名子 Agent（named Agent）的运行时设计，重点是**结果如何从子 Agent 传回主 Agent**，以及实现过程中踩过的坑。文中描述以当前代码为准（`src/agent/`、`src/storage/agent_run.rs`、`src/storage/agent_inbox.rs`、`src/tools/delegate.rs` 等）。

## 1. 概述与设计目标

子 Agent 让主 Agent 把一个独立、可验收的子任务交给一个**具名角色**去执行，角色有独立的 Provider/模型、工具集、系统提示词和执行预算。核心目标：

- **一切可审计**：每次委托（run）先落库再执行，终态、结果、工具调用数、迭代数都持久化，WebUI 可查。
- **结果不丢**：后台任务的完成结果即使进程崩溃、收件箱打满、唤醒丢失也能最终送达主 Agent。
- **fail-closed**：定义文件、工具集、委托边、信号契约任一处非法都拒绝加载或拒绝执行，绝不悄悄放宽权限。
- **有界**：树深度、树内 run 数、并发、信号频率、结果长度全部有上限，模型只能收窄不能扩张。

## 2. 概念模型

### 2.1 Agent 定义（Markdown）

每个子 Agent 是 `definitions_dir`（默认 `agents/`）下的一个 `*.md` 文件，文件名必须等于 `id`。前面是 YAML frontmatter，后面是角色正文（role body）：

```md
---
id: researcher
description: Research primary sources
llm_profile: research            # 引用 config.json 顶层 agents 的 key
# 或者内联指定（WebUI 首选）：
# provider: openai
# model: gpt-4.1
tools: [file_read, file_search, web_fetch]
delegates: [coder]               # 下一级委托目标；缺省=general-purpose，[]=不可，["*"]=任意
skills: [summarize]              # get_skill 的作用域
limits:
  timeout_secs: 900
  max_iterations: 24
  max_result_chars: 16000
signal:                          # 可选：启用 emit_signal
  delivery: queue                # queue | steer
---
# Role
只返回有证据支撑的结论。
```

关键校验（`src/agent/definition.rs`）：

- `deny_unknown_fields`：frontmatter 出现未知字段直接拒绝。
- `enabled`（默认 `true`）：单个定义的开关；`enabled: false` 的定义保留在磁盘供管理 UI 查看，但不进入活动 catalog。
- `id` 必须匹配文件名、小写字母开头、长度 ≤64，且保留 `root/main/default/general`。
- `llm_profile` 或内联 `provider`+`model` 二选一必填；内联的 provider 和 model 必须成对出现。
- 文件必须是非符号链接的普通文件，≤256KB；role body 非空且 ≤64K 字符。
- 计算 `definition_hash`（canonical frontmatter + role body 的 SHA256），随 run 持久化，用于识别运行代内定义是否变更。

内置 `general-purpose` 定义在首次启动释放到 `~/.picobot/agents/`，作为 `delegates` 缺省时的默认委托目标。

### 2.2 Catalog 与委托图

`AgentCatalog` 每个运行代不可变。加载时把定义解析成 `AgentDefinition`（含解析后的 provider config），并校验：

- Provider profile 存在、工具名/Skill 名在注册表里、`delegates` 列表里显式列出的目标存在（`*` 与缺省不校验）。
- 任一无效 → 整代拒绝启动/热重载。

委托规则：

- **主 Agent（ROOT）**：可委托给任意具名子 Agent（`root_can_delegate` 只判断目标是否在 catalog 里）。
- **子 Agent 的下一级**：由定义里的 `delegates` 决定，语义如下：

| `delegates` | 含义 |
|-------------|------|
| 缺省（不写该字段） | 仅可委托内置 `general-purpose` |
| `[]` | 不可继续委托 |
| `["*"]` | 可委托任意子代理（除自己） |
| `["a", "b"]` | 按列表指定 |

self 与祖先链上的 Agent 在 `resolve_agent` 时永远被拒绝（循环检测）。`can_delegate(caller, target)` / `root_can_delegate(target)` / `delegate_targets(caller)` 是这套语义的唯一实现点。

### 2.3 Run 与执行上下文

一次委托 = 一个 run，持久化在 `agent_runs`。执行上下文 `AgentExecutionContext`（`src/agent/run.rs`）携带：

| 字段 | 含义 |
|------|------|
| `root_session_id` | 整棵委托树所属的会话 |
| `run_id` / `execution_id` | run ID 与「执行尝试」ID；首次两者相同，`execution_id` 用于条件状态转换，迟到的旧执行写不进状态 |
| `parent_run_id` / `ancestry` | 父 run 与祖先链（用于循环检测、授权） |
| `depth` | 委托深度（≥1） |
| `budget` | 剩余 run 数与剩余深度 |
| `tree_runs` | 整棵树的共享原子计数，强制 `max_runs_per_tree` |
| `signal_contract` | 信号契约（`None` 表示该 run 不能发信号） |
| `cancellation` | CancellationToken（父取消会向子级联） |

`child()` 构造子上下文：深度 +1、预算 -1、`parent_run_id` 设为父 run、`ancestry` 追加目标，并**共享** `tree_runs`。

## 3. 执行模型

### 3.1 foreground（同步等待）

`delegate` 工具 `mode=foreground` 时，调用方（主 Agent 或某个子 Agent）阻塞等待结果：

1. 先解析所有 target（任何非法请求在写库之前失败，不留孤儿行）。
2. 一次性持久化所有 run（`accept_agent_runs`，status=queued）。
3. 若调用方是具名 Agent，把父 run 置为 `waiting_children`（等待期间不占 step permit）。
4. 并发执行（`join_all`），结果**保持请求顺序**返回。
5. 每个 run 各自 commit terminal；父 run 恢复 `running`。

结果直接作为工具返回值回到模型，同时完整结果持久化到 `agent_runs.result`。

### 3.2 background（异步 + 收件箱）

`mode=background` 时，`delegate` 只做「接纳」就立即返回 run ID；真正执行在后台 runner 里，结果通过 durable inbox 送达。这是结果传递机制最复杂的部分，见第 4 节。

只有 ROOT 能发起 background；子 Agent 发起的 background、以及 background 里再 background 都不开放。

## 4. 结果传递机制（重点）

foreground 的结果是「调用即返回」，没有跨 Turn 的传递问题。**真正需要设计的是 background 的结果如何可靠地回到主 Agent**——因为 background runner 跑在后台，主 Agent 可能正在忙别的 Turn，甚至已经结束上一个 Turn。

核心思路：**结果不是直接通知 Channel，而是落进一个持久化收件箱，由主 Agent 的「续接 Turn」（continuation Turn）读取并汇入会话**。

```
background runner
   └─ terminal commit（原子事务）
        ├─ agent_runs → 终态（execution_id + generation 条件）
        ├─ plan item 完成（若有）
        └─ 预留槽 → agent_inbox_events 完成事件（completion）
                                    │
                                    ▼
                          session 收件箱 worker（queue lane）
                                    │ claim(pending→leased)
                                    ▼
                          continuation Turn（hidden 触发 + 只读工具集）
                                    │ commit_continuation_turn（原子）
                                    ▼
                          可见的 assistant 结果 + 事件 consumed
```

### 4.1 完成槽预留（保证不丢）

接纳 background 批次时，先对每个 run 预留一个完成槽：

```sql
UPDATE agent_session_state
SET reserved_completion_slots = reserved_completion_slots + ?,
    revision = revision + 1, updated_at = ?
WHERE root_session_id = ?
  AND pending_event_count + reserved_completion_slots + ? <= ?
RETURNING revision;
```

- 这是**条件更新**：只有 `pending + reserved + 新增 ≤ 上限` 时才成功，避免并发 `COUNT(*)` 漂移。
- 预留成功后才持久化 run；预留失败则整批拒绝。
- 意义：background 的完成事件**永远占得住位置**，不会因为收件箱被 signal 打满而丢失。signal 只能在「未预留」的容量里插入（见 4.4）。

### 4.2 终态提交（单写者）

`commit_agent_terminal`（`src/storage/agent_run.rs`）在一个事务里完成：

1. `UPDATE agent_runs SET status=终态, result=?, error=?, usage...`，条件是 `WHERE id=? AND execution_id=? AND runtime_generation=? AND status IN ('queued','running','waiting_children')`。**命中 0 行 = 迟到的旧结果，直接丢弃（返回 `None`）**。
2. 若 run 绑定了 plan item，用同一个 `execution_id` 条件完成该子项。
3. 若 `completion_slot_reserved`（background），把预留槽**转换成**一条 completion 事件写入 `agent_inbox_events`（status=completed/failed/timed_out/cancelled/interrupted，携带 result/error/signal_ids）。

三步同一事务提交：要么全部生效，要么全部回滚，**内存与数据库永不分叉**。

### 4.3 收件箱事件状态机

`agent_inbox_events` 里每条事件（signal 或 completion）走：

```
pending ──claim──▶ leased ──admit(steer)──▶ admitted ──▶ consumed
   ▲                  │                          │
   └──release(backoff)◀──────────────────────────┘
pending/leased/admitted ──supersede──▶ superseded（显式取消）
pending/leased/admitted ──dead_letter──▶ dead_letter（归档/删除/超限）
```

- **claim**：`pending → leased`，带 `lease_token` + `lease_until` + `attempt_count+1`。claim 条件 `status='pending'`，天然防双租。
- **admit**（仅 steer）：`leased → admitted`，绑定 `admitted_turn_id`。
- **release**：`leased/admitted → pending`，带重试 `next_attempt_at`。lease token 防止别的 worker 已消费后又被释放。
- **consume**：在续接 Turn 提交事务里原子完成。
- **supersede**：显式取消 run 时，把其未消费 signal 置为 superseded（completion 永不 supersede）。
- **dead_letter**：会话归档/删除、或投递超过 `max_inbox_delivery_attempts` 时；最多发一次有界 system fallback 提示。

### 4.4 两条投递 lane：queue 与 steer

事件按 `delivery` 分两种语义（`SignalDelivery`，定义在 `signal.delivery`）：

| lane | 语义 | 到达方式 |
|------|------|----------|
| `queue` | 排队到**下一个** Turn | 收件箱 worker 在调度边界把事件变成续接 Turn |
| `steer` | 注入**当前活动** Turn 的安全边界 | 两阶段准入：claim → 预留 mailbox 槽 → DB admit(turn_id) |

**steer 两阶段准入**（任何一步失败都必须无损回退）：

1. claim（pending→leased，拿到 lease token）。
2. 在 TurnMailbox 的 agent lane 预留一个槽（容量独立于 user lane）。
3. `admit_inbox_event`（leased→admitted，绑 turn_id）。
4. 同一 Turn/代激活。

失败路径：claim 失败 → 释放 lease 并 wake queue lane；mailbox 满 → 释放 lease 回 pending，等 queue lane 以 continuation 送达。**steer 可靠退化为 queue**：当活动 Turn 关闭时，已 admit 的 steer 事件按 lease token 释放回 pending，绝不静默丢弃。

### 4.5 续接 Turn（continuation Turn）

queue lane 的 worker claim 一批事件后，把它们合成为一条**隐藏的触发消息**（`build_continuation_trigger`）：completion 事件渲染为「后台任务完成（Agent、状态、Run ID、任务、结果/错误）」，signal 渲染为「后台信号（级别、摘要）」。

续接 Turn 的特殊性：

- 触发消息 `client_visibility=hidden`、`turn_origin=agent_continuation`——**不进客户端历史、不进 Channel 投递、只供模型回放**。
- 工具集受限为只读：`file_read/file_search/content_search/web_fetch/calculator/agent_task`。续接 Turn 不能写文件、发消息、再委托、调度。
- `commit_continuation_turn` 在**一个事务**里写 hidden trigger + 可见 assistant/tool 消息 + usage + 事件 consume + session 计数，客户端永远不会看到「半成品续接」。

`requires_continuation=false` 的完成事件（如 `/stop` 产生的 cancel 完成）直接写成 consumed，不触发续接。

### 4.6 唤醒与公平调度

事件 commit 成功后，Coordinator 通过 `AgentInboxNotifier` 做一次**尽力而为**的 wake（弱引用、late-bound，避免与 SessionManager 形成强引用环）。**wake 丢失不是错误**：durable inbox 是唯一事实源，worker 有周期性重新 claim 的兜底。

公平调度：空闲（无用户积压）时 due 事件立即 claim（完成即返回）；忙碌时，连续处理 `max_user_turn_burst_before_inbox` 个用户 Turn 后，或最老 pending 事件等待超过 `max_inbox_wait_secs`，下一个调度项必须是一批 inbox 事件。当前活动 Turn 从不被 queue 事件抢占。

### 4.7 emit_signal（信号）

只在定义声明 `signal` 块时，run 才会被注入 `emit_signal` 工具。契约字段（总量、单条字节、最小间隔、burst、severity allowlist、dedupe 冷却窗、JSON 深度）全部由工具与 Coordinator 强制，模型只提供 key/severity/summary/details/dedupe_key。

- 结构校验（severity 是否在 allowlist、summary/key 长度、payload 大小与深度）在工具内做，不依赖模型自觉。
- 频率限制是每 run 内存态（工具实例为单个 run 的 registry 创建）。
- Coordinator `emit_signal` 再校验：run 存在、`execution_id` 匹配、非终态；`insert_agent_signal` 在 `pending + reserved + 1 ≤ 上限` 下条件插入，并做冷却窗 dedupe（`run_id + event_type + event_key` 唯一）。
- 信号 ID 记入 `emitted_signals`，最终写进该 run 的 completion 事件 payload，供主 Agent 交叉核对。

## 5. 持久化模型

三张 agent 表（schema v8）：

- **`agent_runs`**：每次委托一行。含 run id、root session、父子、caller 身份（`caller_agent_id`/`caller_scope_id`）、agent/definition 快照（`definition_hash`）、provider/model、mode、depth、task/context、budget、signal 契约快照、status（queued/running/waiting_children/终态）、result/error、usage、`execution_id`、`completion_slot_reserved`、时间线、revision。`execution_id` 唯一索引。
- **`agent_inbox_events`**：收件箱。`run_id`（NOT NULL，FK）、event_type（signal/completion）、event_key（去重键）、delivery（queue/steer）、requires_continuation、severity、payload、status、attempt/lease、`UNIQUE(run_id, event_type, event_key)`。
- **`agent_session_state`**：每根会话一行，权威容量计数（`pending_event_count` + `reserved_completion_slots` + 单调 `revision`）。所有容量增减都是条件 UPDATE。

结果不复制大文本：完整结果在 `agent_runs.result`，inbox payload 只放有界摘要/元数据。

## 6. 取消与恢复

- **取消 run**（`cancel_run`）：先按树位置授权、确认非终态，然后 `cancel_agent_run_with_completion`（写终态 + 若预留槽则转换 completion 事件），取消 CancellationToken，并 supersede 未消费 signal。`suppress_continuation=true` 时 completion 写成 consumed（`/stop`/归档后不再续接）。
- **取消会话**（`cancel_session`）：取消该会话所有非终态 run，完成事件写 consumed。
- **启动恢复**（`recover_agent_state`）：旧运行代的 queued/running/waiting_children → interrupted（background 转换 failure completion）；过期 lease → pending 带 backoff、超限 → dead_letter；按行重算容量计数，差异修复并告警。

## 7. 授权

run ID 不是凭证。ROOT 可访问本会话所有 run；具名 Agent 只能访问自己的 run 及其**后代**（沿 `parent_run_id` 向上走到自己）。其他会话一律拒绝读取/取消。

## 8. 易出错点总结

实现过程中反复踩坑的地方，按重要程度排序：

1. **execution_id 条件更新**。终态、running、信号写入都必须带 `execution_id`（和 generation）条件，命中 0 行 = 迟到旧结果，静默丢弃。否则一个超时后被重试的旧 runner 可能覆盖新终态。
2. **收件箱容量 = pending + reserved，且必须条件 UPDATE**。signal 不能挤掉 background 的完成预留；用无锁 `COUNT(*)` 推断会并发漂移，必须在同一写事务里 `UPDATE ... WHERE pending+reserved+n ≤ limit RETURNING`。
3. **完成槽预留 → 完成事件转换必须在终态提交的同一事务里**。一旦分开，崩溃就会留下「已预留但永远不产出 completion」的槽。
4. **wake 是尽力而为，不是正确性来源**。任何依赖「wake 一定到达」的逻辑都会在丢 wake 时漏投。事实源是 durable inbox，wake 只加速，周期性重新 claim 兜底。
5. **steer 两阶段准入的无损性**。claim → mailbox 预留 → DB admit 任何一步失败都要释放 lease 并 wake queue lane；Turn 关闭时已 admit 的 steer 要按 lease token 放回 pending（退化为 queue）。绝不静默丢弃。
6. **输入归属互斥**（steer / 下一 Turn FIFO / `/stop`）。一条输入要么属于当前活动 Turn，要么进下一 Turn FIFO，`/stop` 两者都丢弃；三者必须无损且互斥。
7. **委托循环、预算、深度、树 run 上限要在解析阶段就拦下**。`ancestry` 判环、`budget` 判耗尽、`reserve_tree_run` 用共享原子计数强制 `max_runs_per_tree`。
8. **fail-closed 顺序：先解析所有 target 再写库**。否则批量里一个非法 target 会留下前几个 run 的孤儿行。
9. **spawn 失败的补偿**。TaskSupervisor 拒绝 spawn（如关机）时，要取消该 run 并释放其完成槽，否则槽永远占着。
10. **子 Agent 管理器对 Coordinator 用 `Weak`**。Coordinator 拥有 manager，manager 若强引用 coordinator 会成环。
11. **结果两段式：模型看到截断、库存全量**。`max_result_chars` 截断返回给模型的内容并提示「用 agent_task get_result 查全量」；`full_content` 原样持久化。截断要按 `floor_char_boundary`，否则 UTF-8 边界 panic。
12. **MIN 聚合无行时返回 NULL 被解成 0**。曾导致 worker 空转；`oldest_pending_due`/`next_pending_due_at` 用 `Option<Option<i64>>` 显式区分「无行」与「值为 0」。
13. **claim 的确定性与防双租**。claim 按 `created_at, id` 排序保证确定性；lease token 让 release 只作用于本 worker 租下的事件，防止把别人已消费的又放回 pending。
14. **idempotency_key 的部分唯一索引**。`UNIQUE(root_session_id, caller_scope_id, idempotency_key) WHERE idempotency_key IS NOT NULL`——`NULL` 不参与去重，否则 SQLite 里所有 NULL 会互相冲突。
15. **signal 冷却窗去重键要含时间窗**（`event_key = signal:{key}:{now/cooldown}`）。否则「窗口内去重、窗口外再发」无法表达。
16. **父 run 的 `waiting_children` 状态必须对称恢复**。父等待时释放 step permit，子结束后要 `restore_agent_run_running`，否则父 run 卡在 waiting 状态。

## 9. 配置项

`agent_orchestration`（`src/config/mod.rs`）：

| 键 | 默认 | 含义 |
|----|------|------|
| `definitions_dir` | `agents` | 定义目录（相对 config.json 所在目录） |
| `max_tree_depth` | `4` | 委托树最大深度 |
| `max_runs_per_tree` | `16` | 一棵树内最大 run 数 |
| `max_concurrent_runs` / `max_concurrent_runs_per_session` | `6` / `4` | 全局/每会话并发 run 上限 |
| `max_pending_inbox_events_per_session` | `128` | 每会话 pending + reserved 上限 |
| `max_inbox_delivery_attempts` | `8` | 投递尝试上限（超过进 dead-letter） |
| `max_user_turn_burst_before_inbox` | `4` | 公平调度：连续处理多少个用户 Turn 后必须清 inbox |
| `max_inbox_wait_secs` | `30` | 最老事件等待上限（秒） |
