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:
parent
0813eb4e6d
commit
b558a0a99b
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "1.11.0"
|
||||
version = "1.13.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
16
config.json
16
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,
|
||||
|
||||
279
docs/SUB_AGENT_DESIGN.md
Normal file
279
docs/SUB_AGENT_DESIGN.md
Normal file
@ -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<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
@ -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 节顺序分期实施。
|
||||
@ -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:<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 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 的生产实现。
|
||||
@ -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 不占) |
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(|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.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<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(|| {
|
||||
format!(
|
||||
"delegates:\n{}\n",
|
||||
delegates
|
||||
.iter()
|
||||
.map(|name| format!(" - {name}"))
|
||||
.collect::<Vec<_>>()
|
||||
.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::<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(
|
||||
|
||||
@ -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()
|
||||
};
|
||||
|
||||
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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<String, _> = 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("."))
|
||||
|
||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"private": true,
|
||||
"version": "1.11.0",
|
||||
"version": "1.13.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user