From b558a0a99b47bc1599679be2f2d586f4cb98909a Mon Sep 17 00:00:00 2001 From: xiaoxixi Date: Thu, 13 Aug 2026 18:07:32 +0800 Subject: [PATCH] feat: make sub-agent orchestration always-on, consolidate design docs Remove the agent_orchestration enabled feature switch and root_delegates config; delegation edges now derive from the catalog's delegate_targets. Replace the four orchestration design/review docs with a single SUB_AGENT_DESIGN.md. Bump version to 1.13.0. --- Cargo.toml | 2 +- config.json | 16 + docs/SUB_AGENT_DESIGN.md | 279 ++++ docs/SUB_AGENT_ORCHESTRATION_DESIGN.md | 1314 ----------------- .../SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md | 1054 ------------- docs/SUB_AGENT_ORCHESTRATION_REVIEW.md | 153 -- ...SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md | 222 --- .../skills/about-picobot/references/config.md | 4 +- resources/templates/config.example.json | 2 - src/agent/catalog.rs | 210 ++- src/agent/coordinator.rs | 5 - src/agent/definition.rs | 55 +- src/agent/gate.rs | 1 - src/agent/sub_agent.rs | 12 +- src/config/mod.rs | 13 - src/gateway/http.rs | 11 +- src/gateway/mod.rs | 24 +- webui/package-lock.json | 4 +- webui/package.json | 2 +- 19 files changed, 524 insertions(+), 2859 deletions(-) create mode 100644 docs/SUB_AGENT_DESIGN.md delete mode 100644 docs/SUB_AGENT_ORCHESTRATION_DESIGN.md delete mode 100644 docs/SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md delete mode 100644 docs/SUB_AGENT_ORCHESTRATION_REVIEW.md delete mode 100644 docs/SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md diff --git a/Cargo.toml b/Cargo.toml index 4efb903..98daec3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picobot" -version = "1.11.0" +version = "1.13.0" edition = "2024" [dependencies] diff --git a/config.json b/config.json index 5bedb38..134bbdf 100644 --- a/config.json +++ b/config.json @@ -23,6 +23,22 @@ "token_limit": 128000 } }, + "agent_orchestration": { + "definitions_dir": "agents", + "max_tree_depth": 4, + "max_runs_per_tree": 16, + "max_concurrent_runs": 6, + "max_concurrent_runs_per_session": 4, + "max_concurrent_provider_steps": 8, + "max_concurrent_provider_steps_per_session": 4, + "max_concurrent_tool_steps": 16, + "max_concurrent_tool_steps_per_session": 8, + "max_pending_inbox_events_per_session": 128, + "inbox_event_ttl_hours": 168, + "max_inbox_delivery_attempts": 8, + "max_user_turn_burst_before_inbox": 4, + "max_inbox_wait_secs": 30 + }, "gateway": { "host": "127.0.0.1", "port": 19877, diff --git a/docs/SUB_AGENT_DESIGN.md b/docs/SUB_AGENT_DESIGN.md new file mode 100644 index 0000000..8430240 --- /dev/null +++ b/docs/SUB_AGENT_DESIGN.md @@ -0,0 +1,279 @@ +# 子 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>` 显式区分「无行」与「值为 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` | 最老事件等待上限(秒) | diff --git a/docs/SUB_AGENT_ORCHESTRATION_DESIGN.md b/docs/SUB_AGENT_ORCHESTRATION_DESIGN.md deleted file mode 100644 index 7d5f7d8..0000000 --- a/docs/SUB_AGENT_ORCHESTRATION_DESIGN.md +++ /dev/null @@ -1,1314 +0,0 @@ -# PicoBot 子 Agent 编排与信号投递架构升级设计 - -> 状态:分阶段实施中(2026-08)。Phase 1 的具名 Definition/Catalog、Provider profile、工具与 Skill 裁剪、显式执行上下文、委托图校验和批量 foreground 已落地;durable run/inbox、signal/steer 与可唤醒 sleep 仍按本文后续阶段实施。 -> -> 本文定义具名子 Agent、委托图、多 Provider、`delegate`、`emit_signal`、后台结果收件箱、`queue`/`steer` 投递以及可唤醒 `sleep` 的目标架构。各阶段是否已经实现以代码、测试和 `docs/ARCHITECTURE.md` 为准;未落地章节仍是目标设计。 -> -> 2026-08 评审提出的 A1–A5、B1–B6、C1–C4 已纳入本文;逐项决策与理由见 [`SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md`](SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md),代码级落地方案与验收门槛见 [`SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md`](SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md)。 - -## 1. 背景与现状 - -PicoBot 已经具备一版子 Agent 能力:根交互 Agent 通过 `delegate` 创建临时 Agent,支持 `inline`、`background` 和 `parallel`,可以过滤工具、绑定计划子项、持久化后台任务,并在后台任务完成后向原 Channel 发送通知。 - -当前实现适合作为“一次性子任务执行器”,但还不能表达完整的多 Agent 编排: - -1. 所有子 Agent 复用同一个 `LLMProviderConfig`,不能按角色选择 Provider/Model。 -2. 子 Agent 没有具名、可校验的角色文件;工具权限由 `delegate.allowed_tools` 临时决定。 -3. 子 Agent 被统一移除 `delegate`,不能按受控委托图继续委派。 -4. `DelegateContext` 只有 session/channel/chat,没有 caller、current agent、parent run、depth 和 ancestry,无法执行多级授权。 -5. `parallel` 把“委托方是否等待”和“子任务是否并发”混成一个模式。 -6. 后台完成通知直接走 `MessageBus.outbound` 发给用户,不会自动成为主 Agent 的输入。 -7. 当前 steering mailbox 只建模用户输入;Agent 信号若直接复用会被误标为用户消息,并继承 `/stop` 丢弃语义。 -8. `SleepTool` 只等待定时器;除整个 Turn 被取消外,不能被新用户输入或 Agent 信号唤醒。 -9. `send_message` 同时覆盖跨 Channel 消息、目标会话历史写入和同 Turn 附件暂存,不能作为子 Agent 内部信号的安全替代。 - -本设计在保留 `AgentLoop` 无状态、每 Session 单 Turn、TurnSnapshot latest-wins、持久化后才 Completed 等既有不变量的基础上,引入显式编排层。 - -## 2. 设计目标 - -### 2.1 功能目标 - -- 每个子 Agent 由独立 Markdown 文件定义角色、工具、Provider profile、委托目标和资源上限。 -- 主 Agent 和子 Agent、子 Agent 与子 Agent 之间可以按有向权限图委托任务。 -- 主 Agent 是特殊根身份,只能委托其他 Agent,永远不能成为委托目标。 -- 委托执行模式收敛为成对概念 `foreground | background`。 -- 单任务/批量任务与串行/并发属于调度维度,不再作为第三种执行模式。 -- Background Agent 可以主动发出非终态重要信号,最终完成/失败/超时/取消由运行时自动生成终态事件。 -- Background 事件使用 `queue | steer` 决定进入下一 Turn 还是当前 Turn。 -- 主 Agent 忙碌、空闲、等待 sleep、正在模型调用或工具调用时都有明确、无丢失的投递语义。 -- Agent 信号和后台完成结果先持久化,再通过 SessionManager 投递;Gateway 崩溃或内存唤醒丢失后可以恢复。 -- `sleep` 可以由当前 session 的新输入唤醒,但不能因此破坏 queue/steer 的内容可见性边界。 -- 工具、任务树、事件和结果具备有界并发、取消、超时、去重、审计和可观测性。 - -### 2.2 架构目标 - -- Agent 编排属于 `agent`/`session` 领域,不把内部 Agent 信号塞入外部 Channel 数据面。 -- ToolRegistry 只提供经过角色定义和系统策略共同裁剪后的能力。 -- 所有慢 I/O 在 Session 锁外执行;投递 admission、Turn 关闭和可靠 fallback 保持原子。 -- Background 任务与旧运行代绑定;配置热重载不在任务中途切换角色、Provider 或工具权限。 -- 完成事件、主 Agent 消费确认和 Turn 持久化使用事务与条件更新,避免内存/数据库静默分叉。 - -## 3. 非目标 - -- 不把 PicoBot 改成分布式 Agent 集群;所有运行仍在单 Gateway 进程内。 -- 不恢复 Gateway 崩溃前正在进行的 Provider 流或工具调用现场。 -- 不承诺外部 LLM 调用严格 exactly-once;崩溃恢复可能重新调用 Provider。 -- 不允许模型在委托参数中直接指定 API key、base URL、任意工具或任意目标 session。 -- 不让子 Agent 直接访问 SessionManager 内部状态。 -- 不把 Agent reasoning、Provider 私有 replay state 或完整工具轨迹转发给其他 Agent、客户端或日志。 -- 不默认硬中断正在进行的 Provider 请求或有副作用工具;`steer` 只保证最近安全边界注入。 -- 不用本功能替代跨进程可靠监控。小时/天级持久监控仍应优先使用 Scheduler 的 monitor job。 - -## 4. 术语与核心语义 - -| 术语 | 定义 | -|------|------| -| Root Agent | 当前用户会话的主 Agent,运行时身份为 `ROOT`,不属于可寻址子 Agent 目录 | -| Agent Definition | 从一个 Markdown 文件解析出的具名角色、模型、工具、委托边和限制 | -| Agent Catalog | 当前 Gateway 运行代中全部有效 Agent Definition 的不可变快照 | -| Agent Run | 一个 Agent 对一个具体任务的执行实例 | -| Run Group | 一次批量委托创建的多个同级 Agent Run | -| Foreground | 委托方等待任务终态并直接取得结果;不代表子任务串行 | -| Background | 委托调用立即返回 run ID,任务独立执行,结果通过事件投递 | -| Queue | 输入属于后续 Turn;不改变当前 Turn 的模型上下文 | -| Steer | 输入尝试进入当前 Turn,并在最近安全边界注入;失败时可靠退化为 queue | -| Agent Signal | Background Agent 在运行中主动发出的非终态重要事件 | -| Agent Completion | Agent Run 进入 completed/failed/timed_out/cancelled/interrupted 时由运行时自动产生的终态事实;background 逐 run 投影为 inbox completion event | -| Agent Inbox | 持久化的主 Agent 内部收件箱,是 Background 结果与信号的权威来源 | -| Turn Mailbox | 当前 Turn 接受 steer 输入的有界内存邮箱,保留来源、顺序和 durable event ID | - -### 4.1 两个正交维度 - -执行方式和结果投递必须分开: - -```text -执行生命周期:foreground | background -后台事件投递:queue | steer -``` - -多个 foreground 子任务可以并发运行,父 Agent 仍同步等待全部结果。多个 background 子任务也可以并发运行,但 `delegate` 立即返回 run IDs。并发与否由批量请求、Coordinator 调度和并发配额决定,不由 mode 名称决定。 - -### 4.2 Foreground 与 Background - -| 行为 | Foreground | Background | -|------|------------|------------| -| `delegate` 返回时机 | 子任务进入终态后 | 任务持久化并成功接纳后 | -| 直接返回 | 结构化结果 | run ID / group ID | -| 父 Agent 当前 Turn | 阻塞等待 | 继续运行 | -| 最终结果路径 | 当前 delegate tool result | Agent Inbox event | -| 适用场景 | 当前工作依赖结果 | 长任务、监控、可稍后处理的任务 | - -若当前工作必须及时依赖结果,应使用 foreground。Background 的 queue completion 不保证参与发起它的原 Turn;需要抢占式关注的重要信号应显式使用 steer。 - -## 5. Agent Markdown 定义 - -### 5.1 文件位置 - -第一版只从受信任配置目录加载: - -```text -~/.picobot/ -├── config.json -└── agents/ - ├── researcher.md - ├── coder.md - └── reviewer.md -``` - -角色文件决定工具权限和委托能力,属于安全配置,不应默认从可被普通 Agent 写入的 workspace 自动加载。未来若支持 workspace 角色目录,必须由配置显式开启,并说明它不是硬安全边界。 - -### 5.2 文件格式 - -```md ---- -id: researcher -description: 搜索、阅读并整理技术资料 -llm_profile: research-sonnet - -tools: - - file_read - - file_search - - content_search - - web_fetch - -delegates: - - reviewer - -skills: - - technical-research - -limits: - timeout_secs: 900 - max_iterations: 24 - max_children: 4 - max_depth: 3 - max_concurrent_runs: 2 - max_concurrent_provider_steps: 1 - max_concurrent_tool_steps: 4 - max_result_chars: 16000 ---- - -# Role - -你是一名严谨的研究 Agent。 - -- 优先使用原始资料。 -- 明确区分事实、推断与建议。 -- 只返回与任务有关的结论、证据和不确定性。 -- 不修改项目文件。 -``` - -Frontmatter 只保存非秘密引用和限制;API key、base URL、headers 继续保存在 `config.json`/`.env`。`llm_profile` 引用现有 `config.agents` key,由 `Config::get_provider_config()` 解析 Provider 与 Model。 - -`skills` 是可选的受信任 allowlist;只有 Definition 工具集包含 `get_skill` 时才向子 Agent注入这些 Skill。子 Agent不继承主会话临时启用的 Skill、完整历史或 memory recall。调用方需要传递的事实必须进入显式 task/context;未来若支持 memory,只能通过管理员配置的只读 scope 开放。 - -### 5.3 配置扩展 - -```json -{ - "agent_orchestration": { - "definitions_dir": "agents", - "root_delegates": ["researcher", "coder", "reviewer"], - "max_tree_depth": 4, - "max_runs_per_tree": 16, - "max_concurrent_runs": 6, - "max_concurrent_runs_per_session": 4, - "max_concurrent_provider_steps": 8, - "max_concurrent_provider_steps_per_session": 4, - "max_concurrent_tool_steps": 16, - "max_concurrent_tool_steps_per_session": 8, - "max_pending_inbox_events_per_session": 128, - "inbox_event_ttl_hours": 168, - "max_inbox_delivery_attempts": 8, - "max_user_turn_burst_before_inbox": 4, - "max_inbox_wait_secs": 30 - } -} -``` - -`root_delegates` 是 Root Agent 的出边白名单。Root 不需要也不允许出现在 definitions 目录中。 - -`definitions_dir` 的相对路径按实际加载的 `config.json` 所在目录解析;第一版要求 canonical path 保持在该受信任配置目录内。默认值 `agents` 对应 `~/.picobot/config.json` 旁的 `~/.picobot/agents/`,也能让仓库内 fallback `./config.json` 使用同仓库配置目录而不跨越信任边界。 - -### 5.4 加载与校验 - -AgentCatalog 在 Gateway 候选运行代准备阶段完成全部校验,但候选代不得在此时扫描或修改 inbox/run 恢复状态;恢复扫描、旧 run 收敛和 Router 启动只能在候选代成为活动运行代后的 activation 阶段执行: - -- 文件大小、UTF-8、frontmatter 格式和必填字段。 -- ID 格式、重复 ID、保留 ID(`ROOT`、`main` 等)。 -- `llm_profile` 能解析为完整 `LLMProviderConfig`。 -- 每个工具已注册且允许委托。 -- 每个 delegates 目标存在且不是 Root。 -- 限制值在系统硬上限内。 -- 角色正文和描述长度有界。 -- Skill allowlist 中的每个 ID 均存在,且只有允许 `get_skill` 的角色可以声明。 -- canonical path 位于允许目录,拒绝越界 symlink。 - -任一引用错误应拒绝候选运行代激活,而不是静默删除工具或委托边。AgentCatalog 以 `Arc` 固定在运行代中,已启动任务不读取修改后的文件。 - -第一版 Definition 只允许引用候选代准备阶段已经注册的 built-in 工具。MCP 连接按现有架构只能在 activation 阶段发生,无法在不产生外部副作用的候选代准备阶段完成严格校验,因此 MCP 工具委托暂不开放;未来需要先增加可离线校验的 MCP tool manifest,再扩展 Catalog。 - -## 6. 总体组件设计 - -```mermaid -flowchart LR - Root[Root Agent] --> DT[DelegateTool] - Sub[Sub Agent] --> DT - DT --> C[AgentCoordinator] - C --> AC[AgentCatalog] - C --> RI[runtime-injected tool marker] - C --> PF[ProviderFactory] - C --> TR[Filtered ToolRegistry] - C --> AR[AgentRunner] - AR --> AL[AgentLoop] - AR --> ES[AgentEventSink] - ES --> DB[(agent_runs / agent_inbox_events)] - DB --> RR[AgentResultRouter] - RR --> SM[SessionManager] - SM --> TM[TurnMailbox / Inbox Wake + Worker Claim] - TM --> Root - SM --> DC[DeliveryCoordinator] -``` - -### 6.1 AgentCatalog - -拥有运行代内不可变的 Agent Definition,提供按 ID 查找、委托目标描述和 definition hash。主 Agent系统提示只获得 ID 与短 description,不加载所有角色正文。 - -### 6.2 AgentCoordinator - -替代当前承担过多职责的 `SubAgentManager`,负责: - -- 解析 caller/target 和授权委托边。 -- 创建 run ID、父子关系和预算。 -- 持久化接纳状态后启动 AgentRunner。 -- 管理 foreground await、background spawn、取消和超时。 -- 控制全局、session、Agent 与任务树的 run admission quota,以及 Provider/普通工具步骤的 execution permit;两类配额不共用生命周期。 -- 生成自动 completion terminal outcome,并逐 run 物化 inbox event。 -- 向 WorkManager 条件提交计划子项结果。 - -### 6.3 AgentRunner - -负责一次 Agent Run: - -1. 从 Definition 和运行代创建 Provider。 -2. 构造有效 ToolRegistry。 -3. 组装系统规则、角色正文、任务和显式上下文。 -4. 调用无状态 AgentLoop。 -5. 收集最终正文、媒体、usage、工具次数和子运行引用。 -6. 返回类型化终态,不直接向 Channel 发消息。 - -### 6.4 AgentEventSink / AgentResultRouter - -`AgentEventSink` 负责持久化 signal 和 run completion event(每个 run 终态独立生成);`AgentResultRouter` 负责把 pending inbox event 送到原 root session。Router 的内存 wakeup 是加速器,SQLite inbox 才是权威来源。 - -### 6.5 ProviderFactory - -根据 `llm_profile` 创建 Provider,注入 Storage/Observer,并复用当前运行代的 workspace、input types 和 token limit。Provider 仍是纯 HTTP client,不感知 Session、Channel 或 Agent 图。`cost` 只有在 profile 明确配置 input/output/cache 价格后才计算;当前没有价格来源时持久化为 `NULL`,不能根据模型名猜测价格。 - -## 7. 委托图与权限模型 - -### 7.1 基本规则 - -每次委托必须同时满足: - -```text -target != ROOT -target in allowed_targets(caller) -next_depth <= global max_tree_depth -remaining_delegation_depth > 0 -tree_run_count < max_runs_per_tree -target not in current_agent_ancestry -remaining budget > 0 -runtime admission is open -``` - -Root 的 allowed targets 来自 `root_delegates`;子 Agent 来自自身 Markdown 的 `delegates`。 - -配置可以出现 A→B 和 B→A,允许两者在不同任务树中互相委托,但一个执行链默认禁止再次出现同一 Agent ID,从而拒绝 A→B→A 的递归乒乓。未来若需要受控返工循环,应设计显式 iteration workflow,而不是放开隐式递归。 - -全局 `max_tree_depth` 是 root-relative 硬上限;Definition 的 `limits.max_depth` 表示该 Agent可继续创建的最大相对后代深度。child context 的 remaining depth 取 `min(parent_remaining - 1, target_definition.max_depth)`,任一限制耗尽即拒绝继续委托。 - -### 7.2 工具权限 - -工具可用性完全由具名 Agent 定义文件决定:有效工具集为 - -```text -AgentDefinition.tools ∩ 当前运行代已注册工具 -``` - -不再有工具侧的「可派发」标志。Tool trait 只保留一个运行时注入标记: - -```rust -/// 该工具由运行上下文按需注入(delegate 目标、信号契约、skill allowlist), -/// 不能直接写进 Definition 的 `tools` 列表。普通工具默认 false。 -fn runtime_injected(&self) -> bool { false } -``` - -- `delegate`、`emit_signal`、`get_skill`、`agent_task` 标记 `runtime_injected=true`,由 Coordinator 根据 `delegates`/`signal`/`skills` 字段和运行上下文注入,不能仅靠 Markdown 的 `tools` 声明;`get_skill` 是唯一例外——把它写进 `tools` 表示启用 scoped skill 包装器。 -- 其余任何已注册工具(含 `bash`、`send_message`、`todo` 等)都可由管理员在定义文件的 `tools` 列表显式授权,这是知情的选择。 - -`allowed_tools` 调用参数只能收窄 Definition 的工具集,不能扩权;模型不能在单次调用中越过 Definition。 - -## 8. AgentExecutionContext - -当前只含 session/channel/chat 的隐式上下文不足以支持嵌套委托。目标结构为: - -```rust -pub struct AgentExecutionContext { - pub root_session_id: String, - pub root_turn_id: Option, - pub run_id: String, - pub parent_run_id: Option, - pub caller_agent_id: String, - pub current_agent_id: String, - pub ancestry: Vec, - pub depth: u16, - pub plan_item_id: Option, - pub cancellation: CancellationToken, - pub budget: AgentBudget, - pub signal_contract: Option, -} -``` - -`ToolExecutionContext` 扩展为包含可选 `AgentExecutionContext`、CancellationToken、execution gate、Turn wakeup handle 和资源 scope。Root interactive Agent 没有伪造的 run ID,其 `agent` 字段为 `None`,由 session/turn context 明确识别为 `ROOT`;sub-run 的 `agent` 字段必须为 `Some` 且 run ID 非空。Turn wakeup handle 只为 root interactive Turn 提供,sub-run 中为 `None`。`DelegateTool`、`EmitSignalTool` 必须实现 `execute_with_context`;权限判断不能依赖模型参数或仅依赖 Tokio task-local。 - -task-local 可以继续作为同一调用栈的便利桥接,但不是授权事实来源。Background spawn 必须显式复制所需上下文,不能假设 task-local 跨 `tokio::spawn` 传播。 - -## 9. Delegate 工具设计 - -### 9.1 职责 - -`delegate` 只负责创建 Agent Run,不再同时承担查询、取消、列表。任务管理拆给 `agent_task`: - -```text -delegate → run / run_many -agent_task → get / list / cancel / get_result -``` - -较小、单一的 schema 能减少模型错误调用,也便于分别授权。 - -`agent_task` 的每次操作都必须同时校验 `root_session_id` 和调用者在任务树中的位置。Root 只能操作当前 session 的任务树;子 Agent只能读取自身及后代、取消未终态后代,不能访问祖先、sibling 或其他 session。run ID 不是授权凭证。 - -### 9.2 单任务请求 - -```json -{ - "target": "researcher", - "task": "分析当前 Provider 扩展点", - "context": "重点关注热重载和 usage 持久化", - "mode": "foreground", - "plan_item_id": "T2" -} -``` - -### 9.3 批量请求 - -```json -{ - "mode": "foreground", - "tasks": [ - {"target": "researcher", "task": "研究方案 A"}, - {"target": "coder", "task": "分析实现 B"}, - {"target": "reviewer", "task": "评审风险 C"} - ] -} -``` - -批量 foreground 的三个子任务并发执行,delegate 等待全部终态后返回聚合结果。这等价于旧 `parallel`,但不再把 parallel 当作生命周期模式。 - -批量 background 同样并发接纳,立即返回: - -```json -{ - "runs": [ - {"run_id": "run-a", "agent": "researcher", "status": "queued"}, - {"run_id": "run-b", "agent": "coder", "status": "queued"}, - {"run_id": "run-c", "agent": "reviewer", "status": "queued"} - ] -} -``` - -### 9.4 Background 投递契约 - -```json -{ - "target": "service-monitor", - "task": "监控服务错误率", - "mode": "background", - "delivery": { - "signal": "steer", - "completion": "queue", - "failure": "steer" - } -} -``` - -- `signal`:运行中主动事件的投递方式(当前由 Definition 的 `signal:` 契约 `delivery` 字段决定 queue/steer)。 -- `completion` / `failure`:投递契约为未来扩展;当前 completion 与 failure 恒为 queue,尚未开放可配置 steer。 - -Foreground 请求直接把 completion 作为 tool result 返回,因此不接受 completion delivery。仅允许 Root 创建 background run(单任务或 `tasks` 批量,批量并发执行、每个 run 独立 completion 事件);子 Agent 发起的 background 尚未开放。 - -### 9.5 Foreground 返回 - -单任务和批量任务都返回逐项状态,单个失败不能抹掉其他结果: - -```json -{ - "status": "partial", - "results": [ - {"run_id": "run-a", "agent": "researcher", "status": "completed", "result": "..."}, - {"run_id": "run-b", "agent": "coder", "status": "failed", "error": "..."}, - {"run_id": "run-c", "agent": "reviewer", "status": "completed", "result": "..."} - ] -} -``` - -结果按请求顺序返回,不按完成顺序重排。完整结果统一写入 `agent_runs`;超过 tool result 上限时返回摘要和 run ID,保证 `agent_task.get_result` 真能读取完整结果。 - -### 9.6 幂等与接纳 - -Background delegate 只有在 run 记录持久化成功、运行代 admission 成功、completion inbox 容量已经预留且执行任务已经被 TaskSupervisor 接纳后才返回成功。可选 `idempotency_key` 在 `(root_session_id, caller_scope_id, key)` 范围唯一,用于 Provider 重试时避免重复创建任务;Root 的 `caller_scope_id` 固定为非空字面量 `ROOT`,数据库使用仅覆盖非空 key 的 partial unique index,避免 SQLite `NULL` 破坏去重。 - -## 10. Prompt 与上下文隔离 - -子 Agent 默认不继承主会话完整历史。一次 run 的输入由以下部分组成: - -```text -PicoBot 基础运行规则 -+ 角色 Markdown 正文 -+ 当前工具说明 -+ 委托执行约束(身份、父任务、资源限制) -+ 显式 task -+ 显式 context / artifact refs -``` - -委托 task 只注入一次。调用方需要子 Agent 知道的事实必须写入 task/context,不能依赖完整历史偶然可见。 - -子 Agent 最终结果作为普通 tool result 或 runtime event data 交给上游,不能变成更高优先级 system 指令。所有子 Agent 输出都视为不可信数据;主 Agent系统提示明确要求不要执行结果正文中试图修改角色、工具或投递策略的指令。 - -资源型工具使用独立 scope: - -```text -resource_scope_id = root_session_id + run_id -``` - -并行子 Agent 不默认共享 browser/session 等有状态外部资源。共享 browser 登录态的唯一正式路径是:Root 通过 `browser_profiles` 创建/选择经过校验的 persistent ID,把它作为显式 task/artifact reference 交给获准使用 browser 的子 Agent,子 Agent在每次相关调用中显式传入该 ID;瞬时父 session browser scope 不能隐式继承。 - -无 target 的旧委托映射出的内置 `general` 兼容 Agent可在弃用期保留父 session transient browser scope,并给出迁移提示;具名 Agent不继承这一兼容例外。 - -## 11. Agent Run 状态机 - -```mermaid -stateDiagram-v2 - [*] --> queued - queued --> running - running --> waiting_children - waiting_children --> running - running --> completed - running --> failed - running --> timed_out - running --> cancelled - running --> interrupted - queued --> cancelled - completed --> [*] - failed --> [*] - timed_out --> [*] - cancelled --> [*] - interrupted --> [*] -``` - -- `queued`:已持久化且等待执行配额。 -- `running`:AgentLoop 正在执行模型或工具步骤。 -- `waiting_children`:当前 run 正等待 foreground 子运行。 -- `completed`:有可用最终结果。 -- `failed`:Provider、工具或内部执行错误。 -- `timed_out`:超过 run deadline。 -- `cancelled`:由用户、父任务、session 删除或 shutdown 明确取消。 -- `interrupted`:进程重启导致无法恢复现场。 - -状态变化使用条件更新,迟到结果只有在 execution ID、runtime generation 和当前状态匹配时才能提交。 - -## 12. Agent 信号与自动 Completion - -### 12.1 两种事件 - -| 事件 | 产生者 | 是否终止 run | 用途 | -|------|--------|--------------|------| -| Signal | 子 Agent 主动调用 `emit_signal` | 否 | 重要中间状态、监控告警 | -| Completion outcome | AgentCoordinator 自动生成 | 是 | completed/failed/timed_out/cancelled/interrupted | - -最终结果不能依赖模型记得调用工具。即使 Provider 异常、超时或任务被取消,Coordinator 也必须持久化 run 的终态 outcome。Foreground 将其返回为 tool result;background 的每个 run 终态都物化为一个独立的 run completion inbox event(无 all/each 策略,批量也只是逐 run 生成)。 - -### 12.2 EmitSignalTool - -```json -{ - "key": "service-error-threshold", - "severity": "critical", - "summary": "服务错误率超过 5%", - "details": { - "current": 0.071, - "threshold": 0.05 - }, - "dedupe_key": "service-a:error-rate" -} -``` - -`emit_signal` 不接受 target session、channel、chat ID 或 delivery 参数。目标、queue/steer、最大次数、速率和 root session 全部来自 `AgentExecutionContext.signal_contract`。 - -工具调用只有在 signal event 持久化后才成功返回: - -```json -{ - "signal_id": "signal-123", - "status": "accepted", - "delivery": "steer" -} -``` - -Coordinator 强制执行: - -- 每 run 信号总数与累计字节上限。 -- 最小发送间隔和 burst 上限。 -- dedupe key 冷却窗口。 -- severity allowlist。 -- summary/details 大小与 JSON 深度限制。 -- 只能投递到创建该 run 的 root session。 - -未提供 `dedupe_key` 时,每次调用使用 `signal:` 作为非空 event key;提供 key 时使用 `signal::`,只在冷却窗口内去重,不能因数据库唯一约束永久压制同类告警。 - -普通进度不应滥用 signal。工具调用进度继续通过内部 Observer/TurnEvent 投影到 UI;只有需要主 Agent采取行动的事件才使用 `emit_signal`。 - -### 12.3 Completion 去重 - -run completion payload 包含本 run 已发出的 signal IDs,主 Agent 可以识别并避免再次报告。completion/failure 投递当前恒为 queue(可配置 steer 为未来扩展)。禁止完全静默丢弃失败,`silent` 若未来开放也只能用于正常 completion。 - -## 13. SendMessage、EmitSignal 与附件职责 - -三者方向不同,不合并为一个万能工具: - -```text -emit_signal 子 Agent → Agent Inbox → 主 Agent(内部输入) -send_message Agent → OutboundDispatcher → Channel/用户(外部输出) -attach_artifact 工具产物 → 当前 Turn → DeliveryCoordinator(当前回复附件) -``` - -### 13.1 send_message - -只负责用户明确授权的跨 Channel/跨会话外部消息,具有真实外部副作用。目标和文件参数继续受 Channel/file transfer 限制;`origin` 不再由模型自由填写,改由 ToolExecutionContext 生成,避免来源伪造。是否对子 Agent 开放由管理员在定义文件的 `tools` 里显式决定。 - -### 13.2 emit_signal - -只负责后台子 Agent 的结构化内部事件。它没有任意目标、媒体或直接用户投递能力,不写目标会话 assistant history。 - -### 13.3 attach_artifact - -当前 `send_message(files=...)` 对同 session 的特殊暂存行为长期应拆成 `attach_artifact` 或统一 ToolResult media side channel。短期保留兼容路径,但新子 Agent 信号设计不得依赖它。 - -### 13.4 自动 completion - -Completion 不是工具。AgentRunner 的终结路径统一返回 terminal outcome,Coordinator 保存结果并按 foreground/background 创建相应投递 event,避免模型遗漏或重复。 - -## 14. 持久化模型 - -### 14.1 agent_runs - -```text -agent_runs ----------- -id TEXT PRIMARY KEY -root_session_id TEXT NOT NULL -root_turn_id TEXT -parent_run_id TEXT -caller_agent_id TEXT NOT NULL -caller_scope_id TEXT NOT NULL -idempotency_key TEXT -agent_id TEXT NOT NULL -definition_hash TEXT NOT NULL -provider_profile TEXT NOT NULL -provider_name TEXT NOT NULL -model_id TEXT NOT NULL -mode TEXT NOT NULL -depth INTEGER NOT NULL -plan_item_id TEXT -execution_id TEXT NOT NULL -task TEXT NOT NULL -context_json TEXT -budget_json TEXT NOT NULL -signal_contract_json TEXT -signal_delivery TEXT -completion_delivery TEXT -failure_delivery TEXT -deadline_at INTEGER NOT NULL -status TEXT NOT NULL -result TEXT -error TEXT -prompt_tokens INTEGER -completion_tokens INTEGER -cost REAL -tool_calls_count INTEGER NOT NULL DEFAULT 0 -iterations INTEGER NOT NULL DEFAULT 0 -runtime_generation INTEGER NOT NULL -attempt INTEGER NOT NULL DEFAULT 1 -completion_slot_reserved INTEGER NOT NULL DEFAULT 0 -started_at INTEGER -finished_at INTEGER -created_at INTEGER NOT NULL -``` - -```sql -CREATE UNIQUE INDEX agent_runs_idempotency -ON agent_runs(root_session_id, caller_scope_id, idempotency_key) -WHERE idempotency_key IS NOT NULL; -``` - -不保存 API key、Authorization header、Provider 私有 reasoning state 或完整 connection URL。`cost` 是 nullable projection:Provider profile 未配置价格时必须为 `NULL`,usage token 不受影响。 - -### 14.2 agent_run_groups(已删除,schema v8) - -批量委托不再建组头:单/批量请求的 `idempotency_key` 都绑定各自的 run 行,批量只是多个独立 run 的集合,使用 `(root_session_id, caller_scope_id, idempotency_key)` partial unique index,避免批量 children 互相冲突。 - -批量 background 的每个 run 终态都立即创建独立 completion event,不等待 sibling;主 Agent 空闲时收到即处理(完成即返回),忙碌时由公平调度合并或等待。不存在 all/each 策略。 - -接纳 background 时在每个 run 的 `completion_slot_reserved` 记一个 slot。Storage 用同一写事务统计该 session 的 `pending/leased/admitted` 事件和有效 reservation,避免并发接纳越过上限;`consumed/dead_letter` 受 TTL 清理但不占 pending 配额。容量不足在创建 run 前拒绝;signal 只能使用未预留容量。预留在 completion 事务落库或接纳回滚时释放。 - -容量判断不能在每次接纳时通过无锁 `COUNT(*)` 推断。新增每 root session 一行的 `agent_session_state`,在同一 SQLite 写事务中以条件 `UPDATE` 维护 `pending_event_count`、`reserved_completion_slots` 和单调 `revision`。background 接纳先增加 reservation;signal 只有在 `pending + reserved < limit` 时增加 pending;completion 将 reservation 原子转换为 pending;consume/dead-letter 减少 pending。启动恢复会以事件与 run 事实重算计数,发现差异时修复并记录告警。 - -### 14.3 agent_inbox_events - -```text -agent_inbox_events ------------------- -id TEXT PRIMARY KEY -root_session_id TEXT NOT NULL -run_id TEXT NOT NULL -event_type signal | completion -event_key TEXT NOT NULL -delivery queue | steer -requires_continuation BOOLEAN NOT NULL DEFAULT TRUE -severity TEXT -payload_json TEXT NOT NULL -status pending | leased | admitted | consumed | superseded | dead_letter -attempt_count INTEGER NOT NULL DEFAULT 0 -lease_token TEXT -lease_until INTEGER -next_attempt_at INTEGER -admitted_turn_id TEXT -last_error TEXT -created_at INTEGER NOT NULL -consumed_at INTEGER -dead_lettered_at INTEGER -fallback_notified_at INTEGER -revision INTEGER NOT NULL - -UNIQUE(run_id, event_type, event_key) -``` - -完整结果保存在 `agent_runs.result`,inbox payload 默认只放有界摘要、元数据和 result reference,避免复制大文本。 - -event key 始终非空:无 dedupe key 的 signal 用 `signal:`,有 dedupe key 的 signal 加冷却窗口 ID;run completion 固定为 `completion:`。由同一次 `/stop` 产生、无需主 Agent再次解释的 cancelled completion 使用 `requires_continuation=false`,在终态事务中直接记为 consumed,但仍保留事件审计和客户端投影。 - -### 14.4 原子事务 - -Agent completion 必须在一个 Storage 事务中: - -```text -UPDATE agent_runs terminal state/result/usage -CONSUME reserved completion capacity -INSERT run completion ... ON CONFLICT DO NOTHING -UPDATE bound task item by execution_id -COMMIT -``` - -事务失败时不能对外宣称任务完成。内存 wakeup 只有在 commit 成功后发送。每个 run 的终端事务独立生成自己的 completion 事件;迟到终态只更新自己的 run 行,不影响其它 sibling。 - -### 14.5 continuation 消息与投递绑定 - -现有 message 持久化需要增加两个可向后兼容字段: - -```text -client_visibility visible | hidden(默认 visible) -turn_origin user | agent_continuation | scheduled(默认 user) -``` - -hidden message 参与 Provider replay 和事务回滚,但 `SessionHistory`、`TurnCommitted.messages` 与 Channel 投递只投影 visible message。Storage 的 continuation commit API 必须在一个事务中写 hidden trigger、可见 assistant/tool 消息、usage 和 event consumption。 - -内部历史读取与客户端历史读取必须拆开:Session 恢复和 Provider replay 读取 visible+hidden;WebSocket/HTTP 历史、管理面消息查询和 committed delta 默认只读 visible。hidden `role=user` 不增加面向用户的 `message_count`,也不参与自动标题生成阈值;上下文压缩和 Provider token 占用仍必须统计它。 - -root session 还需持久化最近一次有效 delivery binding:`channel`、`chat_id` 和 Channel 明确标为 durable 的 opaque context。`ChannelContext` 必须把 `durable_private` 与当前仅用于一次回复的 `reply_to`/`private` 分开;核心只持久化 `durable_private`,不通过猜测 key 名过滤现有 `private`。binding 在成功接纳外部用户输入时更新;平台 thread/root 等稳定字段保持 Channel 私有,核心只存取和回传,不解释。binding 不得包含 message/reaction ID、token、临时上传 ID 或其他短期 credential。 - -## 15. Background 事件投递 - -### 15.1 统一输入类型 - -当前 user-only SteeringMailbox 演进为保留来源的 TurnMailbox: - -```rust -pub struct TurnInput { - pub id: String, - pub sequence: u64, - pub source: TurnInputSource, - pub delivery: InputDelivery, - pub content: String, - pub media_refs: Vec, - pub durable_event_id: Option, - pub received_at: i64, -} - -pub enum TurnInputSource { - User, - AgentSignal { run_id: String, agent_id: String }, - AgentCompletion { run_id: String, agent_id: String }, -} - -pub enum InputDelivery { - Queue, - Steer, -} -``` - -`SourceKind` 增加 `agent_signal`、`agent_result`。Provider 不支持 runtime role 时可以序列化为有明确 envelope 的 user-compatible message,但持久化来源、客户端渲染和取消恢复必须保持类型,不得显示成用户气泡。 - -### 15.2 路由规则 - -| 主 Agent 状态 | queue | steer | -|----------------|-------|-------| -| 无活动 Turn | 保持 pending、唤醒 worker claim,启动内部 Turn | 退化为 queue,同左 | -| 活动 Turn 接受输入 | 入下一 Turn | 入当前 TurnMailbox | -| TurnMailbox 满/已关闭 | 保持 durable pending,唤醒 worker | 可靠退化为 queue | -| Provider 请求进行中 | 等下一 Turn | 等请求结束后的安全边界 | -| 普通工具批次进行中 | 等下一 Turn | 等完整工具批次结束 | -| sleep 进行中 | 唤醒 sleep,内容仍留在 queue | 唤醒 sleep,并在工具批次后注入当前 Turn | - -Steer 不承诺硬实时抢占。最迟可见时间由当前不可分割 Provider 请求或工具步骤决定。需要当前逻辑必然依赖子结果时应使用 foreground;不要用 background+steer 模拟同步调用。 - -### 15.3 原子 admission 与 fallback - -AgentResultRouter 对 steer 事件使用不跨 Session 锁做 SQLite I/O 的两阶段 admission: - -1. 在锁外以条件更新把 event 从 `pending` claim 为 `leased`。 -2. 在 Session 状态锁内为当前 accepting Turn 分配 sequence 和不可排空的 mailbox reservation;closed/full/不存在则不创建 reservation。 -3. 在锁外以 lease token 把 event 条件更新为 `admitted` 并写 `admitted_turn_id`。 -4. 重新取得 Session 锁;只有同一 Turn/generation 仍 accepting 时才把 reservation 激活为 AgentLoop 可见输入。 -5. 任一步失败都删除 reservation,并以 lease token 把 event 恢复 `pending`;若恰逢 `/stop`,由 `/stop` 的 admitted-turn 条件释放和 lease expiry 兜底。 - -AgentLoop 只能排空已经激活的 reservation,因此不会在 durable admission 成功前看到事件。若事件不适合当前 Turn,Router 释放 lease、保持 `pending`,只递增该 session 的 inbox wake revision。 - -任何竞态下事件只能属于当前 Turn 或后续 Turn之一,不能同时进入两者,也不能两者都不进入。durable event payload 不进入保存用户 `AgentTask` 的 mpsc,因此普通 session queue 饱和不影响它。worker 在准备运行 continuation 时才从 SQLite claim lease;内存 wake 丢失由 pending/expired lease 扫描恢复。 - -Session worker 的接收面分为: - -```text -user task lane bounded mpsc(32),保存 payload,满时明确拒绝 -agent inbox wake lane watch revision,只合并“SQLite 有待处理事件”的提示 -``` - -watch revision 是延迟优化而不是事实来源;不为每个 event 建立另一个可饱和 payload 队列。Router/worker 遇到瞬时错误按 `next_attempt_at` 退避重试,默认最多 8 次并受 event TTL 限制;永久错误立即进入 dead-letter。 - -### 15.4 Mailbox 容量与公平性 - -用户输入和 Agent 事件共享接收顺序,但使用独立容量配额,避免相互挤占: - -```text -user steer lane: 32 messages / 64 KiB -agent event lane: 8 messages / 32 KiB -``` - -排空时按 session sequence 合并。重要 AgentSignal 可以保留专用容量,但不默认越过更早已接受的用户输入。信号洪泛由 emit_signal rate limit 和 inbox 上限共同控制。 - -queue continuation 在 Turn 调度边界采用有界公平,而不是依赖 UI 保证可见:通常先处理用户任务;连续处理 `max_user_turn_burst_before_inbox`(默认 4)个用户 Turn,或最老 pending event 等待达到 `max_inbox_wait_secs`(默认 30 秒)后,下一个调度项必须是一个有界 event batch。当前活动 Turn 从不被 queue event 抢占,因此等待上限从下一个调度边界计算。UI 未读状态只是投影,不参与正确性。 - -### 15.5 安全边界注入 - -AgentLoop 只在以下边界排空 steer: - -- 一个完整工具批次结束后。 -- Provider 返回无工具候选最终回复、但 mailbox 有新输入时。 -- 明确可安全取消的等待工具被唤醒后。 - -输入被排空后保留为 in-flight,只有整个 Turn 消息和 event consumption 原子提交成功才确认。Provider/工具/持久化失败时恢复原事件。 - -## 16. 主 Agent 忙碌与空闲 - -### 16.1 主 Agent 忙碌 - -- queue event 只进入后续内部任务,不改变当前上下文。 -- steer event 进入 TurnMailbox,在安全边界参与当前 Turn。 -- 当前 Turn 已 finalizing/closed 时,steer 自动退化为 queue。 -- UI 可以立即展示“信号已接纳/后台任务已完成”,但用户可见最终结论仍由主 Agent产生。 - -### 16.2 主 Agent 空闲 - -Session worker 不做固定频率忙轮询。正常路径由 AgentResultRouter 发出 best-effort session wakeup;worker 被唤醒后领取 inbox event 并创建内部 Turn。Gateway 启动、reload 激活和周期恢复任务扫描 pending/expired lease,弥补丢失唤醒。 - -Session worker 调度优先级: - -```text -当前活动 Turn -> 已排队用户输入(受 burst/age 公平上限约束) -> queue background result continuation -> 等待新事件 -``` - -Steer event 在没有活动 Turn 时按 queue 处理。用户输入通常优先以避免后台总结打断新请求,但 §15.4 的 burst/age 规则保证结果不会在持续用户流量下无限饥饿。UI 未读状态只展示 pending/dead-letter 数量,不承担调度正确性。 - -### 16.3 内部 continuation Turn - -内部任务不是伪造的 InboundMessage: - -```rust -enum AgentTaskSource { - UserInput, - BackgroundAgentResults { event_ids: Vec }, - ScheduledTask, -} -``` - -SessionManager 直接把领取的结果构造成 bounded runtime context,并加入一个内部触发语义:“检查这些后台结果,结合原始目标验证和汇总,再向用户报告。”内部输入不显示用户气泡;主 Agent输出按普通 assistant Turn 持久化和投递。 - -continuation 必须创建一条 durable hidden trigger message,而不是只在内存临时拼 prompt: - -- 数据库 role 使用 Provider-compatible `user`,source 为 `agent_signal`/`agent_result`,并标记 `client_visibility=hidden`、`turn_origin=agent_continuation`。 -- hidden content 是有界 runtime envelope,包含 event/run references 和不可信数据边界;Provider history replay 会保留它,普通 history/WebSocket 投影会过滤它。 -- assistant/tool 消息照常可见。`turn_updated`/`turn_committed` 携带 turn origin,客户端可以显示“后台结果处理”标签,但不创建伪用户气泡。 - -内部 Turn 的 delivery target 来自 root session 的 durable binding:`channel`、`chat_id` 和可复用的 thread/root context。一次性 `reply_to` 不得复用。没有可用外部 binding 时仍提交历史并等待 WebUI/TUI 读取,不能猜测或改投其他 chat。 - -### 16.4 消费确认 - -领取流程: - -```text -pending → leased → admitted → consumed -``` - -`lease_token` 防止重复 worker 处理。同一事务必须保存 hidden trigger、主 Agent Turn、usage,并把对应 inbox events 标记 consumed。continuation 持有 `InboxLeaseGuard`:AgentLoop 失败、Turn 取消、generation stale 等正常退出会以 token 显式 release;只有进程崩溃或强制 abort 才依赖 lease 到期恢复 pending。 - -外部 LLM 调用无法严格 exactly-once。为降低恢复重跑的副作用,background result continuation 默认只开放只读/汇总工具;需要外部写操作时由主 Agent向用户确认,或工具自身使用幂等键。 - -## 17. 可唤醒 Sleep 设计 - -### 17.1 目标语义 - -`sleep` 仍是最长 24 小时、不可持久恢复的前台等待工具,但当前 session 接收到任何新输入时立即结束等待: - -- user steer -- user queue -- AgentSignal queue/steer -- AgentCompletion queue/steer -- `/stop`、shutdown 和父 cancellation - -Sleep 只负责唤醒,不负责消费输入。queue 内容仍属于下一 Turn;steer 内容仍由 TurnMailbox 在工具批次后注入。 - -上述“当前 session 输入”只适用于 root interactive Turn。sub-run 没有独立 session input lane,`ToolExecutionContext.turn_wakeup=None`,其 sleep 只响应 timer、run cancellation、timeout 或 shutdown;不会因 root session 用户输入或 sibling signal 被唤醒。Root 如需停止 sleeping sub-run,应调用 `agent_task.cancel`。 - -### 17.2 Wakeup handle - -`ToolExecutionContext` 增加: - -```rust -pub struct TurnWakeupHandle { - pub receiver: watch::Receiver, -} - -pub struct TurnWakeupState { - pub revision: u64, - pub pending_steer: usize, - pub pending_queue: usize, - pub latest_source: WakeupSource, - pub latest_preview: Option, -} -``` - -使用 watch revision 而不是裸 Notify,避免输入恰好在 sleep 开始监听前到达而丢失唤醒。执行前先比较当前 revision/pending,再进入 select。 - -### 17.3 Sleep 执行 - -```rust -tokio::select! { - _ = tokio::time::sleep(duration) => SleepOutcome::Elapsed, - changed = wakeup.changed() => SleepOutcome::InputArrived(changed), - _ = cancellation.cancelled() => SleepOutcome::Cancelled, -} -``` - -返回示例: - -```text -Sleep 提前结束:已等待 37 秒。 -收到一条 steer AgentSignal(run_id=run-123):服务错误率超过 5%。 -该信号将在当前 Turn 的下一个安全边界注入。 -``` - -queue 输入不能把正文泄漏给当前 Turn,否则等价于偷偷 steer。其返回只能说明类型和数量: - -```text -Sleep 提前结束:收到一条排队输入。 -内容不会进入当前 Turn,将在当前工作结束后的下一 Turn处理。 -``` - -### 17.4 工具中断策略 - -新增工具元数据: - -```rust -enum InputInterruptPolicy { - Never, - WakeOnly, - CancelSafe, -} -``` - -- `sleep`:`WakeOnly`,输入使工具正常提前返回。 -- 明确只读且可重试的等待工具可标记 `CancelSafe`。 -- bash、写文件、发送消息和未知外部副作用工具默认 `Never`。 - -Steer 不自动取消 `Never` 工具。未来若需要硬抢占,应新增独立 `interrupt` 策略并定义 partial tool 状态、幂等和恢复;本设计不把它隐含进 steer。 - -## 18. 取消、停止与恢复 - -### 18.1 Foreground 结构化取消 - -Foreground 子 run 是父 run 的结构化子任务: - -- 父 Turn 取消会取消所有未终态 foreground 后代。 -- timeout token 与父 cancellation token 组合。 -- run 进入 waiting_children 时仍保留所有权,但不能长期占用模型执行 permit。 -- 父取消后迟到结果不能提交为 completed。 - -这是 AgentLoop 的显式接口约束:root Turn、foreground child、run timeout 和 runtime shutdown 的 `CancellationToken` 必须贯穿 Provider stream、可取消等待和工具批次外层,不能只依赖调用 future 被 drop。结构化取消先作为 Phase 2A 独立落点实现并回归现有 root Turn 行为,再接入嵌套 run。 - -“不默认硬中断 Provider/副作用工具”只约束普通 `steer`;`steer` 等待安全边界。`/stop` 保持现有强停止语义:取消 token 并使 root Turn future 失效,独立 child 在有界宽限期后仍未退出则由 Coordinator/Supervisor abort。Coordinator 的 terminal condition update 始终阻止取消后的迟到完成提交。 - -### 18.2 Background 所有权 - -Background run 归 root session 所有,不归发起它的模型 future 所有。Root Turn结束不会自动取消它。 - -保留当前 `/stop` 的明确停止语义:取消目标 session 的 active Turn、排队用户输入以及所有非 Scheduler background Agent run。需要跨 `/stop` 和重启长期存在的监控应创建 Scheduler monitor,而不是普通 background delegate。 - -### 18.3 `/stop` 与 durable Agent events - -用户 steering 按现有语义可被 `/stop` 丢弃;已经持久化的 AgentSignal/Completion 不能静默消失: - -- `/stop` 关闭 TurnMailbox 时收集尚未提交的 event ID,并在 generation 失效后按 `lease_token`/`admitted_turn_id` 条件更新立即恢复 pending;lease expiry 只是崩溃兜底。 -- continuation 的 `InboxLeaseGuard` 在 worker 正常退出、失败或取消时显式 release;durable payload 不进入会被 `agent_tx.take()` 丢弃的普通 mpsc。 -- 被取消 background run 仍由 Coordinator 条件事务写 cancelled completion。由本次 `/stop` 自身造成的 completion 使用 `requires_continuation=false`,保留审计和 UI 状态但不反向启动新 Turn;`/stop` 前已经存在的其他 durable event 恢复 pending 后继续投递。 -- 用户显式执行 `agent_task.cancel` 后,可以将该 run 未消费的普通 signal 标记 superseded,但保留审计记录。 - -### 18.4 Gateway reload - -AgentCatalog、ProviderFactory、Coordinator 和 inbox router 属于 Gateway runtime generation: - -- reload 关闭 admission 后不接受新 background run/signal。 -- 已进入旧代的 run 固定使用旧 definition hash、Provider 和工具策略。 -- 排空期等待前台 Turn、Scheduler 和 background run 到持久化边界。 -- 超过总排空期限的 run 被取消/中断并写终态事件。 -- pending inbox event 留在 SQLite,由新运行代恢复投递。 - -### 18.5 进程重启 - -进程退出后无法恢复正在进行的 LLM stream。启动恢复将旧 `running/waiting_children` 标记 `interrupted` 并生成 completion。普通有副作用 Agent run 不自动重试;只读、显式配置 idempotency/restart policy 的监控任务可以创建新 attempt,并保留原 run 的中断记录。 - -### 18.6 Session 归档与删除 - -- session 被归档后不再启动内部 continuation;未消费事件进入 `dead_letter(session_archived)` 并继续在管理面可见,非 Scheduler 所有的未终态 run 被取消。该生命周期原因不发送 system fallback。 -- session 被软删除后同样取消未终态 run,释放 reservation,并将未消费事件收敛为 `dead_letter(session_deleted)`;不得猜测其他 session 或 Channel 作为替代目标,也不发送 system fallback。 -- `/stop` 不是归档或删除:它取消当前 Turn 和该 session 的 active background run,但 `/stop` 前已经存在的 durable event 仍恢复为 pending;由本次停止产生的取消 completion 仅做 status-only 审计。 - -## 19. 并发、预算与死锁避免 - -### 19.1 限制层次 - -```text -Gateway 全局 active provider/tool permits -└── per-session permits - └── per-agent permits - └── per-tree max runs/depth/children/token/cost -``` - -批量请求必须有 `maxItems`,Coordinator 还会按剩余 tree budget 裁剪/拒绝,不能让模型生成任意数量任务。 - -### 19.2 父子等待死锁 - -不能让一个等待 foreground child 的父 run 一直持有唯一执行 permit,否则并发上限为 1 时形成: - -```text -父 run 持有 permit → 等子 run → 子 run 永远拿不到 permit -``` - -permit 应限制活跃 Provider/工具步骤,而不是整个 Agent Run 生命周期。父 run 进入 `waiting_children` 前释放执行 permit,子 run 完成后父 run 再竞争 permit 继续模型迭代。Task tree ownership、timeout 和 cancellation 不随 permit 释放而消失。 - -具体归属如下: - -- Coordinator 的 run admission quota 统计已接纳且未终态的 run,可以跨 `waiting_children` 持有。 -- AgentLoop 在每次 Provider 请求前按 global → session → agent 的固定顺序获取 provider step permits,stream 结束/取消即释放。 -- tool executor 只为普通工具调用获取 tool step permit;`delegate`、`agent_task`、`emit_signal` 等 runtime-control 工具不占这种 permit。 -- foreground delegate 通过状态 guard 在等待前条件更新 `running → waiting_children`,返回/取消时再条件更新;等待动作本身不持有 provider/tool permit。 - -### 19.3 预算传播 - -每次子委托从父 budget 派生硬上限: - -```text -child deadline <= parent deadline -child max depth <= remaining depth -sum child token reservation <= remaining tree budget -sum child cost reservation <= remaining tree budget(仅有价格配置时) -``` - -调用方可以收紧 timeout/结果大小,但不能超过 Agent Definition 和系统上限。Provider profile 没有价格信息时不启用 cost reservation,只执行 token、迭代、deadline 和 run-count 硬预算。 - -## 20. 可观测性与客户端表现 - -### 20.1 运行树 - -WebUI 管理面展示: - -- group/run/parent ID。 -- Agent ID、Provider profile、Model。 -- queued/running/waiting_children/terminal 状态。 -- signal 数量和最近 severity。 -- duration、usage、cost、工具次数。 -- 取消/超时/中断原因。 - -### 20.2 Chat 表现 - -- Foreground delegate 继续作为当前 Turn 的可折叠工具块。 -- Background delegate 启动后显示 run ID,不假装任务已完成。 -- AgentSignal 显示为独立运行时信号卡片,不显示成用户气泡。 -- queue completion 在主 Agent内部 continuation 后只显示主 Agent汇总回复。 -- steer 信号可以在当前 Turn 工具状态中显示“已接纳”,最终历史由 Turn commit 校准。 - -WebSocket 协议使用通用 run/event 投影,不为 Signal 复制一套状态机: - -```text -WsInbound::GetAgentRuns { session_id, cursor, limit } -WsInbound::GetAgentRun { session_id, run_id } - -WsOutbound::SessionAgentRuns { session_id, revision, runs, next_cursor } -WsOutbound::AgentRunUpdated { session_id, revision, run } -WsOutbound::AgentEventUpdated { session_id, revision, event } -``` - -`AgentEventUpdated` 覆盖 accepted/admitted/consumed/dead-letter 状态,客户端按 `(session_id, revision, event_id)` 幂等合并;重连后用 `GetAgentRuns` 全量校准。`TurnSnapshot` 和 `TurnCommitted` 增加 `turn_origin = user | agent_continuation | scheduled`。第一版取消入口继续使用 `/stop`、`agent_task.cancel` 或受保护管理 API,不增加缺少任务树授权上下文的裸 WebSocket cancel 帧。 - -### 20.3 隐私与日志 - -- 不显示/记录 Agent reasoning 和 Provider 私有 state。 -- 默认日志只记录 run ID、Agent ID、状态、duration、usage 和截断错误。 -- task/result 正文不进入 info 日志。 -- API key、headers、临时凭据、含 credential URL 永不落库或日志。 - -## 21. 失败语义 - -| 失败点 | 对外语义 | -|--------|----------| -| Agent Definition 无效 | 拒绝候选运行代;旧代继续服务 | -| 委托边不允许 | delegate 立即返回 permission denied,不创建 run | -| Background 持久化失败 | delegate 返回失败,不报告 run ID | -| Completion capacity 无法预留 | delegate 在创建 run 前返回 inbox capacity exceeded | -| TaskSupervisor 拒绝 spawn | run 条件更新 cancelled/failed,再返回失败 | -| Provider 创建失败 | run failed,foreground 返回错误;background 生成 failure event | -| Signal inbox 无可用容量 | emit_signal 返回 inbox_full;run 继续执行 | -| Inbox wakeup 丢失 | pending event 由恢复扫描重新唤醒 | -| TurnMailbox closed/full | steer 可靠退化 queue | -| Session user queue 满 | 用户输入明确拒绝;durable event 不经过该队列 | -| Main continuation Provider 失败 | lease guard 显式 release 并退避重试;崩溃时才等 lease 到期 | -| 主 Agent回复持久化失败 | event 不确认,避免结果消失 | -| Event 超过 attempts/TTL | 标 dead_letter,并幂等尝试一次 system fallback | -| Channel 最终投递失败 | assistant history已持久化;沿用 DeliveryCoordinator terminal fallback | - -所有重试必须有次数、退避、deadline 和分类;永久错误立即终态化,不能无界重试。默认最多 8 次,退避为 `1s/5s/30s/2m/10m` 后封顶 10 分钟,并同时受 inbox event TTL 限制。 - -dead-letter 记录最终原因和时间,并通过 OutboundDispatcher 最多发送一次有界 system fallback,只包含 run ID、终态和查询提示;`fallback_notified_at` 保证幂等。fallback 渠道失败时,SQLite run/event 记录和管理 UI 是最终诊断出口,不能把 dead-letter 伪装成已交付。 - -## 22. 兼容迁移 - -### 22.1 Delegate 参数 - -旧模式映射已在迁移完成后移除:`inline`/`parallel` 别名与 legacy general 委托均不再解析,只保留 canonical `foreground`/`background` 生命周期词与具名 `target`。 - -### 22.2 allowed_tools - -`allowed_tools` 只能收紧 Definition.tools,不能扩权。没有 `target` 的委托不再支持(旧匿名 general 已移除);内置 `general-purpose` Agent 定义随二进制释放到 `~/.picobot/agents/`,开箱即用。 - -### 22.3 background_tasks - -旧 `background_tasks` 表已在 schema v7 中删除(`DROP TABLE`),旧 adapter 与 direct notification 路径一并移除;`/api/tasks` 只读取 `agent_runs`。无历史兼容需求。 - -### 22.4 版本与文档 - -本设计文档本身不改变产品行为。实现功能合并时按项目规则增加中段版本,并同步更新 README、`docs/ARCHITECTURE.md`、AGENTS.md 和 `resources/skills/about-picobot/references/`。 - -## 23. 实现分期 - -### Phase 1:具名 Agent 与 Foreground - -- 新增 AgentDefinition/AgentCatalog loader。 -- Definition 解析不同 Provider profile 和固定工具集。 -- Delegate schema 使用 target + foreground/background canonical modes。 -- 批量 foreground 并发执行并聚合。 -- 显式 AgentExecutionContext 和委托图授权。 -- 明确 skills/memory 不继承、具名 Agent browser scope 隔离。 -- 不开放嵌套 background(子 Agent 发起的 background)。 - -### Phase 2A:AgentLoop 结构化取消 - -- CancellationToken 贯穿 root Turn、Provider stream、工具批次和 AgentRunner。 -- 保持 `/stop` 强停止、普通 `steer` 安全边界语义。 -- 用现有 sleep cancellation、Provider stream 和 steering recovery tests 锁定回归基线。 - -### Phase 2B:统一 Agent Run 持久化 - -- 新增 `agent_runs`、Storage transaction API。 -- 拆分 `delegate` 与 `agent_task`。 -- Foreground 结果也持久化,修复截断结果不可查询。 -- 实现预算、run admission quota 与 step execution permit 释放。 - -### Phase 3:Agent Inbox 与 Queue Completion - -- 新增 `agent_inbox_events`、lease、恢复扫描。 -- 新增 completion capacity reservation、合并式 wake lane和 worker 有界公平。 -- Background completion 从 Channel direct notification 改为主 Agent内部 continuation。 -- 增加 hidden continuation trigger、SourceKind::AgentResult、Turn origin 和 WebSocket run/event 投影。 -- 批次 completion 合并与 debounce。 -- 增加 dead-letter system fallback、UI 未读计数和 reconnect 全量校准。 - -### Phase 4:Emit Signal 与 Steer - -- 新增 EmitSignalTool、SignalContract 和 rate/dedupe。 -- SteeringMailbox 泛化为来源感知 TurnMailbox。 -- 实现 steer admission、queue fallback、durable ack/recovery。 -- 添加 AgentSignal UI;任务树复用 Phase 3 的 run/event 协议。 - -### Phase 5:可唤醒 Sleep 与工具中断元数据 - -- ToolExecutionContext 增加 TurnWakeupHandle。 -- SleepTool 使用 watch revision + timer + cancellation select。 -- queue/steer 唤醒内容边界和测试。 -- 为工具增加 InputInterruptPolicy,默认 Never。 -- sub-run 保持 timer/cancellation-only,不获得 root TurnWakeupHandle。 - -## 24. 预计代码边界 - -建议模块拆分: - -```text -src/agent/ -├── definition.rs AgentDefinition / loader -├── catalog.rs immutable AgentCatalog -├── coordinator.rs authorization / lifecycle / budgets -├── run.rs AgentRun types / AgentRunner -├── inbox.rs event types / router contracts -└── sub_agent.rs 迁移兼容层,最终缩减或删除 - -src/tools/ -├── delegate.rs create run only -├── agent_task.rs get/list/cancel/get_result -├── emit_signal.rs constrained internal signal -├── sleep.rs wake-aware wait -└── send_message.rs external delivery only - -src/session/ -├── turn_mailbox.rs typed steer inputs -├── agent_inbox.rs claim/admit/ack、lease guard、coalesced wake -└── session.rs typed AgentTask scheduling - -src/storage/ -├── agent_run.rs -└── agent_inbox.rs -``` - -`AgentLoop` 只需要理解来源感知输入的安全边界追加,不拥有 AgentCatalog、任务树或 inbox persistence。 - -## 25. 测试矩阵 - -### 25.1 Definition 与授权 - -- 解析合法 Markdown、frontmatter 与 Unicode 正文。 -- 重复/保留 ID、未知 Provider、未知工具、未知 delegate target 拒绝加载。 -- path traversal/symlink 越界拒绝。 -- Root 永远不能成为 target。 -- 未声明 A→B 时拒绝;声明后允许。 -- A→B→A 在单链中拒绝。 -- 模型参数不能扩大工具、timeout、depth 或预算。 - -### 25.2 Foreground/Background - -- 单 foreground 结果立即成为父 tool result。 -- 三个 foreground task 并发执行、父等待全部、结果按请求顺序。 -- 单项失败不丢其他项结果。 -- Background 只有持久化并成功 spawn 后才返回 run ID。 -- 同 idempotency key 不重复创建 run。 -- Root caller scope 的 idempotency key 在 SQLite 中同样去重。 -- Background completion 不直接伪装为用户消息。 - -### 25.3 Provider 与工具 - -- 不同 Agent 使用不同 provider/model profile。 -- Provider storage/observer 正确注入。 -- 工具集完全由定义文件的 `tools` 列表决定;runtime-injected 工具不能通过 Markdown 声明。 -- runtime-injected delegate/emit_signal 只在上下文允许时存在。 -- 并行 run 的 browser/resource scope 隔离。 -- persistent browser profile 可显式共享;具名 Agent不继承 transient parent scope。 -- 子 Agent不隐式继承主会话 history/memory/临时 Skill。 - -### 25.4 Inbox 与投递竞态 - -- completion update 与 inbox insert 原子。 -- wakeup 丢失后启动扫描恢复。 -- active accepting Turn 的 steer 进入 current Turn。 -- finalizing/closed/full 时 steer 恰好一次退化 queue。 -- 无活动 Turn 的 steer 启动内部 continuation。 -- 用户队列优先于 queue completion。 -- Turn persist 失败时 event 不 consumed。 -- lease 超时后可重领,旧 lease token 不能提交。 -- 正常取消/worker 退出由 lease guard 立即 release,不等待 lease timeout。 -- user mpsc 满不影响 durable wake;wake revision 丢失后扫描可恢复。 -- 用户持续输入时 burst/age 公平上限仍调度 continuation。 -- completion capacity 在 background 接纳时预留,signal 不能抢占。 -- 每个 run 独立 completion,逐 run 投递;无 group 汇总 Turn。 -- retries/TTL 耗尽进入 dead-letter,system fallback 最多发送一次。 - -### 25.5 Signal - -- emit_signal 无上下文或 foreground 禁止策略时失败。 -- 不能指定任意 target/channel/delivery。 -- dedupe key、速率、数量和大小上限生效。 -- signal 不结束 Agent Run。 -- Agent 异常退出仍自动生成 failure completion。 -- 已发 signal IDs 出现在 completion,避免重复汇报。 - -### 25.6 Sleep - -- 无输入时精确等待至 timer。 -- user steer、AgentSignal steer 立即唤醒并随后注入当前 Turn。 -- user queue、AgentCompletion queue 唤醒但正文不泄漏当前 Turn。 -- 输入先于 sleep 订阅时 revision 检查仍立即返回。 -- 多条输入只消费一次且顺序稳定。 -- `/stop`、parent cancellation、shutdown 取消 sleep 并终态化工具块。 -- wakeup 与 timer 同时发生时不丢输入;输入若未入当前 Turn则可靠排队。 -- sub-run sleep 不被 root session 输入唤醒,只响应 timer/cancellation。 - -### 25.7 取消、并发与恢复 - -- 父 foreground 取消级联后代。 -- 父 waiting_children 不持有唯一 permit,无死锁。 -- `/stop` 取消 session background runs,并恢复未提交 durable events。 -- `/stop` 产生的 cancelled completion 只投影状态,不启动新的 continuation。 -- 迟到结果不能覆盖 cancelled/interrupted。 -- reload 关闭 admission 后拒绝新 run/signal,pending inbox 由新代恢复。 -- Gateway 重启把 running 标记 interrupted 并生成 completion。 -- session 删除/归档后的事件按明确 dead-letter/cancel 策略收敛。 - -### 25.8 消息与客户端 - -- AgentSignal 不渲染为用户气泡。 -- Internal continuation 输入不出现在普通历史,assistant 汇总正常持久化。 -- hidden trigger 会参与 Provider replay,并与 assistant/usage/event consumed 原子提交。 -- continuation 使用稳定 delivery binding,绝不复用一次性 reply_to。 -- reasoning/provider state 不进入信号、API、客户端和日志。 -- send_message 仍走外部投递确认;emit_signal 不走 OutboundDispatcher。 -- 同 Turn 附件兼容路径与未来 attach_artifact 不产生重复历史。 -- run/event 增量按 revision 幂等,断线重连后可全量校准。 - -## 26. 必须保持的架构不变量 - -1. 同一 Session 最多一个活动主 Agent Turn;Agent 子 run 可以并行,但不能并发提交主会话历史。 -2. Root Agent 不能成为委托目标,结果回传不等同于反向委托。 -3. Foreground/Background 只描述委托方等待行为;并发是独立调度维度。 -4. Queue 输入永不泄漏正文到当前 Turn;Steer 只在安全边界注入。 -5. Signal 先持久化后唤醒;内存通知不是事实来源。 -6. Signal 是非终态事件;run Completion 由运行时自动生成且恰好对应一个 run 终态(批量背景也逐 run 生成,无 group completion)。 -7. SendMessage 是外部输出,EmitSignal 是内部输入,不能用一个公开万能工具混合权限。 -8. Durable Agent event 在 `/stop`、Turn 失败或 Gateway 崩溃时不能静默丢失。 -9. Agent Definition 和 Provider 绑定 runtime generation;运行中不热切换。 -10. 不持有 Session mutex 等待 Provider、工具、SQLite 或子 run。 -11. 父 run 等待子 run 时不持有会造成递归死锁的执行 permit。 -12. 完成状态、结果、usage、计划子项和 inbox event 使用事务/条件更新提交。 -13. 客户端、Channel 和日志永不暴露 Provider 私有 reasoning state、secret 或本地内部路径。 -14. Durable event payload 只以 SQLite inbox 为权威来源;普通 user task mpsc 和合并式 wake lane 都不能成为确认点。 -15. Continuation 的 hidden trigger 必须可供 Provider replay,但不得投影为用户消息;trigger、回复和 event consumption 原子提交。 -16. 已接纳 background run 的 terminal completion 容量已经预留;运行结束不能因 signal 洪泛丢失 completion。 - -## 27. 设计结论 - -目标架构把现有“一个 delegate 工具创建临时 Agent”提升为明确的编排系统: - -```text -Markdown Agent Definition - ↓ -AgentCatalog + runtime-injected 工具标记 - ↓ -AgentCoordinator - ├─ foreground:并发执行、父等待、tool result 返回 - └─ background:run ID 返回、signal/completion 进入 durable inbox - ↓ - queue | steer - ↓ - inbox wake/worker claim | current TurnMailbox - ↓ - Root Agent -``` - -Foreground 解决依赖型子任务;Background+Queue 解决稍后统一处理;Background+Steer 解决长任务期间的重要监控信号;可唤醒 Sleep 为安全等待提供及时响应点。三条消息路径各自保持单一职责:`emit_signal` 内部告警、自动 completion 终态回传、`send_message` 外部投递。该划分能够在不破坏 PicoBot Session/Turn/Delivery 既有不变量的前提下分阶段实现,并为权限、持久化、取消、热重载和客户端表现提供可验证边界。 diff --git a/docs/SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md b/docs/SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md deleted file mode 100644 index ee00ed2..0000000 --- a/docs/SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md +++ /dev/null @@ -1,1054 +0,0 @@ -# PicoBot 子 Agent 编排实施细节与可实施性审查 - -> 状态:实施基线(2026-08)。 -> -> 本文以 [`SUB_AGENT_ORCHESTRATION_DESIGN.md`](SUB_AGENT_ORCHESTRATION_DESIGN.md) 为产品与架构规范,以 [`SUB_AGENT_ORCHESTRATION_REVIEW.md`](SUB_AGENT_ORCHESTRATION_REVIEW.md) 和 [`SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md`](SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md) 为评审记录,并逐项对照当前代码给出可以直接拆分为开发任务的实现方案。 -> -> 本文不是“代码已经实现”的声明。实现完成前,运行时事实仍以当前代码、测试和 [`ARCHITECTURE.md`](ARCHITECTURE.md) 为准。 - -> 实施进度(2026-08):Phase 1 已落地具名 Definition/Catalog、不同 Provider profile、工具/Skill fail-closed 裁剪、显式 `AgentExecutionContext`、父子委托边与 ancestry 校验、canonical `foreground/background` schema 以及批量 foreground 并发。旧 general background 仅作为兼容路径保留。Phase 2A 已落地结构化取消:`AgentError::Cancelled/TimedOut`、CancellationToken 贯穿 root Turn、Provider 连接/stream、并行与串行工具批次以及 sleep;`/stop` 保留 oneshot 兼容桥接并同时取消 Turn token,协作式与强制路径提交同一 Cancelled 终态;树级 `max_runs_per_tree` 由共享原子计数在 foreground 委托接纳时强制。Phase 2B 已落地 durable foreground 编排:schema v6(`agent_run_groups`/`agent_runs`/`agent_session_state`/`agent_inbox_events` 与 messages/sessions 扩展列)在单一迁移事务中原子创建;Storage 领域 API(接纳、running/waiting_children 条件转换、execution-ID 条件 terminal commit、plan item 原子领取/完成、游标分页);`ExecutionGate` 分离 run quota 与 provider/tool step gate(global→session 顺序获取、弱引用键控回收、取消可中断等待),root Turn 步骤同样占用 step gate;`AgentCoordinator` 持久化全部具名 foreground run(单任务不建 group、批量建 group 并按请求顺序返回)、父 run `waiting_children` 转换、迟到结果丢弃与树位置授权;`agent_task` 工具提供 scoped get/list/get_result/cancel。foreground 不占用 run quota,嵌套 foreground 在并发上限为 1 时不死锁。Phase 3 已落地 durable inbox 与 queue continuation:具名 background 单任务经 Coordinator 接纳(completion slot 预留 → 持久化 queued → TaskSupervisor 托管 runner → terminal commit 原子转换 reservation 为 completion 事件 → notifier wake),spawn 拒绝执行补偿事务;`agent_inbox_events`/`agent_session_state` 容量条件更新与 claim/lease/admit/release/supersede/dead-letter API;`AgentInboxNotifier`(弱引用 late-bound wake)与 Session 双 lane worker(user mpsc + inbox watch 合并 wake),公平调度按 `max_user_turn_burst_before_inbox` 与 `max_inbox_wait_secs` 强制 continuation;continuation Turn 使用 hidden trigger、只读工具集,`commit_continuation_turn` 同事务提交 hidden trigger + assistant/tool/usage + event consume,失败显式 release lease;`client_visibility`/`turn_origin` 贯穿 ChatMessage/MessageMeta/协议 DTO,客户端历史查询默认过滤 hidden,`message_count` 只统计可见用户输入;activation recovery 收敛旧代 run(interrupted + failure completion)、过期 lease、group counter 与容量计数;`/stop`/archive/delete 走 `cancel_session`(suppress_continuation 写 consumed completion)并 dead-letter。background completion 不再直接通知 Channel。Phase 3 审查修复:worker 通过 `next_pending_due_at` 定时器在 release backoff 到期后重新 claim(不再依赖 wake);达到 `max_inbox_delivery_attempts` 的事件在正常运行中即 dead-letter 而非无限重试;`recover_on_activation` 对含 due 事件的在内存 session 发送合并 wake;修正 MIN 聚合无行时 NULL 被解码为 0 导致 worker 空转的缺陷(`oldest_pending_due`/`next_pending_due_at` 用 `Option>` 显式解码)。Phase 4 已落地 emit_signal 与 steer:Agent Definition frontmatter 新增 `signal:` 块(`SignalContract`:delivery queue/steer、总数/字节/间隔/burst/severity allowlist/dedupe 冷却窗/JSON 深度,全部由工具与 Coordinator 强制,模型只提供 key/severity/summary/details/dedupe_key);`EmitSignalTool` 仅在带 contract 的 run 注册(fail-closed),`insert_agent_signal` 支持同冷却窗 dedupe(返回原 event + deduplicated);Coordinator `emit_signal` 校验 run 活跃与 execution ID、容量条件插入、投影+notifier wake,取消 run 时未消费 signal 自动 supersede;SteeringMailbox 泛化为来源感知 TurnMailbox(user lane 32/64KiB 与 agent lane 8/32KiB 独立容量,`TurnInput{source,delivery,durable_event_id,lease_token}`,agent steer 投影为 hidden user 消息保留 source 元数据);steer 两阶段 admission(claim → mailbox 预留 → DB admit(turn_id) → 同 Turn/generation 激活),任何失败 release lease 并 wake queue lane;`/stop`/generation 变更时已 admit 的 steer 事件按 lease token 条件释放回 pending(绝不静默丢弃),用户 Turn commit 与 steer 事件 consume 同事务(`persist_turn_batch_with_steer_consumption`)。Phase 3 收尾已落地:`ChannelContext.durable_private`(Feishu 仅 thread/root/chat_type 进入)持久化到 `sessions.delivery_context` 并被 continuation 投递复用;WS 协议 `GetAgentRuns`/`GetAgentRun` 与 `SessionAgentRuns`/`AgentRunUpdated`/`AgentEventUpdated`(有界 `AgentRunView`/`AgentEventView`,不暴露 budget/contract/delivery context/execution id);`AgentProjectionHub` broadcast(复用 plan-change 模式,lag 由客户端 GetAgentRuns 校准);HTTP `/api/agent-runs*`(列表游标分页/详情/events/cancel)+ `/api/tasks` legacy/new union;WebUI TasksPage 后台 tab 渲染 run tree(group 折叠、agent 标签、深度缩进),ChatPage 显示 continuation 标签与 Signal 卡片(投影事件,不插入 history)。Phase 5 已落地 wake-aware sleep:`TurnWakeupPublisher`/`TurnWakeupHandle` 随 active Turn 创建(root interactive Turn 专用,sub-run 与 continuation 永远没有 handle);watch revision 单调,四个 admission 点(用户进 TurnMailbox、用户进 next-turn mpsc、Agent steer durable admitted、Agent event 保持 pending 且 wake Session)在事实可见后 publish(先更新状态再发 wake,sleep 醒来必能查到输入);`SleepTool` root 上下文先 `borrow_and_update` 预检 pending>0 立即返回,否则 select timer/changed/cancellation,steer 唤醒消息携带 run_id/agent_id 与安全摘要、queue 唤醒只给类型/数量并明确不泄漏正文;child/continuation sleep 仅 timer/cancel(不受 root 输入或 sibling signal 影响)。`InputInterruptPolicy` 元数据沿用(sleep=WakeOnly,其余默认 Never,不扩展)。legacy general background 的 direct notification 兼容路径按设计保留一个版本观察,随后删除旧 adapter 与 `background_tasks` 写入。后续破坏性收敛(2026-08,schema v7):删除 legacy 匿名 general 与 `background_tasks` 表(旧 adapter 移除、`/api/tasks` 只回 agent_runs);工具可派发门槛取消,普通工具由定义文件 `tools` 决定、`runtime_injected` 仅标记 delegate/emit_signal/get_skill/agent_task;内置 general-purpose 定义随二进制释放、`root_delegates` 默认指向它、WebUI「子代理」页可增删改启停并内联 provider/model;bash 开放 Delegatable。background 批量开放:`delegate_background` 批量接纳(单/批量,批量建 group,每 run 独立 completion slot),run permit 移入 runner(delegate 立即返回、排队计入 timeout),N 超 `max_concurrent_runs` 硬拒绝;取消 `completion_policy`(all/each 与 group_completion 事件移除,统一 each 语义);公平调度修正——空闲(无用户积压)时 due 事件立即 claim(完成即返回),忙碌时仍以 burst/age 防饥饿,顺带消除空闲时 0ms 定时器空转。后续收敛(2026-08,schema v8):彻底删除 group id——`agent_run_groups` 表删除、`agent_runs.group_id`/`completion_delivery`/`failure_delivery` 与 `agent_inbox_events.scope_kind`/`scope_id`/`group_id` 列删除(`run_id` 改 NOT NULL、`UNIQUE(run_id, event_type, event_key)`),`AgentExecutionContext.group_id` 移除,recovery 不再收敛 group counter,`RecoveryReport.groups_converged` 移除;WebUI TasksPage 后台 tab 由 group 折叠的 run tree 改为平铺 run 列表。 - -三方对齐审查(2026-08)修复:RunQuota 接线(background admission 前按 global→session 获取 run permit,随 runner 持有至 terminal commit;foreground 不占 run permit,嵌套 limit=1 永不死锁,与 gate 注释语义一致);`signal_contract_json`/`signal_delivery` 在 run 接纳时持久化(此前 steer delivery 静默回退为 queue);terminal completion payload 携带本 run 已发出的 signal IDs(§12.3);ROOT 的 `caller_scope_id` 固定为字面量 `"ROOT"`(§9.6);legacy general 委托返回弃用迁移提示(§4.3)。已知剩余偏差:`resource_scope_id` 未建模为显式字段(browser 瞬态隔离经 session_id 惯例达成,行为等效);`idempotency_key` 仅 schema 预留、工具未开放入口;`all` policy 的 group completion 事件在 background 批量开放前不可达;legacy 适配器与 `background_tasks` 写入按设计保留一个版本后删除。 - -## 1. 审查结论 - -结论为:**设计可实施,没有需要推翻总体方案的阻断项,但必须按依赖顺序实施,不能在当前 `SubAgentManager` 上直接追加 durable inbox 或 steer signal。** - -当前代码已经提供以下可复用基础: - -- `AgentLoop` 已经是跨 Turn 无状态执行器,工具执行统一经过 `ToolExecutionContext` 和 `ToolOutputProcessor`。 -- Session 已有单 worker、用户输入有界队列、活动 Turn steering、generation/state version 和 persistence lock。 -- `TurnController`、`DeliveryCoordinator` 已建立“持久化成功后才 Completed”的边界。 -- Storage 已有单事务迁移、批量消息与 usage 原子提交模式。 -- `TaskSupervisor`、`RuntimeAdmission` 和 Gateway 候选运行代已经提供重载与有界关闭框架。 -- WorkManager 已有 `execution_id` 条件更新和计划变更广播。 -- WebSocket 已有 request/event 路由与断线后全量校准的现成模式。 - -必须先解决的结构性差距如下: - -| 领域 | 当前实现 | 实施要求 | 风险等级 | -|------|----------|----------|----------| -| Agent 身份 | 一个共享 Provider 的临时子 Agent | 不可变 AgentCatalog、具名 definition、显式 caller/run context | 高 | -| 委托生命周期 | `inline/background/parallel` 混合建模 | `foreground/background` 与批量并发正交 | 中 | -| 取消 | Session 在 AgentLoop 外 drop future | CancellationToken 贯穿 Provider stream、工具批次和 child tree | 高 | -| 并发 | background run 持有一个 Semaphore permit | run admission 与 provider/tool step permit 分离 | 高 | -| 结果投递 | background 终态直接发 Channel | SQLite inbox 为事实源,Session wake 仅为加速器 | 高 | -| steering | mailbox 只接受用户 ChatMessage | 来源感知 TurnInput、durable admission 和可靠 queue fallback | 高 | -| continuation | 用户任务在模型调用前落库 | hidden trigger 与 assistant/tool/usage/event consume 同事务提交 | 高 | -| history | 所有 role=user 都作为用户消息 | internal replay 读 hidden,客户端默认过滤 hidden | 高 | -| Channel context | `reply_to` 与 opaque `private` 未区分稳定性 | Channel 显式提供 `durable_private`,核心不得猜 key | 高 | -| 重载恢复 | 候选代创建完整 SessionManager | Catalog 在 prepare 校验,run/inbox 恢复只能 activation 后执行 | 高 | -| 客户端 | `/api/tasks` 只读旧 `background_tasks` | 新 run/event 投影、revision(旧表已在 schema v7 删除) | 中 | - -实现的关键路径为: - -```text -Catalog/Tool policy - ↓ -AgentLoop cancellation + execution gate - ↓ -Run durable lifecycle - ↓ -Inbox state/capacity + Session wake + queue continuation - ↓ -Typed TurnMailbox + emit_signal/steer - ↓ -Wake-aware sleep + UI/legacy cleanup -``` - -Phase 3 依赖前面全部基础。若跳过 cancellation、持久化状态机或 hidden message 分层,结果会在 `/stop`、reload、SQLite 失败或进程重启时出现无法修复的丢失与重复。 - -## 2. 实施中固定的架构决策 - -以下决策在编码前固定,不留给各模块自行解释: - -1. Root Agent 是运行时身份,不创建虚假的 root run。`ToolExecutionContext.agent=None` 表示 Root;child 必须携带完整 `AgentExecutionContext`。 -2. `foreground/background` 只描述调用方是否等待。批量是否并发由 `tasks[]` 和调度器决定;不再存在 canonical `parallel` 模式。 -3. 每个 Agent Run 都落 `agent_runs`,包括 foreground。这样截断结果、失败审计和 `agent_task.get_result` 对两种模式一致。 -4. Background 接纳时预留 completion slot。signal 可以因 inbox 满而失败,已经接纳的 run completion 不得因容量耗尽而丢失。 -5. SQLite 是 run、event、容量计数和消费状态的唯一事实源。watch/broadcast/内存 map 只负责降低延迟或缓存投影。 -6. queue continuation 不伪造 `InboundMessage`。它通过 Session 内部 typed task 启动,并在成功时原子保存 hidden trigger、可见结果和 event consumption。 -7. `steer` 不取消 Provider 或普通工具;它只在安全边界注入。`/stop` 和显式 cancel 才触发 cancellation token。 -8. 候选运行代只解析和校验 Catalog,不扫描或修改数据库运行状态。恢复动作仅在新代 activation 后执行。 -9. 工具可用性由具名 Agent 定义文件的 `tools` 列表决定;`delegate`/`emit_signal`/`get_skill`/`agent_task` 为 runtime-injected,由 Coordinator 按 `delegates`/`signal`/`skills` 字段注入。 -10. session 归档/删除、run 取消、event supersede 和 dead-letter 都保留审计事实,不通过物理删除表达状态变化。 - -## 3. 目标运行时组件与依赖装配 - -### 3.1 GatewayState 新增成员 - -建议增加: - -```rust -pub struct GatewayState { - // existing fields ... - agent_catalog: Arc, - agent_coordinator: Arc, - agent_result_router: Arc, - agent_projection_hub: Arc, -} -``` - -装配顺序必须明确: - -```text -Config + registered built-in tools + SkillsLoader - → parse/validate AgentCatalog - → create AgentProjectionHub - → create late-bound AgentInboxNotifier - → create AgentCoordinator(Storage, Catalog, ProviderFactory, tools, Supervisor, Admission) - → create SessionManager(..., notifier) - → bind notifier to Weak - → create AgentResultRouter(Storage, notifier, projection hub) -``` - -`AgentInboxNotifier` 使用 late-bound weak target 解决 Coordinator/SessionManager 的环形依赖: - -```rust -#[async_trait] -pub trait AgentInboxWakeTarget: Send + Sync { - async fn wake_agent_inbox(&self, session_id: &str, revision: i64); -} - -pub struct AgentInboxNotifier { - target: RwLock>, -} -``` - -事件提交后 notifier 失败不回滚事务;周期扫描会重新唤醒。SessionManager 也不能反向持有强 `Arc` 形成释放环。 - -### 3.2 prepare 与 activation 分离 - -`GatewayState::from_config()` 是候选运行代准备阶段,只允许: - -- 解析配置和 Agent Markdown。 -- 校验 Provider profile、工具、skill 和委托图。 -- 创建无外部副作用的内存对象。 -- 运行 Storage schema migration。 - -`start_message_processing()` 成为 activation 边界,按顺序执行: - -1. 连接 MCP;第一版 Agent Definition 不允许引用 MCP 工具,因此此步不改变已经校验完成的 Catalog。 -2. 启动 Session worker/router、projection relay 和 inbox recovery scanner。 -3. 调用 `AgentCoordinator::recover_on_activation(runtime_generation)`,收敛旧代 queued/running/waiting runs。 -4. 扫描 pending/expired leases,修复 `agent_session_state` 计数并发出合并式 wake。 -5. 最后打开新代的 Agent run admission。 - -为避免候选代构造期间与旧代同时修改 run 状态,Coordinator 初始处于 closed 状态,activation 成功后才 `open()`。 - -## 4. 配置与 Agent Definition - -### 4.1 配置类型 - -在 `src/config/mod.rs` 增加: - -```rust -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(default, deny_unknown_fields)] -pub struct AgentOrchestrationConfig { - pub enabled: bool, - pub definitions_dir: String, - pub root_delegates: Vec, - pub max_tree_depth: u16, - pub max_runs_per_tree: usize, - pub max_concurrent_runs: usize, - pub max_concurrent_runs_per_session: usize, - pub max_concurrent_provider_steps: usize, - pub max_concurrent_provider_steps_per_session: usize, - pub max_concurrent_tool_steps: usize, - pub max_concurrent_tool_steps_per_session: usize, - pub max_pending_inbox_events_per_session: usize, - pub inbox_event_ttl_hours: u64, - pub max_inbox_delivery_attempts: u32, - pub max_user_turn_burst_before_inbox: usize, - pub max_inbox_wait_secs: u64, -} -``` - -`Config` 增加 `#[serde(default)] pub agent_orchestration: AgentOrchestrationConfig`。所有默认值必须保持现有配置可加载。旧 `gateway.max_concurrent_background_tasks` 字段已随 legacy adapter 一并删除。 - -配置校验需要拒绝 0 容量、session 上限大于 global 上限、TTL/timeout 超过硬上限,以及 definitions 目录越界。配置示例、README 和运行时 config reference 在功能合并时同步更新。 - -热重载允许把 inbox 上限降低到当前占用以下:已有 pending 和 reservation 仍受保护,不删除也不拒绝其 completion;新 background 接纳和 signal 在计数回落到新上限前返回 capacity exceeded。 - -### 4.2 Markdown parser - -新增 `serde_yaml` 依赖,禁止用现有 Skill frontmatter 的宽松字符串 parser 解析安全配置。建议类型: - -```rust -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct AgentFrontmatter { - id: AgentId, - description: String, - llm_profile: String, - #[serde(default)] tools: Vec, - #[serde(default)] delegates: Vec, - #[serde(default)] skills: Vec, - #[serde(default)] limits: AgentLimits, -} -``` - -加载算法: - -1. canonicalize definitions root,确认它是受信任目录。 -2. 只读取 root 第一层的 `*.md`,按文件名排序,保证错误顺序和 hash 稳定。 -3. 对每个文件先用 metadata 检查上限,再做有界读取;拒绝非 UTF-8、越界 symlink 和非普通文件。 -4. frontmatter 必须以第一行 `---` 开始并有独立结束 `---`;正文为空、重复 key、未知 key 均报错。 -5. `id` 使用 `[a-z][a-z0-9_-]{0,63}`,拒绝大小写折叠冲突和保留名。 -6. 对规范化 frontmatter JSON 与原始 role body 计算 SHA-256 `definition_hash`。 -7. 第一遍建立 ID map,第二遍解析 delegate target、Provider、工具和 skill 引用。 - -Catalog 最终类型不可变: - -```rust -pub struct AgentCatalog { - definitions: BTreeMap>, - root_delegates: BTreeSet, - runtime_generation: u64, -} -``` - -第一版 Catalog 只接受候选代准备阶段已经注册的 built-in 工具。现有 MCP 连接只允许在 activation 发生,为维持候选代无外部副作用和“引用错误拒绝整代”的不变量,MCP 工具不得出现在 Agent Definition;未来只有在 MCP 提供可离线校验的 tool manifest 后才能开放。 - -### 4.3 内置 general-purpose - -随二进制打包内置 `general-purpose` Agent definition(`resources/agents/general-purpose.md`),首次运行释放到 `/agents/`(已存在不覆盖,用户可编辑)。`root_delegates` 默认指向它,开箱即用;未启用编排或缺少 `target` 时委托会直接报错(旧匿名 general 兼容路径已移除)。 - -## 5. ToolRegistry 与执行上下文改造 - -### 5.1 Tool 元数据 - -在 `Tool` trait 上只有一个运行时注入标记(工具可用性由定义文件决定): - -```rust -/// 该工具由运行上下文注入(delegate 目标、信号契约、skill allowlist), -/// 不能直接写进 Definition 的 `tools` 列表。普通工具默认 false。 -fn runtime_injected(&self) -> bool { false } - -fn input_interrupt_policy(&self) -> InputInterruptPolicy { - InputInterruptPolicy::Never -} -``` - -`InputInterruptPolicy` 为 `Never | WakeOnly | CancelSafe`,不能从 Markdown 覆盖。 - -运行时注入工具: - -| 工具 | 标记 | 说明 | -|----------|----------|------| -| `delegate` | runtime-injected | 由 Definition 的 `delegates` 白名单注入 ScopedDelegateTool | -| `emit_signal` | runtime-injected | 仅在带 `signal:` 契约的 run 注入 | -| `get_skill` | runtime-injected(例外) | 写进 `tools` 表示启用 scoped skill 包装器 | -| `agent_task` | runtime-injected | 由 Coordinator 注入 | - -其余任何已注册工具(含 `bash`、`send_message`、`todo` 等)都可由管理员在定义文件的 `tools` 里显式授权。 - -`ToolRegistry` 增加只读构建方法,不在共享 registry 上删除工具: - -```rust -pub fn scoped_for_agent( - &self, - definition: &AgentDefinition, - runtime_tools: Vec>, -) -> Result, AgentCatalogError>; -``` - -### 5.2 ToolExecutionContext - -目标类型: - -```rust -#[derive(Clone)] -pub struct ToolExecutionContext { - pub session_id: Option, - pub turn_id: Option, - pub agent: Option>, - pub cancellation: CancellationToken, - pub execution_gate: Option>, - pub turn_wakeup: Option, - pub resource_scope_id: Option, - pub turn_origin: TurnOrigin, -} -``` - -Root interactive context 的 `agent=None`;Coordinator 只允许在 session/turn 身份完整时把它解释成 ROOT。缺少两者的 context 不能调用 delegate。child 的 `agent=Some`,其中 run、parent、ancestry、budget 和 signal contract 是授权事实。 - -不要再依赖 `DELEGATE_CONTEXT` task-local 作授权。它可以暂时保留为旧 adapter 桥接,但所有新工具必须从 `ToolExecutionContext` 读取身份。 - -## 6. AgentLoop:取消、许可与 typed input - -### 6.1 CancellationContext - -给 AgentLoop 的所有 process 入口增加 `AgentLoopExecution`: - -```rust -pub struct AgentLoopExecution { - pub cancellation: CancellationToken, - pub gate: Arc, - pub tool_context: ToolExecutionContext, -} -``` - -Provider 请求必须在获取 provider-step permit 后执行: - -```rust -let _permit = execution.gate.acquire_provider(&execution.cancellation).await?; -let response = tokio::select! { - _ = execution.cancellation.cancelled() => Err(AgentError::Cancelled), - result = self.stream_completion(...) => result, -}; -``` - -普通工具调用同样取得 tool-step permit。runtime-control 工具不取得普通 tool permit,防止父 run 等 child 时占住唯一许可。工具 future 被取消只表示 PicoBot 不再等待;对 bash、HTTP 写入等外部副作用不能宣称已回滚,因此迟到结果必须被 execution ID 条件提交挡住。 - -工具批次当前使用 `join_all`。改造后每项 future 自行获取 permit,批次仍可并发;取消时等待一个很短的 cooperative grace,然后由外层 task abort。`AgentError` 增加结构化 `Cancelled` 和 `TimedOut`,不要用字符串判断终态。 - -### 6.2 ExecutionGate - -配额分成两组: - -- `RunQuota`:统计 queued/running/waiting_children 的非终态 run,直到 terminal commit 才释放。 -- `StepGate`:provider/tool 步骤执行期间持有,步骤结束立即释放。 - -permit 固定按 global → session → agent 顺序获取;失败或取消逆序释放。为了避免动态 semaphore 缓存泄漏,session/agent gate 使用带弱引用的 keyed registry,并在没有 run/permit 时清理。 - -Root Turn 不是 Agent Run,不占 run quota,但它的 Provider/tool 步骤占 global/session step gate,这样 background run 不会绕过整个 Gateway 的资源上限。 - -### 6.3 waiting_children - -`delegate` foreground 执行前创建 `WaitingChildrenGuard`: - -1. child 接纳成功后,条件更新 parent `running → waiting_children`。 -2. 等待期间父没有 provider/tool step permit。 -3. 全部 child 终态、父取消或错误退出时 guard 尝试 `waiting_children → running`;父已经 terminal 时不覆盖。 -4. 父取消递归取消未终态 foreground descendants;background run 归 root session token 所有,不因创建它的 Turn 正常结束而取消。 - -### 6.4 typed Turn input - -Phase 4 将 `SteeringMailbox` 替换为 `TurnMailbox`。在此之前 Phase 2A 只做 cancellation,不改变用户 steering 行为,降低一次改动的回归面。 - -`AgentLoop::append_steering_messages` 不再强制构造 user source;改为由 serializer 把 typed source 转成 Provider-compatible role,同时保留 durable source metadata。Agent event envelope 明确标注为不可信数据,不能改变 system/tool 权限。 - -## 7. AgentCoordinator API 与状态机 - -建议公开最小接口: - -```rust -pub struct DelegateRequest { - pub mode: ExecutionMode, - pub tasks: Vec, - pub idempotency_key: Option, -} - -impl AgentCoordinator { - pub async fn delegate(&self, caller: CallerContext, request: DelegateRequest) - -> Result; - pub async fn get_run(&self, caller: CallerContext, run_id: &RunId) -> Result; - pub async fn list_runs(&self, caller: CallerContext, query: AgentRunQuery) -> Result<_, _>; - pub async fn get_result(&self, caller: CallerContext, run_id: &RunId) -> Result<_, _>; - pub async fn cancel_run(&self, caller: CallerContext, run_id: &RunId) -> Result<_, _>; - pub async fn emit_signal(&self, context: &AgentExecutionContext, signal: SignalInput) - -> Result; - pub async fn cancel_session(&self, session_id: &str, reason: CancelReason) -> Result<(), _>; - pub async fn recover_on_activation(&self, generation: u64) -> Result; -} -``` - -### 7.1 Background 接纳顺序 - -严格顺序: - -1. 从 context 解析 caller,校验 root session、委托边、ancestry、depth 和 budget。 -2. 解析全部 target definition,派生 deadline、token/run reservation 和 delivery contract。 -3. 取得 RuntimeAdmission activity guard 和内存 run quota reservation;尚未写库前任何失败均直接释放。 -4. Storage 事务条件增加 completion reservation、领取 plan item、插入 group/run queued 记录。 -5. 把 cancellation token 和 execution ID 注册到 Coordinator active map。 -6. 通过 `TaskSupervisor::spawn_graceful` 接纳 runner。 -7. spawn 被拒绝时执行补偿事务:queued → cancelled、释放 completion reservation、回滚/阻塞已领取 plan item;不得向模型返回可用 run ID。 -8. spawn 成功后返回 run ID。 - -第一版在步骤 1 强制 `background caller == ROOT`;child 只能 foreground 委托。该限制属于 Coordinator policy,不仅是 delegate schema 提示。以后开放 nested background 时仍必须把 run 归属到原 root session,并重新审查取消所有权和 completion reservation。 - -SQLite commit 与 task spawn 无法成为同一原子操作。崩溃发生在步骤 4 与 6 之间时,会留下旧 generation 的 queued run;activation recovery 必须将其收敛为 `interrupted` 并生成预留过的 completion,不能无限保持 queued。 - -ActivityGuard 必须移动进已接纳的 background runner,直到 terminal transaction 完成后才 drop;不能在 `delegate` 返回时提前释放。Runner 等待 run quota/provider permit 时同样受 deadline 和 cancellation 约束,排队时间计入 run timeout。 - -### 7.2 Foreground 执行 - -Foreground 同样先持久化 run,但不预留 inbox slot,也不创建 completion event。单任务直接 await;批量使用 `FuturesUnordered` 并保存原 request index,最终按请求顺序组装结果。每个 child 有独立取消 token;父取消时全部 token 同时取消。 - -完整结果先写 `agent_runs.result`,tool result 只返回有界 projection。这样无论是否截断,`agent_task.get_result` 都能读取相同事实。 - -### 7.3 terminal commit - -Runner 只返回 `AgentTerminalOutcome`,不能自己发 Channel 或更新 WorkManager: - -```rust -pub enum AgentTerminalOutcome { - Completed { result: String, usage: Usage, tool_calls: u32, iterations: u32 }, - Failed { error: AgentRunError, usage: Option }, - TimedOut { deadline_at: i64 }, - Cancelled { reason: CancelReason }, - Interrupted { reason: String }, -} -``` - -Coordinator 用 `(run_id, execution_id, runtime_generation, nonterminal status)` 条件提交。只有更新行数为 1 的赢家可以: - -- 写终态和完整 result/error。 -- 更新 group terminal counter。 -- 转换或释放 completion reservation。 -- 插入 run inbox event。 -- 更新绑定的 task item。 -- 递增 session agent revision。 - -迟到结果返回 `StaleExecution` 并丢弃,不改变已保存终态。 - -Coordinator 还需启动一个由 TaskSupervisor 管理、观察 shutdown token 的 deadline reaper。正常 runner 使用 `timeout_at(deadline)`;reaper 负责没有活跃内存 task 的 queued run 和 group deadline,分批条件终态化未完成 children,再走同一个 terminal transaction,不能维护第二套完成逻辑。 - -## 8. Storage schema v6 - -实现时把 `SCHEMA_VERSION` 从 5 升到 6。表创建、旧列增加、索引和 `PRAGMA user_version=6` 必须在当前 `migrate_schema()` 的同一事务中完成。下面的 `ALTER TABLE` 表达目标列;实际代码必须同时更新 fresh-schema 的 `CREATE TABLE`,迁移时沿用现有 `PRAGMA table_info` 检查,仅对缺失列执行 ALTER,不能在 fresh database 上重复加列。 - -SQLite 使用 INTEGER 表示布尔值;应用层枚举仍用 Rust enum,读到未知值要报 corruption,不能静默当成 completed。 - -### 8.1 session 与 message 扩展 - -```sql -ALTER TABLE sessions ADD COLUMN delivery_context TEXT; -ALTER TABLE sessions ADD COLUMN delivery_context_updated_at INTEGER; - -ALTER TABLE messages -ADD COLUMN client_visibility TEXT NOT NULL DEFAULT 'visible'; -ALTER TABLE messages -ADD COLUMN turn_origin TEXT NOT NULL DEFAULT 'user'; - -CREATE INDEX IF NOT EXISTS idx_messages_session_visibility_seq -ON messages(session_id, client_visibility, seq); -``` - -`delivery_context` 只序列化 `ChannelContext.durable_private`。现有 `reply_to` 和 `private` 永不写入该字段。 - -### 8.2 agent_session_state - -```sql -CREATE TABLE IF NOT EXISTS agent_session_state ( - root_session_id TEXT PRIMARY KEY, - revision INTEGER NOT NULL DEFAULT 0, - pending_event_count INTEGER NOT NULL DEFAULT 0, - reserved_completion_slots INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL, - CHECK (revision >= 0), - CHECK (pending_event_count >= 0), - CHECK (reserved_completion_slots >= 0) -); -``` - -这是 inbox 容量和客户端 revision 的权威计数行。每个改变客户端 run/event 投影的事务只分配一个新 revision,并把同一 revision 写回本事务改变的所有行;仅内部读取不递增。所有容量增加操作使用条件更新: - -```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; -``` - -若 session 尚无行,先 `INSERT ... ON CONFLICT DO NOTHING`,仍在同一写事务中执行条件更新。 - -### 8.3 agent_run_groups(已删除,schema v8) - -批量委托的组头表在 schema v8 中移除。批量请求现在只是多个独立 run 的集合:单/批量请求的 idempotency key 都写在各自 run 行,每个 background run 独立预留 completion slot、独立生成完成事件,不存在组级收敛或 all/each 策略。 - -### 8.4 agent_runs - -```sql -CREATE TABLE IF NOT EXISTS agent_runs ( - id TEXT PRIMARY KEY, - root_session_id TEXT NOT NULL, - root_turn_id TEXT, - parent_run_id TEXT, - caller_agent_id TEXT NOT NULL, - caller_scope_id TEXT NOT NULL, - idempotency_key TEXT, - agent_id TEXT NOT NULL, - definition_hash TEXT NOT NULL, - provider_profile TEXT NOT NULL, - provider_name TEXT NOT NULL, - model_id TEXT NOT NULL, - mode TEXT NOT NULL, - depth INTEGER NOT NULL, - plan_item_id TEXT, - execution_id TEXT NOT NULL, - task TEXT NOT NULL, - context_json TEXT, - budget_json TEXT NOT NULL, - signal_contract_json TEXT, - signal_delivery TEXT, - status TEXT NOT NULL, - result TEXT, - error TEXT, - prompt_tokens INTEGER, - completion_tokens INTEGER, - cost REAL, - tool_calls_count INTEGER NOT NULL DEFAULT 0, - iterations INTEGER NOT NULL DEFAULT 0, - runtime_generation INTEGER NOT NULL, - attempt INTEGER NOT NULL DEFAULT 1, - completion_slot_reserved INTEGER NOT NULL DEFAULT 0, - deadline_at INTEGER NOT NULL, - revision INTEGER NOT NULL, - started_at INTEGER, - finished_at INTEGER, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - CHECK (mode IN ('foreground', 'background')), - CHECK (status IN ('queued', 'running', 'waiting_children', 'completed', - 'failed', 'timed_out', 'cancelled', 'interrupted')), - CHECK (depth >= 1), - CHECK (completion_slot_reserved IN (0, 1)), - FOREIGN KEY (parent_run_id) REFERENCES agent_runs(id) ON DELETE RESTRICT -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_runs_execution -ON agent_runs(execution_id); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_runs_idempotency -ON agent_runs(root_session_id, caller_scope_id, idempotency_key) -WHERE idempotency_key IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_agent_runs_session_created -ON agent_runs(root_session_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS idx_agent_runs_parent -ON agent_runs(parent_run_id, created_at); - -CREATE INDEX IF NOT EXISTS idx_agent_runs_recovery -ON agent_runs(runtime_generation, status, deadline_at); -``` - -批量请求的 request idempotency key 写在各 run 行的 `idempotency_key`(`caller_scope_id` 相同),retry 能返回原 run 集合。 - -Session 使用软删除,agent 表不对 `root_session_id` 建级联外键;生命周期由 `cancel_session` 和恢复扫描显式收敛。 - -### 8.5 agent_inbox_events - -```sql -CREATE TABLE IF NOT EXISTS agent_inbox_events ( - id TEXT PRIMARY KEY, - root_session_id TEXT NOT NULL, - run_id TEXT NOT NULL, - event_type TEXT NOT NULL, - event_key TEXT NOT NULL, - delivery TEXT NOT NULL, - requires_continuation INTEGER NOT NULL DEFAULT 1, - severity TEXT, - payload_json TEXT NOT NULL, - status TEXT NOT NULL, - attempt_count INTEGER NOT NULL DEFAULT 0, - lease_token TEXT, - lease_until INTEGER, - next_attempt_at INTEGER, - admitted_turn_id TEXT, - last_error TEXT, - revision INTEGER NOT NULL, - created_at INTEGER NOT NULL, - consumed_at INTEGER, - superseded_at INTEGER, - dead_lettered_at INTEGER, - fallback_notified_at INTEGER, - fallback_suppressed_reason TEXT, - updated_at INTEGER NOT NULL, - CHECK (event_type IN ('signal', 'completion')), - CHECK (delivery IN ('queue', 'steer')), - CHECK (requires_continuation IN (0, 1)), - CHECK (status IN ('pending', 'leased', 'admitted', 'consumed', - 'superseded', 'dead_letter')), - UNIQUE(run_id, event_type, event_key), - FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE RESTRICT -); - -CREATE INDEX IF NOT EXISTS idx_agent_inbox_claim -ON agent_inbox_events(root_session_id, status, next_attempt_at, created_at); - -CREATE INDEX IF NOT EXISTS idx_agent_inbox_lease -ON agent_inbox_events(status, lease_until); - -CREATE INDEX IF NOT EXISTS idx_agent_inbox_revision -ON agent_inbox_events(root_session_id, revision); -``` - -run 外键使用 RESTRICT。清理默认只清理大正文或归档整组记录;若未来需要物理删除,必须先按明确保留策略删除 terminal inbox event,再删除 run,不能留下失去审计来源的事件。 - -### 8.6 事务 API - -不要把 SQL 分散在 Coordinator、Router 和 WorkManager。Storage 增加领域 API: - -```rust -accept_agent_group(request) -> AcceptedGroup -mark_agent_run_running(run_id, execution_id) -mark_agent_run_waiting_children(run_id, execution_id, expected_status) -commit_agent_terminal(run_id, execution_id, outcome) -> TerminalCommit -insert_agent_signal(run_id, execution_id, signal) -> EventCommit -claim_inbox_batch(session_id, now, lease) -> InboxLease -admit_inbox_event(event_id, lease_token, turn_id) -release_inbox_lease(event_ids, lease_token, retry) -commit_continuation_turn(batch) -> CommittedTurnDelta -recover_agent_state(active_generation, now) -> RecoveryReport -``` - -`commit_agent_terminal` 在一个事务内更新 run、容量计数、event、plan item 和 revision。提交后 Coordinator 调用 `WorkManager::refresh_after_external_commit()` 刷新 cache 并广播;WorkManager 不再为这条路径另开事务。 - -## 9. Agent Inbox、唤醒与公平调度 - -### 9.1 Session worker 接收面 - -Session 增加: - -```rust -agent_tx: Option>, -agent_inbox_wake: watch::Sender, -consecutive_user_turns: usize, -``` - -把 worker 创建逻辑抽为 `ensure_agent_worker_locked()`,用户 enqueue 与 `wake_agent_inbox()` 共用。worker 同时持有 user receiver 和 wake receiver: - -```rust -tokio::select! { - biased; - user = task_rx.recv(), if should_take_user => { ... } - changed = inbox_wake.changed() => { ... } -} -``` - -不能仅靠 `biased` 实现公平。每个 Turn 结束后查询最老 due pending event;满足以下任一条件时下一项必须是 continuation: - -- `consecutive_user_turns >= max_user_turn_burst_before_inbox`; -- oldest pending age >= `max_inbox_wait_secs`。 - -watch value 是最新 durable revision,只合并 wake,不携带 payload。worker 收到 wake 后从 SQLite claim;发送方从不等待 Session user mpsc 容量。 - -### 9.2 queue continuation - -claim batch 必须有大小与总字节上限,例如 8 events/32 KiB envelope。每个 run 的 completion 独立落库;主 Agent 空闲时收到即处理,忙碌时由公平调度合并(burst/age 上限)。 - -执行过程: - -1. Storage 把 due events `pending → leased`,返回 lease token。 -2. worker 构造 `AgentTaskSource::BackgroundAgentResults` 和 hidden trigger,但暂不落 messages。 -3. root Agent 使用只读 continuation ToolRegistry 执行。默认允许内容读取、检索和 scoped `agent_task.get/get_result`,不允许 send_message、todo、写文件、delegate 或其他外部副作用。 -4. 成功后 `commit_continuation_turn` 原子插入 hidden trigger、tool/assistant messages、usage,并把同 token events `leased/admitted → consumed`。 -5. commit 后更新 Session 内存、Turn Completed、projection 和 Channel delivery。 -6. 执行失败、取消或 stale generation 时 `InboxLeaseGuard` 显式 release 到 pending 并写 `next_attempt_at`。 - -这一流程与当前“先持久化用户消息再调用模型”不同,必须走独立分支。不能先写 hidden trigger,否则失败重试会在 history 中累积无 assistant 配对的内部输入。 - -### 9.3 hidden history - -`ChatMessage` 和 `MessageMeta` 增加: - -```rust -pub client_visibility: ClientVisibility, -pub turn_origin: TurnOrigin, -``` - -默认构造器生成 `Visible/User`。需要拆分查询: - -- `load_messages_for_replay(session_id)`:visible + hidden。 -- `load_visible_messages(session_id, ...)`:WebSocket/HTTP/管理历史。 -- `persist_message_batch_inner()`:支持两字段并保持向后兼容默认值。 -- `CommittedTurnDelta`:过滤 hidden,并携带 turn origin。 - -Session 的 `message_count` 只统计 visible external user input;`total_message_count` 和压缩/token 估算统计全部 replay messages。自动标题只由 visible user input 触发。 - -### 9.4 steer admission - -Router 的两阶段流程必须由测试锁定: - -```text -pending --claim(token)--> leased -leased --reserve current Turn--> memory reservation (not drainable) -leased --DB admit(token, turn)--> admitted -admitted --activate same turn/generation--> drainable TurnInput -``` - -任何失败都取消 reservation,并以 token 把 event 恢复 pending。若 active Turn 已关闭、mailbox 满或 generation 改变,直接释放 lease并 wake queue;这就是可靠 steer→queue fallback。 - -TurnMailbox 分 user lane 与 agent lane 容量,最终按 session sequence 合并排空。durable agent reservation 在 DB admit 前不能被 AgentLoop 看见;`/stop` 关闭 mailbox 时返回尚未提交的 event IDs/tokens,由 SessionManager 立即 release。 - -## 10. emit_signal 与 completion 投影 - -`EmitSignalTool` 只注册到携带 background signal contract 的 runner。foreground run、Root 或没有 contract 的 child 看不到该工具。 - -`emit_signal` 流程: - -1. 从 AgentExecutionContext 取得唯一 root session、run、execution ID 和 delivery。 -2. 校验 run 仍为 running/waiting_children 且 execution ID 匹配。 -3. 校验 severity、JSON 深度、summary/details 字节、信号总数、burst、最小间隔和 dedupe window。 -4. 条件增加 session pending count;容量必须扣除 reserved completion slots。 -5. 插入 event 和新 revision;commit 后发布 projection + wake。 -6. 返回 accepted event ID。事件已存在时返回同一 ID 和 `deduplicated=true`。 - -Completion 永远由 Coordinator 生成:每个 background run 的终态独立物化为一个 run completion inbox event(无 all/each 策略),批量也只是逐 run 生成。 - -显式 `agent_task.cancel` 可以把该 run 尚未消费的普通 signal 更新为 `superseded` 并减少 pending count;completion 事实不能 supersede。 - -## 11. Session 生命周期、停止与恢复 - -### 11.1 `/stop` - -执行顺序: - -1. 在 Session 锁内关闭 active Turn admission、取出 mailbox durable reservations、递增 worker generation、取出 root Turn cancellation token。 -2. 锁外取消 root token,条件 release admitted/leased inbox events。 -3. 调用 Coordinator cancel 当前 session 的 active background runs。 -4. cancellation terminal event 使用 `requires_continuation=false` 并直接 consumed,防止 stop 后又自动启动“已取消”Turn。 -5. stop 前已经 pending 的其他事件仍保持 pending;Session 下次可调度时处理。 - -`current_cancel` oneshot 兼容桥已移除,统一为 `CancellationToken` + 一个纯观测用的 `turn_busy` 标志。 - -### 11.2 archive/delete - -Session 归档或软删除必须调用 `AgentCoordinator::cancel_session`。提交事务: - -- 非 Scheduler 所有且未终态 run → cancelled。 -- 释放所有 completion reservation。 -- pending/leased/admitted events → `dead_letter(session_archived|session_deleted)`。 -- 清理 mailbox reservation,不启动 continuation。 - -客户端仍可通过管理面看到 run/event 审计。当前没有可靠 unarchive 投递语义,因此归档事件不保留 pending。归档/删除属于用户生命周期操作,写 `fallback_suppressed_reason` 并禁止 system fallback,避免用户删除对话后又收到后台通知。 - -### 11.3 restart/reload recovery - -activation recovery 分批执行,避免长事务: - -1. 将旧 generation 的 queued/running/waiting_children 条件更新为 interrupted。 -2. 为 background interrupted outcome 转换预留并插入 failure completion;foreground 只保存终态。 -3. 批量背景的每个 run 独立收敛为 interrupted/failure completion。 -4. expired leased/admitted events 恢复 pending,attempt +1,写 next retry。 -5. 按每 session 重算 `pending_event_count` 和有效 reservation;差异修复并记录 structured warning。 -6. 对有 pending due events 的 session 只发一次合并 wake。 - -旧代排空期间每个 run 持有 RuntimeAdmission activity guard。超出 reload grace 后 cancellation token 先触发;Supervisor 强制 abort 后留下的非终态记录由新代 recovery 标 interrupted。 - -## 12. ChannelContext 与结果投递 - -当前 `ChannelContext.private` 同时可能含稳定 thread/root ID 和一次性 message/reaction ID,核心无法安全猜测。类型改为: - -```rust -pub struct ChannelContext { - pub reply_to: Option, - pub private: HashMap, - pub durable_private: HashMap, -} -``` - -Channel adapter 负责分类: - -- CLI/WebUI:通常为空。 -- Feishu:thread/root/chat type 等是否可复用由 Feishu adapter 决定;message ID、reaction ID 和本次 reply target 留在 `private/reply_to`。 - -Session 在成功接纳外部用户输入时保存 `durable_private`。continuation 的 `TurnTarget` 使用 session 自身 channel/chat ID 和 durable context,`reply_to=None`。没有 binding 时照常提交历史和 WebSocket 投影,只跳过外部 Channel delivery。 - -## 13. WorkManager 原子性 - -当前 `assign_sub_agent` 和 `finish_sub_agent` 各自开事务,无法与 run 接纳/终态保持原子。实施方案: - -- 把 plan item 条件 SQL 移入 Storage 的 agent run transaction。 -- run 接纳时按 `(plan_id, item_id, status=pending)` 领取,并把 `execution_id` 写为 run execution ID。 -- terminal commit 时按同 execution ID 更新 completed/blocked 和 plan version。 -- WorkManager 新增 `refresh_after_external_commit(session_id, reason, item_ids)`,只负责重新查询、刷新 cache 和广播,不写数据库。 -- 若 plan item 已被其他执行领取,整个 run 接纳事务回滚;不得创建一个与计划脱节的 run。 - -## 14. 协议、管理 API 与 WebUI - -### 14.1 Rust protocol - -增加 DTO 而不直接序列化 Storage row: - -```text -WsInbound::GetAgentRuns { session_id, cursor, limit } -WsInbound::GetAgentRun { session_id, run_id } - -WsOutbound::SessionAgentRuns { session_id, revision, runs, next_cursor } -WsOutbound::AgentRunUpdated { session_id, revision, run } -WsOutbound::AgentEventUpdated { session_id, revision, event } -``` - -`TurnSnapshot`、`CommittedTurnDelta`、`turn_updated` 和 `turn_committed` 增加 `turn_origin`。DTO 不暴露 task/result 全文以外的敏感 context、reasoning、Provider state、budget internals 或 delivery context。 - -`AgentProjectionHub` 复用 WorkManager 的 broadcast 模式。广播 lag 不补逐条事件,客户端收到 lag 或重连后调用 `GetAgentRuns` 校准。revision 来自 `agent_session_state`,客户端只接受更大的 revision;分页 cursor 使用 `(created_at,id)`,不能用 offset。 - -### 14.2 HTTP 管理 API - -过渡期 `/api/tasks` 返回统一 projection: - -- 新 `agent_runs` 映射为 `source=agent_run`。 -- 旧 `background_tasks` 映射为 `source=legacy_background_task`。 -- 按 created_at 合并排序,旧记录只读。 - -新增受设备鉴权保护的: - -```text -GET /api/agent-runs?session_id=&cursor=&limit= -GET /api/agent-runs/:id -GET /api/agent-runs/:id/events -POST /api/agent-runs/:id/cancel -``` - -cancel endpoint 仍通过 Coordinator 做 root session/task-tree 授权,不直接执行 SQL。 - -### 14.3 WebUI - -现有 `TasksPage.svelte` 的“后台任务”tab 演进为运行树: - -- group 可折叠展示 children。 -- status、Agent、Provider/model、duration、usage、工具次数。 -- signal 数量、最近 severity、pending/dead-letter 标记。 -- terminal result 有界预览,完整内容按需加载。 -- cancel 只对允许取消的非终态 run 显示。 - -ChatPage 对 `turn_origin=agent_continuation` 显示轻量标签,不创建 user bubble;Signal 卡片来自 `AgentEventUpdated`,不能把 payload 插入普通聊天 history。所有新 Markdown/result 仍经过现有 sanitize 流程。 - -## 15. 可唤醒 sleep 的代码实现 - -`TurnWakeupHandle` 只由 root interactive Turn 创建并放入 ToolExecutionContext。它持有 `watch::Receiver`;state 使用独立于 durable inbox revision 的 session-local 单调 revision,因为用户 queue/steer 同样需要唤醒: - -```rust -pub struct TurnWakeupState { - pub revision: u64, - pub pending_user_steer: usize, - pub pending_user_queue: usize, - pub pending_agent_steer: usize, - pub pending_agent_queue: usize, - pub latest_source: WakeupSource, - pub latest_safe_preview: Option, -} -``` - -以下 admission 成功后递增 revision 并 `send_replace`:用户进入 TurnMailbox、用户进入 next-turn mpsc、Agent event durable admitted、Agent event 保持 pending 并成功 wake Session。先更新事实状态再发 wake,不能让 sleep 醒来却查询不到输入。 - -`SleepTool::execute_with_context`: - -1. 解析并限制 duration 到 24 小时。 -2. child context 的 `turn_wakeup=None`,只 select timer 与 cancellation。 -3. root context 先读取 `receiver.borrow_and_update()`;若 pending 总数已大于 0,立即返回,不进入等待。 -4. 否则同时等待 timer、`receiver.changed()` 和 cancellation。watch 保留最新 revision,因此 input 在检查与 select 之间到达也不会丢。 -5. wake 后再次读取 state 并构造有界结果。steer 可以返回来源、run ID 和安全摘要;queue 只返回类型/数量,不返回正文。 -6. cancellation 映射为统一 `AgentError::Cancelled`,不能让模型把它当普通 sleep 完成后继续执行。 - -Sleep 只提前结束工具 future,不消费 mailbox/inbox,也不自行改变 queue/steer。工具批次返回后,AgentLoop 在既定安全边界排空 steer;queue 留给下一个 Turn。 - -第一版 `InputInterruptPolicy` 只驱动 sleep 的 `WakeOnly`。不要顺带让 bash、browser、HTTP 或文件工具响应 input wake;以后开放 `CancelSafe` 必须逐工具证明取消、重试和副作用语义。 - -## 16. 分阶段实施清单 - -### 文件级落点 - -| 文件/模块 | 主要改动 | -|-----------|----------| -| `src/config/mod.rs` | orchestration config、默认值、边界校验 | -| `src/agent/definition.rs`(新) | Markdown/frontmatter 类型、严格 parser、definition hash | -| `src/agent/catalog.rs`(新) | immutable catalog、委托图与引用校验 | -| `src/agent/run.rs`(新) | run/outcome DTO、AgentRunner、ProviderFactory | -| `src/agent/coordinator.rs`(新) | 授权、预算、接纳、取消、deadline、terminal commit | -| `src/agent/inbox.rs`(新) | event/lease/projection/notifier contracts | -| `src/agent/agent_loop.rs` | cancellation、step gate、typed input safe boundaries | -| `src/agent/steering.rs` | 迁移为 typed TurnMailbox 与 reservation | -| `src/agent/sub_agent.rs` | legacy adapter,逐阶段缩减并最终删除旧 manager | -| `src/tools/traits.rs` | ToolExecutionContext、runtime-injected 标记、interrupt policy | -| `src/tools/delegate.rs` | 仅 run/run_many 和兼容参数转换 | -| `src/tools/agent_task.rs`(新) | scoped get/list/cancel/get_result | -| `src/tools/emit_signal.rs`(新) | contract-bound signal | -| `src/tools/sleep.rs` | watch-aware wait 和 cancellation | -| `src/session/session.rs` | dual lane worker、internal task、atomic continuation、stop release | -| `src/session/agent_inbox.rs`(新) | claim/admit/release、fair scheduler、lease guard | -| `src/storage/mod.rs` | schema v6 migration 和领域 API re-export | -| `src/storage/agent_run.rs`(新) | run transaction SQL | -| `src/storage/agent_inbox.rs`(新) | capacity/event/lease/recovery SQL | -| `src/storage/message.rs`、`session.rs` | visibility/origin/durable delivery context | -| `src/gateway/mod.rs`、`reload.rs` | prepare/activation、recovery task、projection relay | -| `src/protocol.rs`、`src/session/commands.rs` | run query/event DTO 和 Turn origin | -| `src/gateway/http.rs` | 管理 API、legacy/new projection union | -| `webui/src/pages/TasksPage.svelte` | run tree、event/dead-letter/cancel UI | -| `webui/src/pages/ChatPage.svelte` | continuation origin 和 Signal 卡片 | - -不要让 `session.rs` 继续吸收所有 inbox SQL 和状态机;先抽 Storage 领域 API 与 `session/agent_inbox.rs`,再接 worker,能显著降低竞态代码的审查难度。 - -### Phase 0:契约与 schema 冻结 - -- 固定本文 enums、状态转换、DDL 和默认值。 -- 给现有 delegate、background notification、Session stop/reload 行为补基线测试。 -- 为 config 示例准备 backward-compatible fixture。 - -完成条件:所有后续 PR 可以只引用固定 DTO/Storage contract,不再各自发明状态名。 - -### Phase 1:Catalog 与安全裁剪 - -- `definition.rs`、`catalog.rs`、严格 YAML parser。 -- Config 扩展与候选代校验。 -- Tool delegation metadata、scoped registry、skill wrapper。 -- `foreground/background` schema 和 legacy parameter adapter。 -- built-in general compatibility。 - -完成条件:具名 foreground Agent 能使用不同 Provider 和固定工具集;尚不开放 background 新路径。 - -### Phase 2A:AgentLoop 结构化取消 - -- CancellationToken 进入 root Turn 和 AgentLoop。 -- Provider stream、工具批次、sleep 观察 token。 -- `AgentError::Cancelled/TimedOut`。 -- `/stop` oneshot 兼容桥接和迟到结果测试。 - -完成条件:root 行为不变;Provider、tool、preparation 各阶段 stop 均得到一致 Cancelled 终态。 - -### Phase 2B:Coordinator、run persistence 与 execution gate - -- schema v6 的 run 部分。 -- ProviderFactory、AgentRunner、Coordinator。 -- run quota/provider gate/tool gate。 -- foreground 单/批量、parent waiting_children、agent_task。 -- WorkManager 原子绑定。 - -完成条件:foreground 全量持久化、截断结果可查询、并发上限为 1 时嵌套 foreground 不死锁。 - -### Phase 3:durable inbox 与 queue continuation - -- `agent_session_state`、inbox Storage API、容量 reservation。 -- late-bound notifier、Session watch wake、公平调度。 -- hidden messages、turn origin、atomic continuation commit。 -- recovery/dead-letter/fallback。 -- WebSocket/HTTP run projection 和 WebUI 基础运行树。 - -完成条件:background completion 不直接通知 Channel;空闲/忙碌/队列满/重启/reload 下最终都能被主 Agent处理或进入可诊断 dead-letter。 - -### Phase 4:signal 与 steer - -- EmitSignalTool、rate/dedupe/capacity。 -- typed TurnMailbox 和两阶段 steer admission。 -- `/stop` 立即 release、supersede 和 Signal UI。 - -完成条件:steer 在所有 admission 竞态中严格属于当前 Turn 或未来 queue 之一,不能重复或消失。 - -### Phase 5:wake-aware sleep 与清理 - -- TurnWakeupHandle watch revision。 -- root sleep 响应 user/agent queue/steer,child sleep 仅 timer/cancel。 -- 工具 interrupt policy。 -- 移除 direct background notification 默认路径;观察一个版本后删除旧 adapter 和旧表写入。 - -完成条件:sleep 不丢 pre-listen wake,queue 内容不泄漏进当前 Turn,steer 在下一个安全边界可见。 - -## 17. 测试与故障注入 - -除主设计测试矩阵外,实施必须增加以下代码级场景。 - -### 17.1 Storage - -- fresh database 创建 schema v6。 -- v5 fixture 升级到 v6,原消息默认 visible/user。 -- migration 任一步失败时 `user_version` 和全部表结构回滚。 -- 两个并发 background 接纳只有一个能占最后 completion slot。 -- completion 把 reservation 转 pending,不出现中间负数或超限。 -- terminal transaction 在 plan update/event insert/usage 任一点失败时全回滚。 -- execution ID 不匹配的迟到结果更新 0 行。 -- startup reconciliation 能修复故意破坏的 state counters。 - -### 17.2 AgentLoop/Coordinator - -- cancellation 发生在 permit wait、Provider connect、stream、工具批次、child wait。 -- parent 等 child 时不持 step permit;global provider permit=1 仍能完成。 -- foreground batch 并发执行但结果按 request index 返回。 -- parent cancel 取消 foreground descendants,不取消已独立接纳的 background run。 -- TaskSupervisor spawn reject 执行补偿事务且 delegate 不返回可用 ID。 -- crash window 留下 queued old-generation run,activation 恢复为 interrupted。 - -### 17.3 Inbox/Session - -- watch wake 在 worker 开始等待前发生也不会丢。 -- user mpsc 满不影响 durable event。 -- 连续用户输入达到 burst 上限后强制执行一个 event batch。 -- continuation Provider 失败显式 release;进程模拟崩溃后 lease expiry 恢复。 -- hidden trigger 与 assistant/event consume 原子;客户端 history 永远不返回 hidden。 -- hidden user 不增加 title threshold/message_count。 -- `/stop` 与 admit 的每个交错点都只产生 pending 或 consumed/admitted ownership之一。 -- archived/deleted session 不启动 continuation且 reservation 归零。 - -### 17.4 Channel/protocol/UI - -- durable context round trip 不包含 reply_to、message/reaction ID。 -- 无 delivery binding 时 history commit 成功且不发送其他 chat。 -- event broadcast lag/reconnect 后 full query 校准 revision。 -- legacy/new tasks union 不重复并稳定分页。 -- Signal payload、result Markdown 和错误信息通过 sanitize/redaction。 - -### 17.5 Sleep - -- input 在 sleep 读取 revision 前、读取后但 select 前、select 后三个时点到达都能提前结束。 -- user/agent 的 queue wake 只返回数量,不泄漏正文;对应输入仍由下一 Turn处理。 -- steer wake 返回后由 AgentLoop 安全边界注入,SleepTool 自身不消费输入。 -- child sleep 不因 root user input 或 sibling signal 唤醒,但 run cancel/timeout 会立即结束。 -- duration 超过 24 小时拒绝;Gateway shutdown 不留下未托管等待任务。 - -建议把 SQLite failpoint、barrier 和暂停钩子限制在 `#[cfg(test)]`,用于精确制造 admission、stop、commit 与 reload 竞态,避免依赖随机 sleep。 - -## 18. 验证命令与合并门槛 - -每个 Rust 阶段至少执行: - -```bash -cargo fmt --check -cargo test --lib -cargo test --test test_scheduler -cargo test --test test_request_format -cargo clippy --all-targets --all-features -- -D warnings -cargo build -``` - -涉及 WebUI 时另外执行: - -```bash -cd webui -npm ci -npm run check -npm run build -cd .. -cargo build -``` - -文档或配置变更执行 `git diff --check`,并核对 README、配置模板、ARCHITECTURE、AGENTS 和 runtime knowledge 是否需要同步。API-backed ignored tests 只在配置了真实凭据时运行。 - -功能首次合并时按仓库规则提升产品中段版本;本文档本身不改变行为,不单独修改 `1.5.1`(当前功能合并版本 1.7.0)。 - -## 19. 主要风险与回滚 - -| 风险 | 预防 | 回滚方式 | -|------|------|----------| -| hidden history 过滤错误泄漏内部输入 | 查询 API 分层和协议测试 | 禁用 continuation worker,保留事件 pending | -| completion 容量计数漂移 | 单行条件更新 + 启动重算 | reconcile 修复后重启 Router | -| steer admission 竞态重复 | lease token + 不可排空 reservation | 配置强制所有 Agent delivery=queue | -| background 新路径影响现有用户 | legacy adapter 与新表分离 | 一个版本内启用 direct-notification rollback flag,二者互斥 | -| reload 双代同时恢复 | prepare/activation 硬边界 | 关闭新代 admission,旧代继续服务 | -| 工具权限误放大 | 工具集完全由定义文件决定 + Catalog fail closed | 从 definition 移除工具并 reload;已启动 run 固定旧快照 | -| continuation 重试重复外部副作用 | 默认只读 registry | dead-letter,要求用户显式处理 | - -rollback flag 只能在 Phase 3 过渡期存在,并保证新 inbox continuation 与旧 direct notification 互斥。即使回滚投递方式,新 `agent_runs`/inbox 表仍保留审计事实,不能降 schema 或删除记录。 - -## 20. 最终实施判据 - -只有同时满足下列条件,才能把主设计状态从“提案”改为“已实现”: - -- 具名 definition、不同 Provider、委托图和工具裁剪全部由运行时代码强制。 -- foreground/background 都有 durable run,batch foreground 真正并发且父同步等待。 -- run quota 与 provider/tool step gate 分离,嵌套委托无 permit 死锁。 -- background completion 和 signal 先落 SQLite,丢 wake、queue 满、重启和 reload 均可恢复。 -- queue continuation 使用 hidden trigger 原子提交;客户端历史没有伪用户消息。 -- steer admission 与 `/stop` 竞态经过故障注入证明无丢失、无双重归属。 -- sleep 的 wake 只改变等待,不破坏 queue/steer 内容边界。 -- session archive/delete、dead-letter、fallback 和 management UI 均可诊断。 -- 全部离线测试、Clippy、build、WebUI check/build 通过,并同步公开文档和产品版本。 - -在达到这些判据前,可以按 Phase 逐步合并内部基础,但不能提前对外宣称已经具备可靠的 background Agent orchestration。 diff --git a/docs/SUB_AGENT_ORCHESTRATION_REVIEW.md b/docs/SUB_AGENT_ORCHESTRATION_REVIEW.md deleted file mode 100644 index 985644f..0000000 --- a/docs/SUB_AGENT_ORCHESTRATION_REVIEW.md +++ /dev/null @@ -1,153 +0,0 @@ -# 子 Agent 编排与信号投递设计审核报告 - -> 状态:审核完成(2026-08)。审核对象为设计提案 `docs/SUB_AGENT_ORCHESTRATION_DESIGN.md`,该设计尚未实现;本文所有"现状"描述以当前代码和测试为准。 -> -> 本文结合现有实现逐条核实设计的现状诊断,评估架构合理性,并按严重程度列出缺陷与落地前必须补齐的定义。行号基于审核时的代码快照,后续实现合并后可能过时。 -> -> 设计方逐项答复见 [`SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md`](SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md);已接受结论同步写入设计文档。 - -## 1. 审核范围与依据 - -### 1.1 审核对象 - -- 设计文档:`docs/SUB_AGENT_ORCHESTRATION_DESIGN.md`(提案,未实现;`rg` 确认 `src/` 与 `webui/` 中无任何 `AgentCatalog`/`AgentCoordinator`/`agent_inbox`/`emit_signal` 相关实现) -- 对照实现:`src/agent/sub_agent.rs`、`src/agent/agent_loop.rs`、`src/agent/steering.rs`、`src/session/session.rs`、`src/session/turn_input.rs`、`src/session/persistence.rs`、`src/tools/delegate.rs`、`src/tools/sleep.rs`、`src/tools/send_message.rs`、`src/tools/traits.rs`、`src/storage/`、`src/work/mod.rs`、`src/scheduler/mod.rs`、`src/task_supervisor.rs`、`src/config/mod.rs`、`src/gateway/reload.rs` - -### 1.2 审核依据 - -- `docs/ARCHITECTURE.md` 与 AGENTS.md 中的架构边界和并发不变量 -- 现有相似机制:Scheduler durable lease(`src/storage/scheduler.rs:310-348`)、WorkManager 乐观并发(`src/work/mod.rs:320-342`)、TurnController"持久化后才 Completed"(`src/session/persistence.rs:147-167`)、RuntimeAdmission(`src/gateway/reload.rs`) - -## 2. 总体结论 - -**设计方向合理,可以按分期推进;但存在 5 处与现有代码强耦合的接缝缺口(A1–A5),落地前必须先补齐定义,否则 Phase 2/3 会被迫返工。** - -设计的核心决策——`foreground/background` 与 `queue/steer` 两个正交维度、先持久化后唤醒、SQLite inbox 为权威来源、lease/consumed 事务提交、ancestry 环检查、AgentCatalog 绑定运行代——与 PicoBot 既有不变量一致,且现状诊断(设计 §1 的 9 条)逐条属实(见第 3 节)。主要问题不在方向,而在设计与现有 session worker、`/stop`、AgentLoop 取消机制的衔接处留白过多。 - -## 3. 现状诊断核实 - -设计 §1 的 9 条诊断全部与代码一致: - -| # | 设计诊断 | 代码证据 | 结论 | -|---|----------|----------|------| -| 1 | 所有子 Agent 复用同一 `LLMProviderConfig` | `SubAgentManager.provider_config` 单实例(`src/agent/sub_agent.rs:119`),inline/background 均用它创建 Provider(:204、:477) | 属实 | -| 2 | 无具名角色文件,工具权限由 `allowed_tools` 临时决定 | `delegate` schema 的 `allowed_tools` 数组(`src/tools/delegate.rs:50-54`);未填时用默认只读集(`sub_agent.rs:34-41`) | 属实 | -| 3 | 子 Agent 被统一移除 `delegate` | `filter_tools` 硬编码排除 `delegate`/`todo`/`reload_config`(`sub_agent.rs:177-181`) | 属实 | -| 4 | `DelegateContext` 只有 session/channel/chat | `sub_agent.rs:90-95`;无 caller/parent/depth/ancestry | 属实 | -| 5 | `parallel` 混淆"委托方是否等待"与"是否并发" | `run_parallel` 就是 `join_all(run_inline)`(`sub_agent.rs:332-346`) | 属实 | -| 6 | 后台完成通知直发 `MessageBus.outbound`,不成为主 Agent 输入 | `background-task-notifications` 任务格式化后 `publish_outbound` fire-and-forget(`src/session/session.rs:1757-1776`),不写会话历史、不触发 Turn | 属实 | -| 7 | Steering mailbox 只建模用户输入,且继承 `/stop` 丢弃语义 | admission 只推 `SourceKind::UserInput`(`session.rs:2910-2938`);AgentLoop 防御性归一 `role=user`(`src/agent/agent_loop.rs:624-628`);`/stop` 调 `close_and_take_pending` 主动丢弃(`session.rs:2120-2128`、`src/agent/steering.rs:215-229`) | 属实 | -| 8 | `SleepTool` 只等定时器 | `tokio::time::sleep` 单一路径(`src/tools/sleep.rs:72`),无 wakeup/cancel 分支 | 属实 | -| 9 | `send_message` 同时覆盖跨 Channel、目标会话写入和同 Turn 附件暂存 | `src/tools/send_message.rs` 的 target/content/origin/files 参数;`OutboundDelivery::AttachedToCurrentTurn` 同 Turn 分支(`src/tools/traits.rs:127-131`) | 属实 | - -**补充:设计隐含覆盖了一个现存 bug。** inline 结果截断时提示"完整结果请使用 check_task 查看"(`sub_agent.rs:809`),但 `run_inline` 从不写 `background_tasks` 表(只有 `run_background` 写,`sub_agent.rs:373-398`),`check_task`(`sub_agent.rs:707-713`)查不到 inline 结果。设计 Phase 2"Foreground 结果也持久化"(§23)修复此问题,分期安排正确。 - -## 4. 设计合理性评估 - -以下决策予以肯定: - -| 设计点 | 评估 | -|--------|------| -| 执行生命周期与投递方式正交化(§4.1) | ✅ 干净消除 `parallel` 的语义混淆;批量 foreground 并发等价旧 parallel 但不作为第三种模式 | -| 先持久化后唤醒、内存 wakeup 仅为加速器(§6.4、§14.4) | ✅ 与"持久化后才 Completed"既有不变量(`persistence.rs:147-167`)同构 | -| lease/consumed 与条件更新(§14、§16.4) | ✅ 复用 Scheduler durable lease 与 WorkManager 乐观并发的成熟模式 | -| `target not in ancestry` 拒绝 A→B→A(§7.1) | ✅ 以显式 iteration workflow 替代隐式递归,边界正确 | -| 授权不依赖 task-local(§8) | ✅ 正确诊断现状 `DELEGATE_CONTEXT` task-local(`sub_agent.rs:19-28`)不是授权事实来源;`tokio::spawn` 不传播 task-local | -| AgentCatalog 以 Arc 固定运行代(§5.4、§18.4) | ✅ 与 RuntimeAdmission 现有集成一致(`SubAgentManager` 已接 admission,`sub_agent.rs:157-163、353-358`) | -| `llm_profile` 引用现有 `config.agents`(§5.2) | ✅ `Config.agents` 与 `get_provider_config(agent_name)` 已存在(`src/config/mod.rs:45、712-745`),无需配置重构 | -| Completion 由运行时自动生成、不依赖模型记得调工具(§12.1) | ✅ 正确;`emit_signal` 无任意目标参数,收敛了权限面 | -| 可唤醒 sleep 用 watch revision 而非裸 Notify(§17.2) | ✅ 正确规避"输入先于订阅到达"的丢失唤醒竞态 | -| §26 不变量清单 | ✅ 与 ARCHITECTURE.md 一致,可作为实现验收标准 | -| 分期顺序(§23) | ✅ Phase 1 纯增量;Phase 2 顺带修复 inline 截断 bug;依赖方向正确 | - -## 5. 缺陷清单 - -严重程度:A=主要(落地前必须补齐定义);B=中等(实现对应 Phase 前补齐);C=次要(修订文档即可)。 - -### 5.1 A 级:主要缺陷 - -**A1 — Session 队列饱和语义与现状冲突(设计 §15.3)** - -现状:session 队列容量 32(`session.rs:27`),满时 `try_send` 失败直接丢弃输入并回复"队列已满"(`session.rs:3001-3007`)。设计要求 durable event 在队列饱和时"保持 durable pending,由 Router 有界重试,不能丢弃",但未定义: - -- agent 事件与用户输入是否共用同一 mpsc(共用则用户流量可长期占满队列,事件重试无收敛界); -- Router 重试的退避、deadline 与最终处置; -- §15.4 只拆分了 TurnMailbox 的 lane(user 32/64KiB、agent 8/32KiB),session 级队列的 agent lane 容量与优先级未定义。 - -**A2 — `/stop` 与 worker 退出时 durable event 的恢复机制缺失(设计 §18.3)** - -现状 `/stop`:`current_cancel.take()`(`session.rs:2117`)→ `close_and_take_pending` 丢弃 steering(:2120-2128)→ `agent_tx.take()` 丢弃全部排队任务(:2136)→ bump generation/state_version(:2139-2140)→ 取消后台子任务(:2144-2148)。mpsc 被 drop 时没有逐项回调。设计未定义: - -- 被丢弃的内部 `AgentTask`(含 `BackgroundAgentResults`)如何触发 inbox lease 释放——只能靠 lease 超时被动收敛(应明说延迟界),或为 AgentTask 增加 Drop guard(未提); -- `/stop` 后 worker 退出(`task_rx.recv()` 返回 None 即 break,`session.rs:3150-3152`),被取消 run 异步生成的 cancelled completion 由谁、何时消费,完整链路未写。 - -**A3 — `waiting_children` permit 释放在现有结构中无落点(设计 §19.2)** - -AgentLoop 目前没有任何 permit/cancellation 原语(`agent_loop.rs` 无 CancellationToken/select;取消靠 worker 整体 drop future,`session.rs:3778-3804`);TaskSupervisor 也没有并发上限,只在 stopping 时拒绝 spawn(`task_supervisor.rs:60-89`)。设计只给出原则"permit 限制活跃 Provider/工具步骤",未定义: - -- permit 由谁持有与获取/释放(AgentLoop?AgentRunner?Coordinator?); -- delegate 在父 run 的 tool batch 内执行(`agent_loop.rs:1187-1242`),父 run 进入等待时释放 permit 的钩子如何嵌入现有批处理流程。 - -这是 Phase 2 复杂度最高的部分,只给原则不够。 - -**A4 — 结构化取消是前置条件,但 AgentLoop 当前零支持(设计 §18.1)** - -新架构中子 run 是 Coordinator spawn 的独立任务,父 future 被 drop 不再传播取消,必须用显式 CancellationToken 树贯穿 AgentLoop——这是横切重构,设计只在 Phase 2 列了一行"实现结构化取消"。另有一处表述需要澄清:§3 非目标"不默认硬中断正在进行的 Provider 请求"只约束 `steer`;现有 `/stop` 恰是硬 drop(drop `process_future` 连带中断 provider 流,`session.rs:3778-3804`)。文档应显式声明 `/stop` 保持硬语义,避免实现时误读为 `/stop` 也要走安全边界。 - -**A5 — 内部 continuation Turn 的消息/持久化/渲染模型未定义(设计 §16.3)** - -现有 Turn 以用户消息为起点:先持久化用户消息(`session.rs:3191`);`prepare_turn_input` 把 runtime context 附加到最后一条 user message(`src/session/turn_input.rs:19-25`);WebUI/TUI 按 user/assistant 交替渲染。设计说"内部输入不显示用户气泡",但未定义: - -- continuation Turn 写什么消息行(无 user 行?系统行?)、历史 replay 给 provider 时的形态; -- 客户端如何渲染无用户消息的 Turn(§15.1 的 SourceKind 扩展只解决标记问题); -- continuation 输出的投递目标:`AgentTask` 的 channel/chat_id/channel_context 来自 InboundMessage(`session.rs:631-641`),内部任务没有 channel 上下文,应显式规定投递到 session 最近的 channel/chat。 - -### 5.2 B 级:中等缺陷 - -**B1 — browser/resource scope 隔离是行为破坏,且无共享出口(设计 §10)。** 现状子 Agent 复用父对话的 browser session(`browser_session_id` 回退到 delegate context 的 session_id,`sub_agent.rs:264-279`;background 路径 :505-508 同)。改为 `root_session_id + run_id` 隔离会破坏依赖父会话登录态/cookie 的场景。"确需共享必须由工具定义显式支持"没有给出机制,应指明 `browser_profiles` persistent ID 为官方共享路径。 - -**B2 — dead_letter 与重试上限策略空缺(设计 §14.3、§21)。** Phase 3 移除直发通知后,continuation 反复失败转 dead_letter 时结果对用户彻底不可见,文档未定义 dead-letter 后的用户可见行为(如回退一条系统通知)。`max_pending_inbox_events_per_session=128` 打满后新事件的行为同样未定义。 - -**B3 — `completion_policy=each` 只有字段没有语义(设计 §14.2)。** 正文只描述了 `all`:一个 run 超时会把全组结果交付拖到 group deadline,缺少 per-run 提前交付或分组拆分策略。 - -**B4 — "UI 未读状态"是防饥饿的关键依赖,但不存在且未立项(设计 §16.2)。** 调度优先级把 queue completion 排在用户输入之后,持续用户流量下后台结果会被无限推迟,设计靠"UI 未读状态"兜底;该 WebUI 功能当前不存在,Phase 3 只写了"WebUI 投影",未列为明确工作项。 - -**B5 — `cost` 字段假设了不存在的定价配置(设计 §6.5、§14.1)。** `agent_runs.cost` 与 ProviderFactory"复用价格信息"的前提不成立:config 从不填 `price_input/output_per_million`(`src/config/mod.rs:742-743` 硬编码 None,无配置键解析)。要么补定价配置,要么注明 cost 暂为 NULL。 - -**B6 — 子 Agent run 内 sleep 的唤醒语义未定义(设计 §17)。** 全章隐含 root session 的 Turn;sub-run 没有"当前 session 用户输入"概念。应显式规定 sub-run 内 sleep 只响应自身 cancellation/timeout,否则 `TurnWakeupHandle` 的来源不明。 - -### 5.3 C 级:次要问题 - -**C1 — SQLite UNIQUE 与 NULL 语义(设计 §14.3、§9.6)。** `UNIQUE(run_id, event_type, event_key)`:`emit_signal` 未提供 `dedupe_key` 时 `event_key` 的生成规则未定义。`idempotency_key` 的唯一范围 `(root_session_id, caller_run_id, key)` 在 ROOT 调用时 `caller_run_id` 为 NULL,SQLite 中 NULL≠NULL 会导致去重失效,需要哨兵值(如 `root`)。 - -**C2 — 授权与上下文的细节留白(设计 §7.1、§9.1、§10)。** definition `max_depth` 与全局 `max_tree_depth` 是否取 min 未明说;`agent_task` 工具能否操作本 root session 任务树之外的 run(跨 session 越权)未明说;skills/memory 是否进入子 Agent 上下文未提(现状子 Agent 可带 skills prompt,`sub_agent.rs:188-197`;memory recall 只在 session Turn,`turn_input.rs:47`)。 - -**C3 — `async` 别名是多余假设(设计 §22.1)。** 现代码从未接受 `async`(`delegate.rs:151-165` 只解析 inline/background/parallel),该迁移条目可删。 - -**C4 — 客户端协议变更未枚举(设计 §20.2、Phase 4)。** AgentSignal 卡片、"已接纳"状态、continuation Turn 都需要新的 `WsOutbound` 消息类型(`src/protocol.rs`),Phase 4 只写"添加 AgentSignal UI 和任务树",未列协议变更清单。 - -## 6. 修订建议 - -实现启动前,建议在设计文档中补充五个专项定义(对应 A 级缺陷): - -1. **Durable event 与 session 队列的 lane 划分**:agent 事件是否独立队列、饱和时的重试退避/deadline/dead-letter 策略、与用户输入的优先级关系(A1)。 -2. **`/stop`、worker 退出与 lease 释放的衔接**:被丢弃内部任务的 lease 释放路径(Drop guard 或明确依赖 lease 超时及延迟界)、`/stop` 后生成的 cancelled completion 的消费链路(A2)。 -3. **Permit 归属**:执行 permit 在 AgentLoop/AgentRunner/Coordinator 之间的获取与释放点,特别是 `waiting_children` 前后的钩子位置(A3)。 -4. **Continuation Turn 模型**:消息行写入形态、provider replay 形态、客户端渲染契约、输出投递目标(A5)。 -5. **取消横切方案**:CancellationToken 贯穿 AgentLoop 的接口设计,并显式声明 `/stop` 保持硬 drop 语义、安全边界注入只约束 `steer`(A4)。 - -B 级问题建议在对应 Phase 实现前补齐:B1/B6 在 Phase 1,B5 在 Phase 2,B2/B3/B4 在 Phase 3。 - -## 7. 分期实施意见 - -| Phase | 风险 | 意见 | -|-------|------|------| -| 1 具名 Agent 与 Foreground | 低 | 纯增量。`llm_profile` 直接复用 `Config::get_provider_config`(`config/mod.rs:712-745`),无配置重构。注意 B1:browser scope 隔离会改变现有子 Agent 共享父会话 browser 的行为,需要迁移说明 | -| 2 统一 Run 持久化 | 高 | 改动面最大:AgentLoop 取消与 permit 均为横切变更。建议先独立原型"CancellationToken 贯穿 AgentLoop",用现有 sleep 取消测试(`sleep.rs:194-237`)与 steering 恢复测试锁定回归基线,再叠加 permit | -| 3 Inbox 与 Queue Completion | 中 | UX 拐点:完成通知从直发 Channel 改为主 Agent continuation。建议保留配置开关回退直发通知,覆盖 B2 的 dead-letter 空窗;§25.4 测试矩阵是本 Phase 验收关键 | -| 4 Emit Signal 与 Steer | 中 | TurnMailbox 泛化触及 `handle_message` 核心 admission 路径(`session.rs:2815-2953`),与 `/stop` 的原子性必须沿用现有同锁判定模式;先补 C4 协议清单 | -| 5 可唤醒 Sleep | 低 | 相对独立。watch revision 方案正确;先补 B6 的 sub-run 语义 | - -## 8. 结论 - -设计的现状诊断准确、核心决策与既有架构不变量兼容、分期依赖方向正确,**审核结论为"方向通过,需修订后实现"**。A1–A5 五个接缝缺口不是方向错误,而是设计与 `session worker`/`/stop`/`AgentLoop` 取消机制的衔接定义不足;按第 6 节补齐专项定义后,可按第 7 节顺序分期实施。 diff --git a/docs/SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md b/docs/SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md deleted file mode 100644 index 72af9b6..0000000 --- a/docs/SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md +++ /dev/null @@ -1,222 +0,0 @@ -# 子 Agent 编排与信号投递设计评审答复 - -> 状态:设计方答复(2026-08)。 -> -> 本文逐项回应 `docs/SUB_AGENT_ORCHESTRATION_REVIEW.md`。评审原文作为审核记录保留;已接受的结论同时回写到 `docs/SUB_AGENT_ORCHESTRATION_DESIGN.md`,后者仍是后续实现的规范来源。代码级实施方案见 [`SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md`](SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md)。 - -## 1. 总体答复 - -接受评审的总体结论:方案方向成立,但 A1–A5 必须在实现前成为明确契约。全部 A、B、C 项均采纳;其中 A2、B2 和 B4 不只补充文字,还调整了原方案的数据流: - -- durable Agent event 不进入普通 session 消息队列,而由 SQLite inbox 保存 payload、独立的合并式 wake lane 只传递“有待处理事件”的提示。 -- `/stop` 清理瞬时用户工作,但不通过丢弃内存队列来确认 durable event;事件由条件更新立即释放,lease expiry 只作为崩溃兜底。 -- 后台结果调度采用有界公平策略,UI 未读状态降为可观察性能力,不再承担防饥饿正确性。 -- inbox 容量在接纳 background run 时预留终态事件空间;signal 可以因容量不足被拒绝,completion 不能在 run 结束时才发现无处落库。 -- continuation 使用“持久化但对客户端隐藏”的内部触发消息,保证 Provider replay、事务提交和客户端渲染三者一致。 - -| 评审项 | 答复 | 设计处理 | -|--------|------|----------| -| A1 | 接受 | 独立 durable wake lane、claim-on-run、公平调度和有界重试 | -| A2 | 接受 | `/stop` 条件释放、lease guard、取消 completion 的 status-only 语义 | -| A3 | 接受 | run admission 与 step execution permit 分离 | -| A4 | 接受 | CancellationToken 贯穿 AgentLoop;明确 `/stop` 与 `steer` 不同 | -| A5 | 接受 | hidden trigger message、Turn origin、稳定 delivery binding | -| B1 | 接受 | 默认隔离;persistent browser profile 是唯一显式共享路径 | -| B2 | 接受 | 容量预留、dead-letter fallback、重试边界 | -| B3 | 接受 | `all` 与 `each` 的事件生成和 deadline 语义 | -| B4 | 接受 | worker 有界公平;UI 未读只负责呈现 | -| B5 | 接受 | 未配置价格时 `cost=NULL` | -| B6 | 接受 | sub-run sleep 只响应 timer/cancellation | -| C1 | 接受 | 非空 event key、非空 caller scope、partial unique index | -| C2 | 接受 | 深度、task-tree 授权、skills/memory 继承规则 | -| C3 | 接受 | 删除不存在的 `async` 迁移别名 | -| C4 | 接受 | 枚举 WebSocket 请求、投影和 Turn origin 变更 | - -## 2. A 级意见答复 - -### A1 — Session 队列饱和语义 - -**答复:接受。durable event 不与用户 `AgentTask` 共用 payload mpsc。** - -实现采用两条不同语义的 lane: - -```text -user task lane bounded mpsc(32),保存任务;满时明确拒绝新用户输入 -agent inbox wake lane watch revision,合并通知;payload 始终留在 SQLite -``` - -Router 对活动 Turn 的 `steer` 使用 lease → mailbox reservation → durable admitted → activate 的两阶段 admission;reservation 在 durable 更新成功前不可被 AgentLoop 排空,且 SQLite I/O 不跨 Session 锁。失败或 `queue` 事件都恢复/保持 `pending`,只递增 wake revision。worker 在真正准备执行 continuation 时才领取 lease,不先把已 leased 的事件塞进可能被丢弃的 mpsc。 - -`watch` 只负责降低延迟:发送失败、revision 被合并或进程退出都不影响事实状态。Gateway 启动、reload 激活和周期恢复扫描会重新发现 `pending`/expired lease。普通 session 队列满不再构成 durable event 丢失或无限重试问题。 - -worker 在每个 Turn 调度边界执行有界公平:通常先处理用户任务;连续处理 4 个用户 Turn,或最老 pending event 已等待 30 秒后,必须先领取一批 continuation。它仍不能中断当前不可分割的 Turn,因此时限从下一个调度边界计算。 - -### A2 — `/stop`、worker 退出与事件恢复 - -**答复:接受。lease expiry 只能是崩溃兜底,不能是正常 `/stop` 的唯一恢复路径。** - -调整后的链路为: - -1. `/stop` 在关闭 TurnMailbox 时取回尚未提交的 durable event IDs。 -2. 在 session generation 失效后,以 `lease_token`/`admitted_turn_id` 条件更新把这些事件立即恢复为 `pending`。 -3. continuation 执行持有 `InboxLeaseGuard`;正常失败、取消或 stale generation 会显式 release,只有进程崩溃或任务被强制 abort 才等待 `lease_until` 到期。 -4. 普通内部 continuation 不作为 payload 存在 session mpsc 中,因此 `agent_tx.take()` 不会吞掉 leased event;worker 领取后才在本地构造 typed task source。 -5. Coordinator 独立拥有 background run。`/stop` 取消 run token 后,Coordinator 仍负责用条件事务写入 `cancelled` 终态,迟到的 completed 结果不能覆盖它。 - -由本次 `/stop` 自身造成的 cancelled completion 设为 `requires_continuation=false`:事件和 run 状态会持久化并投影到任务树,但事件在同一事务中记为 status-only consumed,不会在 `/stop` 后反向启动一个“任务已取消”的主 Agent Turn。`/stop` 前已经存在、尚未处理的 signal/completion 不被确认或删除,恢复为 pending 后仍可继续投递。 - -### A3 — `waiting_children` permit 归属 - -**答复:接受。permit 不由整个 Agent Run 持有,也不由 `delegate` 等编排工具持有。** - -Coordinator 管理两类限制: - -- **run admission quota**:限制树、session、Agent 的已接纳/未终态 run 数量;可以跨 `waiting_children` 持有。 -- **step execution permit**:限制当前正在占用 Provider 或普通工具执行资源的步骤;只在一个步骤期间持有。 - -AgentLoop 在每次 Provider 请求前按固定顺序获取 global → session → agent provider permits,流结束或取消后立即释放。普通工具调用由 tool executor 获取 tool permit;`delegate`、`agent_task`、`emit_signal` 等 runtime-control 工具不获取这种稀缺执行 permit。 - -foreground `delegate` 在创建 child 前通过状态 guard 把父 run 从 `running` 条件更新为 `waiting_children`,等待期间没有 provider/tool permit。children 终态后 guard 把父状态恢复为 `running`;父 Agent 的下一次模型迭代重新竞争 permit。这样即使 provider 并发上限为 1,父等待 child 也不会死锁。 - -### A4 — AgentLoop 结构化取消 - -**答复:接受,并把它提升为 Phase 2 的独立前置里程碑。** - -`AgentLoop` 的执行入口将显式接收 cancellation context,而不是只依赖父 future 被 drop: - -```text -root Turn token -└── foreground run token - └── descendant foreground run token - -root session token ── independently owns background run tokens -``` - -Provider stream、可取消等待和工具批次外层都观察 token;AgentRunner 的终结路径把取消归一为类型化 `cancelled`,Coordinator 再用 execution ID 条件提交。父取消、run timeout、reload/shutdown 可以组合为任一触发即取消。 - -“不默认硬中断”只约束普通 `steer`:它等待安全边界,不取消 Provider 或副作用工具。`/stop` 保持现有强停止语义,会取消 token 并使 root Turn future 失效;未在宽限期内自行退出的独立 child task 由 Coordinator/Supervisor abort。无论 future 如何结束,terminal condition update 都阻止迟到结果提交。 - -### A5 — continuation Turn 模型 - -**答复:接受。continuation 需要同时满足 durable replay、无伪用户气泡和正常 Turn 投递。** - -每个 continuation 生成一条内部触发消息: - -- 数据库 role 使用 Provider 可兼容的 `user`,source 为 `agent_signal`/`agent_result`。 -- 增加 `client_visibility=hidden` 和 `turn_origin=agent_continuation`;普通历史 API 和 `turn_committed.messages` 不投影这条消息。 -- 内容是有界、带 event/run reference 的 runtime envelope,不伪造外部 sender,也不直接采用子 Agent 输出中的指令优先级。 -- Provider 历史 replay 会包含该隐藏消息,因此后续 assistant 回复不会成为无来源的悬空历史。 -- 隐藏触发消息、assistant/tool 结果、usage 和 inbox `consumed` 在同一事务中提交;失败时全部不确认。 - -客户端继续使用 `turn_updated`/`turn_committed` 展示 assistant Turn,但帧增加 `turn_origin`,从而可以显示“后台结果处理”标记且不创建用户气泡。 - -内部 Turn 的出站目标来自 root session 的 durable delivery binding:`channel`、`chat_id` 以及可复用的 thread/root 上下文。一次性 `reply_to` 不得复用。若没有可用的外部 binding,结果仍持久化并供 WebUI/TUI 历史读取,不猜测其他目标。 - -## 3. B 级意见答复 - -### B1 — browser/resource scope 隔离 - -**答复:接受。** 新具名 Agent 默认使用 `root_session_id + run_id` 的瞬时资源 scope,不继承父会话 browser cookie。需要共享登录态时,官方路径是由 Root 创建/选择经过校验的 `browser_profiles` persistent ID,并把该 ID 作为显式 task/artifact reference 交给获准使用 browser 的子 Agent;子 Agent必须在每次相关调用中显式传入该 ID。 - -为平滑迁移,由无 target 的旧调用映射出的内置 `general` 兼容 Agent 可在弃用期保留父 session transient scope;具名 Agent不继承这个例外。文档和 tool result 会明确提示两种 scope 的差异。 - -### B2 — dead letter、重试和 inbox 上限 - -**答复:接受。** 接纳 background run 时按 completion policy 预留不可抢占的终态 event slot:`each` 每个 run 一个,`all` 每个 group 一个。容量不足时 `delegate` 在创建 run 前拒绝。signal 只使用未预留容量,满时 `emit_signal` 返回 `inbox_full`,但不终止 run。因此已接纳 run 的 completion 永远不会在终结时因 inbox 满而丢失。 - -暂定恢复策略为 8 次可配置尝试,退避 `1s/5s/30s/2m/10m` 后封顶 10 分钟,并同时受 event TTL 限制。瞬时 Storage/worker/Provider continuation 失败可重试;session 已删除、授权事实失效或 payload 永久损坏立即 dead-letter。lease timeout 不单独计作永久错误,但会记录 attempt 和原因。 - -事件进入 dead-letter 后: - -1. 保存最终原因和 `dead_lettered_at`,在任务树/API 中持续可见。 -2. 通过 OutboundDispatcher 最多发送一次有界 system fallback,内容只包含 run ID、终态和查询提示,不复制大结果。 -3. 用 `fallback_notified_at` 保证 fallback 幂等;渠道也失败时仍以 SQLite 记录和管理 UI 为最终可诊断出口。 - -### B3 — `completion_policy=each` - -**答复:接受。** 语义修订为: - -- `each`:每个 run 进入终态即创建独立 completion event;Router 可在 300–500ms debounce 窗口合并一次 continuation,但不能等待其他 sibling。 -- `all`:单 run 终态只更新 group 计数,不创建可投递 completion;全部终态或 group deadline 到达后创建一个 `group_completion` event,包含全部逐项状态和 result references。 -- group deadline 到达时,未终态 children 被取消并条件更新为 `timed_out`,随后生成唯一 group completion。 - -因此 inbox schema 允许 run-scoped 或 group-scoped event 二选一,而不是强制 `run_id NOT NULL`。 - -### B4 — UI 未读状态与防饥饿 - -**答复:接受。** 正确性由 A1 的 worker 有界公平策略保证;UI 未读只呈现尚未汇总/已 dead-letter 的事件数量,不参与调度。Phase 3 明确包含未读计数、event revision 和 reconnect 后全量校准。 - -### B5 — `cost` 与定价配置 - -**答复:接受。** Phase 2 保留 nullable `cost` 字段,但只有 Provider profile 明确提供 input/output/cache 价格时才计算;当前配置没有价格来源,因此写 `NULL`。usage token 仍照常持久化。价格配置和历史重算不属于本次编排功能的前置条件。 - -### B6 — sub-run 内 sleep - -**答复:接受。** `TurnWakeupHandle` 只存在于 root interactive Turn。sub-run 的 `ToolExecutionContext.turn_wakeup=None`,其 `sleep` 只等待 timer、run cancellation、timeout 或 shutdown;不会监听 root session 用户输入或其他 Agent signal。需要被主 Agent立即控制时使用 `agent_task.cancel`,由 cancellation token 唤醒。 - -## 4. C 级意见答复 - -### C1 — SQLite NULL 与事件去重 - -**答复:接受。** 所有参与唯一约束的 scope/key 都规范化为非空值: - -- background idempotency 使用 `caller_scope_id TEXT NOT NULL`;Root 固定为字面量 `ROOT`。 -- `idempotency_key` 仍可为空,但使用 `CREATE UNIQUE INDEX ... WHERE idempotency_key IS NOT NULL` 的 partial unique index。 -- 无 `dedupe_key` 的 signal 使用 `signal:`。 -- 有 `dedupe_key` 的 signal 使用 `signal::`,只在冷却窗口内去重,不会永久压制同类告警。 -- run completion 使用固定 `completion:terminal-v1`;group completion 使用 `group-completion:terminal-v1`。 - -### C2 — 深度、task-tree 授权和上下文继承 - -**答复:接受。** - -- 全局 `max_tree_depth` 是 root-relative 硬上限;Definition `limits.max_depth` 是该 Agent可继续创建的最大相对后代深度。child 的 remaining depth 为 `min(parent_remaining - 1, target_definition.max_depth)`,任何一项为 0 都不能继续委托。 -- `agent_task` 查询必须匹配当前 `root_session_id`。Root 可操作本 session 的整棵树;子 Agent只能读取自身与后代,只能取消其未终态后代,不能通过猜测 run ID 跨 session 或操作祖先/sibling。 -- 子 Agent不继承主会话 history、memory recall 或临时 activated skills。第一版仅在 Definition 工具集中包含 `get_skill` 时注入受信任 Skill catalog;调用方只能通过显式 task/context 传递事实。若未来开放 memory,必须新增管理员配置的只读 scope,不能默认继承。 - -### C3 — `async` 别名 - -**答复:接受。** 删除 `async → background`。迁移只接受代码中确实存在的 `inline`、`parallel`、`background`;新 prompt/schema 只公布 `foreground`、`background`。 - -### C4 — 客户端协议清单 - -**答复:接受。** 协议按通用运行投影设计,不为 Signal 单独复制一套模型: - -- `WsInbound::GetAgentRuns { session_id, cursor, limit }` -- `WsInbound::GetAgentRun { session_id, run_id }` -- `WsOutbound::SessionAgentRuns { session_id, revision, runs, next_cursor }` -- `WsOutbound::AgentRunUpdated { session_id, revision, run }` -- `WsOutbound::AgentEventUpdated { session_id, revision, event }` -- `TurnSnapshot` 与 `WsOutbound::TurnCommitted` 增加 `turn_origin = user | agent_continuation | scheduled` - -`AgentEventUpdated` 同时承载 accepted、admitted、consumed、dead-letter 等状态,按 `(session_id, revision, event_id)` 幂等合并。断线重连后客户端用 `GetAgentRuns` 全量校准,实时帧只是增量。取消操作第一版继续通过 `/stop`、`agent_task.cancel` 或受保护管理 API,不额外开放一个缺少权限上下文的裸 WebSocket cancel 帧。 - -## 5. 对分期的调整 - -| Phase | 调整后的完成条件 | -|-------|------------------| -| 1 | 除原内容外,明确 browser 兼容 scope、skills/memory 规则和 sub-run sleep 行为 | -| 2A | CancellationToken 贯穿 AgentLoop,先以现有 root Turn/sleep/Provider tests 锁定取消语义 | -| 2B | run 持久化、step execution gate、foreground child cancellation 和结果查询 | -| 3 | durable wake lane、capacity reservation、hidden continuation trigger、bounded fairness、dead-letter fallback 与 WebSocket run/event projection | -| 4 | typed TurnMailbox、emit_signal、steer admission,以及同一 `AgentEventUpdated` 的 Signal 卡片呈现 | -| 5 | root Turn wake-aware sleep;sub-run 保持 timer/cancellation-only | - -Phase 3 上线时保留旧 direct notification 的受控兼容开关,但仅作为 rollback 手段,默认路径必须是 inbox/continuation;不能同时投递两条用户通知。开关移除前必须验证 dead-letter fallback、重启恢复和 reconnect 校准。 - -## 6. 最终结论 - -评审结论“方向通过,需修订后实现”成立。修订后的关键边界是: - -```text -SQLite inbox 保存事实 - ├── direct steer admission → 当前 TurnMailbox - └── coalesced wake → worker claim → hidden continuation Turn - -普通 user mpsc 满 ≠ durable event 丢失 -/stop 丢弃瞬时用户工作 ≠ 确认 durable event -run 存活配额 ≠ Provider/tool step permit -UI 未读提示 ≠ 调度正确性 -``` - -在 A1–A5 的专项契约和上述 schema/protocol 调整落地前,不应开始 Phase 3/4 的生产实现。 diff --git a/resources/skills/about-picobot/references/config.md b/resources/skills/about-picobot/references/config.md index cf50681..f7700b0 100644 --- a/resources/skills/about-picobot/references/config.md +++ b/resources/skills/about-picobot/references/config.md @@ -54,13 +54,11 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取 ## agent_orchestration 字段 -默认 `enabled=false`。启用后,`definitions_dir` 相对 `config.json` 所在目录解析,且不得通过绝对路径或 symlink 逃逸该受信任配置目录。Gateway 启动和热重载会严格校验全部 Markdown Definition;任一无效 Provider profile、工具、Skill 或委托目标会拒绝整个候选运行代。 +子 Agent 编排是 PicoBot 的内在机制,始终启用、不可关闭;该配置块只控制定义目录与各类上限。`definitions_dir` 相对 `config.json` 所在目录解析,且不得通过绝对路径或 symlink 逃逸该受信任配置目录。Gateway 启动和热重载会严格校验全部 Markdown Definition;任一无效 Provider profile、工具、Skill 或委托目标会拒绝整个候选运行代。 | 字段 | 默认 | 说明 | |------|------|------| -| `enabled` | false | 是否启用具名 Agent Catalog | | `definitions_dir` | agents | 第一层 `*.md` Definition 目录 | -| `root_delegates` | [] | Root 可委托的具名 Agent ID | | `max_tree_depth` | 4 | Root-relative 委托深度硬上限 | | `max_runs_per_tree` | 16 | 单任务树 run 预算(树级原子计数强制) | | `max_concurrent_runs` / `max_concurrent_runs_per_session` | 6 / 4 | background run 接纳配额(global→session 顺序获取,runner 持有至 terminal commit;foreground 不占) | diff --git a/resources/templates/config.example.json b/resources/templates/config.example.json index 816d9f1..b7ee984 100644 --- a/resources/templates/config.example.json +++ b/resources/templates/config.example.json @@ -47,9 +47,7 @@ } }, "agent_orchestration": { - "enabled": false, "definitions_dir": "agents", - "root_delegates": ["general-purpose"], "max_tree_depth": 4, "max_runs_per_tree": 16, "max_concurrent_runs": 6, diff --git a/src/agent/catalog.rs b/src/agent/catalog.rs index cba7d67..accddc0 100644 --- a/src/agent/catalog.rs +++ b/src/agent/catalog.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -10,6 +10,12 @@ use crate::tools::ToolRegistry; use super::definition::{AgentDefinition, AgentDefinitionError, parse_definition}; +/// Fallback delegation target for Agents that do not declare a `delegates` +/// field. The built-in `general-purpose` definition is released on first +/// run, so the default works out of the box; if it is deleted, an Agent with +/// no explicit `delegates` simply cannot delegate further. +pub const DEFAULT_DELEGATE: &str = "general-purpose"; + #[derive(Debug, thiserror::Error)] pub enum AgentCatalogError { #[error("invalid Agent orchestration config: {0}")] @@ -39,9 +45,7 @@ pub enum AgentCatalogError { #[derive(Debug)] pub struct AgentCatalog { definitions: BTreeMap>, - root_delegates: BTreeSet, runtime_generation: u64, - enabled: bool, max_tree_depth: u16, max_runs_per_tree: usize, } @@ -50,9 +54,7 @@ impl AgentCatalog { pub fn legacy() -> Self { Self { definitions: BTreeMap::new(), - root_delegates: BTreeSet::new(), runtime_generation: 0, - enabled: false, max_tree_depth: 4, max_runs_per_tree: 16, } @@ -71,9 +73,6 @@ impl AgentCatalog { runtime_generation: u64, ) -> Result { config.validate().map_err(AgentCatalogError::Config)?; - if !config.enabled { - return Ok(Self::legacy()); - } let trusted_root = config_dir.canonicalize().map_err(|error| { AgentCatalogError::Directory(format!("{}: {error}", config_dir.display())) @@ -103,14 +102,12 @@ impl AgentCatalog { .map(|(name, _)| name) .collect(); let mut definitions = BTreeMap::new(); - let mut disabled_ids = HashSet::new(); for path in paths { let spec = read_provider_spec(&path)?; // Disabled definitions stay on disk for the management UI but // never enter the active catalog. if !spec.enabled { - disabled_ids.insert(spec.id); continue; } let provider = @@ -144,7 +141,14 @@ impl AgentCatalog { } for definition in definitions.values() { - for target in &definition.delegates { + let Some(delegates) = definition.delegates.as_deref() else { + continue; + }; + // A `*` entry means "any other Agent" and skips target validation. + if delegates.iter().any(|target| target == "*") { + continue; + } + for target in delegates { if !definitions.contains_key(target) { return Err(AgentCatalogError::UnknownDelegate { agent: definition.id.clone(), @@ -154,35 +158,14 @@ impl AgentCatalog { } } - let root_delegates: BTreeSet<_> = config.root_delegates.iter().cloned().collect(); - if root_delegates.len() != config.root_delegates.len() { - return Err(AgentCatalogError::Config( - "root_delegates contains duplicates".to_string(), - )); - } - for target in &root_delegates { - if !definitions.contains_key(target) && !disabled_ids.contains(target) { - return Err(AgentCatalogError::UnknownDelegate { - agent: "ROOT".to_string(), - target: target.clone(), - }); - } - } - Ok(Self { definitions, - root_delegates, runtime_generation, - enabled: true, max_tree_depth: config.max_tree_depth, max_runs_per_tree: config.max_runs_per_tree, }) } - pub fn enabled(&self) -> bool { - self.enabled - } - pub fn runtime_generation(&self) -> u64 { self.runtime_generation } @@ -199,21 +182,61 @@ impl AgentCatalog { self.definitions.get(id).cloned() } + /// ROOT may delegate to any named Agent. ROOT has no self to exclude. pub fn root_can_delegate(&self, target: &str) -> bool { - self.root_delegates.contains(target) && self.definitions.contains_key(target) + self.definitions.contains_key(target) } + /// Whether `caller` may delegate to `target`, honouring the + /// default/`*`/empty/list semantics. Self-delegation is never allowed. pub fn can_delegate(&self, caller: &str, target: &str) -> bool { - self.definitions - .get(caller) - .is_some_and(|definition| definition.delegates.iter().any(|id| id == target)) + if caller == target { + return false; + } + let Some(definition) = self.definitions.get(caller) else { + return false; + }; + match definition.delegates.as_deref() { + None => target == DEFAULT_DELEGATE && self.definitions.contains_key(target), + Some(list) if list.iter().any(|entry| entry == "*") => { + self.definitions.contains_key(target) + } + Some(list) => list.iter().any(|entry| entry == target), + } } + /// Concrete delegation targets for `agent_id` after applying the + /// default/`*`/empty/list semantics. Used to scope the model-visible + /// `delegate` tool schema. Self is never a valid target. + pub fn delegate_targets(&self, agent_id: &str) -> Vec { + let Some(definition) = self.definitions.get(agent_id) else { + return Vec::new(); + }; + match definition.delegates.as_deref() { + None => { + if agent_id != DEFAULT_DELEGATE && self.definitions.contains_key(DEFAULT_DELEGATE) { + vec![DEFAULT_DELEGATE.to_string()] + } else { + Vec::new() + } + } + Some(list) if list.iter().any(|entry| entry == "*") => self + .definitions + .keys() + .filter(|id| id.as_str() != agent_id) + .cloned() + .collect(), + Some(list) => list + .iter() + .filter(|entry| entry.as_str() != agent_id) + .cloned() + .collect(), + } + } + + /// Every named Agent, exposed to the root `delegate` tool schema. pub fn root_targets(&self) -> Vec> { - self.root_delegates - .iter() - .filter_map(|id| self.get(id)) - .collect() + self.definitions.values().cloned().collect() } } @@ -242,9 +265,9 @@ fn definition_paths(directory: &Path) -> Result, AgentCatalogError> Ok(paths) } -/// Provider/model/enabled fields read from a definition's frontmatter before -/// the full definition is parsed, so the catalog can resolve the provider -/// config and skip disabled definitions in one pass. +/// Provider/model fields read from a definition's frontmatter before the +/// full definition is parsed, so the catalog can resolve the provider config +/// and skip disabled definitions in one pass. struct ProviderSpec { id: String, llm_profile: Option, @@ -423,14 +446,14 @@ mod tests { fn config() -> AgentOrchestrationConfig { AgentOrchestrationConfig { - enabled: true, definitions_dir: "agents".to_string(), - root_delegates: vec!["researcher".to_string()], ..Default::default() } } - fn write_agent(root: &Path, id: &str, tools: &[&str], delegates: &[&str]) { + /// `delegates`: `None` omits the field (default semantics), `Some([])` + /// writes an explicit empty list, `Some(list)` writes the entries. + fn write_agent(root: &Path, id: &str, tools: &[&str], delegates: Option<&[&str]>) { let tools = (!tools.is_empty()).then(|| { format!( "tools:\n{}\n", @@ -441,15 +464,25 @@ mod tests { .join("\n") ) }); - let delegates = (!delegates.is_empty()).then(|| { - format!( - "delegates:\n{}\n", - delegates - .iter() - .map(|name| format!(" - {name}")) - .collect::>() - .join("\n") - ) + let delegates = delegates.map(|delegates| { + if delegates.is_empty() { + "delegates: []\n".to_string() + } else { + format!( + "delegates:\n{}\n", + delegates + .iter() + .map(|name| { + if *name == "*" { + " - \"*\"".to_string() + } else { + format!(" - {name}") + } + }) + .collect::>() + .join("\n") + ) + } }); std::fs::write( root.join("agents").join(format!("{id}.md")), @@ -466,8 +499,8 @@ mod tests { fn catalog_loads_provider_tools_and_delegation_graph() { let root = tempfile::tempdir().unwrap(); std::fs::create_dir(root.path().join("agents")).unwrap(); - write_agent(root.path(), "researcher", &["calculator"], &["reviewer"]); - write_agent(root.path(), "reviewer", &["calculator"], &[]); + write_agent(root.path(), "researcher", &["calculator"], Some(&["reviewer"])); + write_agent(root.path(), "reviewer", &["calculator"], None); let tools = ToolRegistry::new(); tools.register(CalculatorTool::new()); let loader = SkillsLoader::new_for_testing( @@ -498,13 +531,72 @@ mod tests { assert_eq!(catalog.runtime_generation(), 7); } + #[test] + fn delegation_semantics_default_empty_any_and_list() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("agents")).unwrap(); + write_agent(root.path(), "general-purpose", &[], None); + write_agent(root.path(), "researcher", &[], None); + write_agent(root.path(), "reviewer", &[], Some(&[])); + write_agent(root.path(), "coder", &[], Some(&["*"])); + write_agent(root.path(), "writer", &[], Some(&["reviewer"])); + let tools = ToolRegistry::new(); + let loader = SkillsLoader::new_for_testing( + root.path().join("skills"), + root.path().join("external-skills"), + ); + let profiles = HashMap::from([("research".to_string(), provider())]); + let catalog = AgentCatalog::load( + &config(), + root.path(), + &profiles, + &HashMap::new(), + &HashMap::new(), + root.path(), + &tools, + &loader, + 1, + ) + .unwrap(); + + // ROOT may delegate to every named Agent. + for id in ["general-purpose", "researcher", "reviewer", "coder", "writer"] { + assert!(catalog.root_can_delegate(id), "ROOT -> {id}"); + } + + // Unset `delegates` defaults to general-purpose only. + assert!(catalog.can_delegate("researcher", "general-purpose")); + assert!(!catalog.can_delegate("researcher", "reviewer")); + assert_eq!(catalog.delegate_targets("researcher"), ["general-purpose"]); + + // general-purpose itself has no further delegate (self excluded). + assert!(!catalog.can_delegate("general-purpose", "researcher")); + assert!(catalog.delegate_targets("general-purpose").is_empty()); + + // Explicit empty list forbids delegation. + assert!(!catalog.can_delegate("reviewer", "researcher")); + assert!(!catalog.can_delegate("reviewer", "general-purpose")); + assert!(catalog.delegate_targets("reviewer").is_empty()); + + // `*` allows any other Agent, never self. + assert!(catalog.can_delegate("coder", "researcher")); + assert!(catalog.can_delegate("coder", "writer")); + assert!(!catalog.can_delegate("coder", "coder")); + assert_eq!(catalog.delegate_targets("coder").len(), 4); + + // Explicit list allows exactly the listed targets. + assert!(catalog.can_delegate("writer", "reviewer")); + assert!(!catalog.can_delegate("writer", "researcher")); + assert_eq!(catalog.delegate_targets("writer"), ["reviewer"]); + } + #[test] fn catalog_accepts_any_ordinary_tool_but_rejects_runtime_injected() { // Ordinary tools (including side-effecting ones like file_write) are // now accepted purely by the definition file. let root = tempfile::tempdir().unwrap(); std::fs::create_dir(root.path().join("agents")).unwrap(); - write_agent(root.path(), "researcher", &["file_write"], &[]); + write_agent(root.path(), "researcher", &["file_write"], None); let tools = ToolRegistry::new(); tools.register(crate::tools::FileWriteTool::new()); let loader = SkillsLoader::new_for_testing( @@ -529,7 +621,7 @@ mod tests { // definition's `tools` list; get_skill remains the one exception. let root2 = tempfile::tempdir().unwrap(); std::fs::create_dir(root2.path().join("agents")).unwrap(); - write_agent(root2.path(), "researcher", &["get_skill"], &[]); + write_agent(root2.path(), "researcher", &["get_skill"], None); let tools2 = ToolRegistry::new(); tools2.register(GetSkillTool::new(Arc::new( crate::skills::SkillsLoader::new_for_testing( diff --git a/src/agent/coordinator.rs b/src/agent/coordinator.rs index 5f77d80..7d3a559 100644 --- a/src/agent/coordinator.rs +++ b/src/agent/coordinator.rs @@ -1151,9 +1151,7 @@ mod tests { ); let profiles = HashMap::from([("research".to_string(), provider_config())]); let config = crate::config::AgentOrchestrationConfig { - enabled: true, definitions_dir: "agents".to_string(), - root_delegates: vec!["researcher".to_string()], ..Default::default() }; AgentCatalog::load( @@ -1200,14 +1198,12 @@ mod tests { let notifier = crate::agent::AgentInboxNotifier::new(); let supervisor = crate::task_supervisor::TaskSupervisor::new(); let orchestration = crate::config::AgentOrchestrationConfig { - enabled: true, max_pending_inbox_events_per_session: max_pending, ..Default::default() }; let gate = match max_concurrent_runs { Some(limit) => { let config = crate::config::AgentOrchestrationConfig { - enabled: true, max_concurrent_runs: limit, ..Default::default() }; @@ -1726,7 +1722,6 @@ mod tests { let notifier = crate::agent::AgentInboxNotifier::new(); let supervisor = crate::task_supervisor::TaskSupervisor::new(); let orchestration = crate::config::AgentOrchestrationConfig { - enabled: true, max_pending_inbox_events_per_session: 1, ..Default::default() }; diff --git a/src/agent/definition.rs b/src/agent/definition.rs index 6214867..1698441 100644 --- a/src/agent/definition.rs +++ b/src/agent/definition.rs @@ -220,13 +220,17 @@ pub struct AgentFrontmatter { pub token_limit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tool_iterations: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, /// Disabled definitions stay on disk but never load into the catalog. #[serde(default = "default_true")] pub enabled: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tools: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub delegates: Vec, + /// Which Agents this one may further delegate to. `None` (field absent) + /// defaults to the built-in `general-purpose`; `Some([])` forbids further + /// delegation; a list containing `*` allows any other Agent; an explicit + /// list allows exactly those targets. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegates: Option>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub skills: Vec, #[serde(default)] @@ -249,7 +253,7 @@ pub struct AgentDefinition { pub provider_config: Arc, pub enabled: bool, pub tools: Vec, - pub delegates: Vec, + pub delegates: Option>, pub skills: Vec, pub limits: AgentLimits, pub signal_contract: Option, @@ -427,7 +431,9 @@ fn read_frontmatter(path: &Path) -> Result<(AgentFrontmatter, String), AgentDefi signal.validate()?; } reject_duplicates("tools", &frontmatter.tools)?; - reject_duplicates("delegates", &frontmatter.delegates)?; + if let Some(delegates) = frontmatter.delegates.as_deref() { + reject_duplicates("delegates", delegates)?; + } reject_duplicates("skills", &frontmatter.skills)?; let file_stem = path.file_stem().and_then(|value| value.to_str()); if file_stem != Some(frontmatter.id.as_str()) { @@ -551,4 +557,41 @@ mod tests { .unwrap(); assert!(parse_definition(&path, provider()).is_err()); } + + #[test] + fn delegates_round_trip_preserves_all_forms() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("researcher.md"); + for (label, delegates) in [ + ("unset", None), + ("empty", Some(Vec::::new())), + ("any", Some(vec!["*".to_string()])), + ( + "list", + Some(vec!["coder".to_string(), "reviewer".to_string()]), + ), + ] { + let info = AgentDefinitionInfo { + frontmatter: AgentFrontmatter { + id: "researcher".to_string(), + description: "test".to_string(), + llm_profile: None, + provider: Some("openai".to_string()), + model: Some("m".to_string()), + token_limit: None, + max_tool_iterations: None, + tools: Vec::new(), + enabled: true, + delegates: delegates.clone(), + skills: Vec::new(), + limits: AgentLimits::default(), + signal: None, + }, + role_prompt: "# Role\n\nwork".to_string(), + }; + std::fs::write(&path, serialize_definition(&info)).unwrap(); + let parsed = parse_definition_info(&path).unwrap(); + assert_eq!(parsed.frontmatter.delegates, delegates, "{label}"); + } + } } diff --git a/src/agent/gate.rs b/src/agent/gate.rs index 611cb22..f12605f 100644 --- a/src/agent/gate.rs +++ b/src/agent/gate.rs @@ -224,7 +224,6 @@ mod tests { /// is global -> session by design). fn gate(provider_session: usize, tool_session: usize) -> Arc { let config = AgentOrchestrationConfig { - enabled: true, max_concurrent_runs: 8, max_concurrent_runs_per_session: 1, max_concurrent_provider_steps: 8, diff --git a/src/agent/sub_agent.rs b/src/agent/sub_agent.rs index cf6fd9f..a271ca7 100644 --- a/src/agent/sub_agent.rs +++ b/src/agent/sub_agent.rs @@ -175,11 +175,6 @@ impl SubAgentManager { )); }; - if !self.catalog.enabled() { - return Err(SubAgentError::Other(format!( - "named Agent '{target}' requested while agent_orchestration is disabled" - ))); - } let definition = self .catalog .get(target) @@ -302,13 +297,14 @@ impl SubAgentManager { } else { None }; - if !definition.delegates.is_empty() { + let delegate_targets = self.catalog.delegate_targets(target); + if !delegate_targets.is_empty() { let delegate = self.full_tools.get("delegate").ok_or_else(|| { SubAgentError::Other("delegate runtime tool is unavailable".to_string()) })?; runtime_tools.push(Arc::new(crate::tools::delegate::ScopedDelegateTool::new( delegate, - definition.delegates.clone(), + delegate_targets, )) as Arc); } // The signal tool is contract-bound: it exists only when the @@ -628,7 +624,7 @@ mod tests { Err(error) => error, }; assert!( - matches!(error, SubAgentError::Other(message) if message.contains("orchestration")) + matches!(error, SubAgentError::Other(message) if message.contains("unknown Agent target")) ); } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 9ce9376..da62ffb 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -181,9 +181,7 @@ fn default_token_limit() -> usize { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct AgentOrchestrationConfig { - pub enabled: bool, pub definitions_dir: String, - pub root_delegates: Vec, pub max_tree_depth: u16, pub max_runs_per_tree: usize, pub max_concurrent_runs: usize, @@ -202,12 +200,7 @@ pub struct AgentOrchestrationConfig { impl Default for AgentOrchestrationConfig { fn default() -> Self { Self { - enabled: false, definitions_dir: "agents".to_string(), - // The built-in general-purpose Agent is released automatically; - // it is delegated by default so orchestration works out of the - // box. Explicit configuration fully overrides this list. - root_delegates: vec!["general-purpose".to_string()], max_tree_depth: 4, max_runs_per_tree: 16, max_concurrent_runs: 6, @@ -227,9 +220,6 @@ impl Default for AgentOrchestrationConfig { impl AgentOrchestrationConfig { pub fn validate(&self) -> Result<(), String> { - if !self.enabled { - return Ok(()); - } let positive = [ ("max_tree_depth", usize::from(self.max_tree_depth)), ("max_runs_per_tree", self.max_runs_per_tree), @@ -1151,7 +1141,6 @@ mod tests { 25 * 1024 * 1024 ); assert!(config.browser.enabled); - assert!(!config.agent_orchestration.enabled); let browser: BrowserConfig = serde_json::from_str("{}").unwrap(); assert!(browser.enabled); assert!( @@ -1165,13 +1154,11 @@ mod tests { #[test] fn orchestration_config_enforces_hierarchical_limits() { let valid = AgentOrchestrationConfig { - enabled: true, ..Default::default() }; assert!(valid.validate().is_ok()); let invalid = AgentOrchestrationConfig { - enabled: true, max_concurrent_runs: 1, max_concurrent_runs_per_session: 2, ..Default::default() diff --git a/src/gateway/http.rs b/src/gateway/http.rs index 5978cc8..bf3a60f 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -1082,7 +1082,16 @@ fn agent_info_from_json( .map(|v| v as usize), enabled: body.get("enabled").and_then(Value::as_bool).unwrap_or(true), tools: string_array(body, "tools"), - delegates: string_array(body, "delegates"), + delegates: body + .get("delegates") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }), skills: string_array(body, "skills"), limits: body .get("limits") diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 970b743..037abf5 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -173,20 +173,16 @@ impl GatewayState { None }; let health = Arc::new(crate::health::HealthService::new(config.clone())); - let provider_profiles = if config.agent_orchestration.enabled { - config - .agents - .keys() - .filter_map(|name| { - config - .get_provider_config(name) - .ok() - .map(|profile| (name.clone(), profile)) - }) - .collect() - } else { - std::collections::HashMap::new() - }; + let provider_profiles: std::collections::HashMap = config + .agents + .keys() + .filter_map(|name| { + config + .get_provider_config(name) + .ok() + .map(|profile| (name.clone(), profile)) + }) + .collect(); let config_dir = config_path .parent() .unwrap_or_else(|| std::path::Path::new(".")) diff --git a/webui/package-lock.json b/webui/package-lock.json index 7e1de8a..58ae6ec 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "picobot-webui", - "version": "1.11.0", + "version": "1.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picobot-webui", - "version": "1.11.0", + "version": "1.13.0", "dependencies": { "bits-ui": "^2.0.0", "dompurify": "^3.4.12", diff --git a/webui/package.json b/webui/package.json index 86df5e3..e9609a4 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "picobot-webui", "private": true, - "version": "1.11.0", + "version": "1.13.0", "type": "module", "engines": { "node": ">=20"