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.
This commit is contained in:
xiaoxixi 2026-08-13 18:07:32 +08:00
parent 0813eb4e6d
commit b558a0a99b
19 changed files with 524 additions and 2859 deletions

View File

@ -1,6 +1,6 @@
[package]
name = "picobot"
version = "1.11.0"
version = "1.13.0"
edition = "2024"
[dependencies]

View File

@ -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,

279
docs/SUB_AGENT_DESIGN.md Normal file
View File

@ -0,0 +1,279 @@
# 子 Agent 设计
本文说明 PicoBot 具名子 Agentnamed 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 必须成对出现。
- 文件必须是非符号链接的普通文件≤256KBrole 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` 列表里显式列出的目标存在(`*` 与缺省不校验)。
- 任一无效 → 整代拒绝启动/热重载。
委托规则:
- **主 AgentROOT**:可委托给任意具名子 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 收件箱 workerqueue lane
│ claim(pending→leased)
continuation Turnhidden 触发 + 只读工具集)
│ 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 置为 supersededcompletion 永不 supersede
- **dead_letter**:会话归档/删除、或投递超过 `max_inbox_delivery_attempts` 时;最多发一次有界 system fallback 提示。
### 4.4 两条投递 lanequeue 与 steer
事件按 `delivery` 分两种语义(`SignalDelivery`,定义在 `signal.delivery`
| lane | 语义 | 到达方式 |
|------|------|----------|
| `queue` | 排队到**下一个** Turn | 收件箱 worker 在调度边界把事件变成续接 Turn |
| `steer` | 注入**当前活动** Turn 的安全边界 | 两阶段准入claim → 预留 mailbox 槽 → DB admit(turn_id) |
**steer 两阶段准入**(任何一步失败都必须无损回退):
1. claimpending→leased拿到 lease token
2. 在 TurnMailbox 的 agent lane 预留一个槽(容量独立于 user lane
3. `admit_inbox_event`leased→admitted绑 turn_id
4. 同一 Turn/代激活。
失败路径claim 失败 → 释放 lease 并 wake queue lanemailbox 满 → 释放 lease 回 pending等 queue lane 以 continuation 送达。**steer 可靠退化为 queue**:当活动 Turn 关闭时,已 admit 的 steer 事件按 lease token 释放回 pending绝不静默丢弃。
### 4.5 续接 Turncontinuation 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 契约快照、statusqueued/running/waiting_children/终态、result/error、usage、`execution_id``completion_slot_reserved`、时间线、revision。`execution_id` 唯一索引。
- **`agent_inbox_events`**:收件箱。`run_id`NOT NULLFK、event_typesignal/completion、event_key去重键、deliveryqueue/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 → interruptedbackground 转换 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 inboxwake 只加速,周期性重新 claim 兜底。
5. **steer 两阶段准入的无损性**。claim → mailbox 预留 → DB admit 任何一步失败都要释放 lease 并 wake queue laneTurn 关闭时已 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 拥有 managermanager 若强引用 coordinator 会成环。
11. **结果两段式:模型看到截断、库存全量**`max_result_chars` 截断返回给模型的内容并提示「用 agent_task get_result 查全量」;`full_content` 原样持久化。截断要按 `floor_char_boundary`,否则 UTF-8 边界 panic。
12. **MIN 聚合无行时返回 NULL 被解成 0**。曾导致 worker 空转;`oldest_pending_due`/`next_pending_due_at``Option<Option<i64>>` 显式区分「无行」与「值为 0」。
13. **claim 的确定性与防双租**。claim 按 `created_at, id` 排序保证确定性lease token 让 release 只作用于本 worker 租下的事件,防止把别人已消费的又放回 pending。
14. **idempotency_key 的部分唯一索引**`UNIQUE(root_session_id, caller_scope_id, idempotency_key) WHERE idempotency_key IS NOT NULL`——`NULL` 不参与去重,否则 SQLite 里所有 NULL 会互相冲突。
15. **signal 冷却窗去重键要含时间窗**`event_key = signal:{key}:{now/cooldown}`)。否则「窗口内去重、窗口外再发」无法表达。
16. **父 run 的 `waiting_children` 状态必须对称恢复**。父等待时释放 step permit子结束后要 `restore_agent_run_running`,否则父 run 卡在 waiting 状态。
## 9. 配置项
`agent_orchestration``src/config/mod.rs`
| 键 | 默认 | 含义 |
|----|------|------|
| `definitions_dir` | `agents` | 定义目录(相对 config.json 所在目录) |
| `max_tree_depth` | `4` | 委托树最大深度 |
| `max_runs_per_tree` | `16` | 一棵树内最大 run 数 |
| `max_concurrent_runs` / `max_concurrent_runs_per_session` | `6` / `4` | 全局/每会话并发 run 上限 |
| `max_pending_inbox_events_per_session` | `128` | 每会话 pending + reserved 上限 |
| `max_inbox_delivery_attempts` | `8` | 投递尝试上限(超过进 dead-letter |
| `max_user_turn_burst_before_inbox` | `4` | 公平调度:连续处理多少个用户 Turn 后必须清 inbox |
| `max_inbox_wait_secs` | `30` | 最老事件等待上限(秒) |

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -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 处与现有代码强耦合的接缝缺口A1A5落地前必须先补齐定义否则 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 的 laneuser 32/64KiB、agent 8/32KiBsession 级队列的 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 由谁持有与获取/释放AgentLoopAgentRunnerCoordinator
- 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` 恰是硬 dropdrop `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 的 Turnsub-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` 为 NULLSQLite 中 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 1B5 在 Phase 2B2/B3/B4 在 Phase 3。
## 7. 分期实施意见
| Phase | 风险 | 意见 |
|-------|------|------|
| 1 具名 Agent 与 Foreground | 低 | 纯增量。`llm_profile` 直接复用 `Config::get_provider_config``config/mod.rs:712-745`),无配置重构。注意 B1browser 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. 结论
设计的现状诊断准确、核心决策与既有架构不变量兼容、分期依赖方向正确,**审核结论为"方向通过,需修订后实现"**。A1A5 五个接缝缺口不是方向错误,而是设计与 `session worker`/`/stop`/`AgentLoop` 取消机制的衔接定义不足;按第 6 节补齐专项定义后,可按第 7 节顺序分期实施。

View File

@ -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. 总体答复
接受评审的总体结论:方案方向成立,但 A1A5 必须在实现前成为明确契约。全部 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 的两阶段 admissionreservation 在 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 eventworker 领取后才在本地构造 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、可取消等待和工具批次外层都观察 tokenAgentRunner 的终结路径把取消归一为类型化 `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 eventRouter 可在 300500ms 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:<event_uuid>`
- 有 `dedupe_key` 的 signal 使用 `signal:<normalized-key>:<cooldown-window-id>`,只在冷却窗口内去重,不会永久压制同类告警。
- 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 sleepsub-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 未读提示 ≠ 调度正确性
```
在 A1A5 的专项契约和上述 schema/protocol 调整落地前,不应开始 Phase 3/4 的生产实现。

View File

@ -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 commitforeground 不占) |

View File

@ -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,

View File

@ -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<String, Arc<AgentDefinition>>,
root_delegates: BTreeSet<String>,
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<Self, AgentCatalogError> {
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),
}
}
pub fn root_targets(&self) -> Vec<Arc<AgentDefinition>> {
self.root_delegates
/// 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<String> {
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_map(|id| self.get(id))
.collect()
.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<Arc<AgentDefinition>> {
self.definitions.values().cloned().collect()
}
}
@ -242,9 +265,9 @@ fn definition_paths(directory: &Path) -> Result<Vec<PathBuf>, 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<String>,
@ -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(|| {
let delegates = delegates.map(|delegates| {
if delegates.is_empty() {
"delegates: []\n".to_string()
} else {
format!(
"delegates:\n{}\n",
delegates
.iter()
.map(|name| format!(" - {name}"))
.map(|name| {
if *name == "*" {
" - \"*\"".to_string()
} else {
format!(" - {name}")
}
})
.collect::<Vec<_>>()
.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(

View File

@ -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()
};

View File

@ -220,13 +220,17 @@ pub struct AgentFrontmatter {
pub token_limit: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tool_iterations: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
/// 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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delegates: Vec<String>,
/// 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<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<String>,
#[serde(default)]
@ -249,7 +253,7 @@ pub struct AgentDefinition {
pub provider_config: Arc<LLMProviderConfig>,
pub enabled: bool,
pub tools: Vec<String>,
pub delegates: Vec<String>,
pub delegates: Option<Vec<String>>,
pub skills: Vec<String>,
pub limits: AgentLimits,
pub signal_contract: Option<SignalContract>,
@ -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::<String>::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}");
}
}
}

View File

@ -224,7 +224,6 @@ mod tests {
/// is global -> session by design).
fn gate(provider_session: usize, tool_session: usize) -> Arc<ExecutionGate> {
let config = AgentOrchestrationConfig {
enabled: true,
max_concurrent_runs: 8,
max_concurrent_runs_per_session: 1,
max_concurrent_provider_steps: 8,

View File

@ -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<dyn crate::tools::Tool>);
}
// 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"))
);
}
}

View File

@ -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<String>,
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()

View File

@ -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")

View File

@ -173,8 +173,7 @@ impl GatewayState {
None
};
let health = Arc::new(crate::health::HealthService::new(config.clone()));
let provider_profiles = if config.agent_orchestration.enabled {
config
let provider_profiles: std::collections::HashMap<String, _> = config
.agents
.keys()
.filter_map(|name| {
@ -183,10 +182,7 @@ impl GatewayState {
.ok()
.map(|profile| (name.clone(), profile))
})
.collect()
} else {
std::collections::HashMap::new()
};
.collect();
let config_dir = config_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))

View File

@ -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",

View File

@ -1,7 +1,7 @@
{
"name": "picobot-webui",
"private": true,
"version": "1.11.0",
"version": "1.13.0",
"type": "module",
"engines": {
"node": ">=20"