Compare commits

..

5 Commits

Author SHA1 Message Date
7de7a40de7 feat: add sub-agent hot reload to settings, polish definition editor
- rename settings tab and editor labels (子代理定义 → 子代理, 可继续委托给 → 递归委托)
- show available sub-agents as selectable tags when delegation is a list
- fix edit button text wrapping next to the enable switch
- add hot reload button + phase status polling to the sub-agent pane
2026-08-13 18:26:23 +08:00
b13450498b refactor: remove sleep tool and wake-aware steering machinery
Drop the model-callable sleep tool and its TurnWakeup publisher/handle state. Async background completions and user input already inject through steer-at-safe-boundary or the queued continuation Turn, so the sleep path only misled agents into busy-waiting on non-actionable queue wakes. Cancellation still normalizes running tool blocks to Cancelled.
2026-08-13 18:08:07 +08:00
b2574dc7af feat: add sub-agent activity WebUI with streamed run transcripts
Restructure the WebUI around sub-agent activity: move definition management into Settings, turn the agents page into a live activity monitor with a read-only detail view, and slim the tasks page to scheduled jobs only. Persist incrementally streamed per-run transcripts in a new agent_run_messages table (schema v9, version-gated drops preserve v8 run history) and expose them via GET /api/agent-runs/{id}.
2026-08-13 18:07:56 +08:00
b558a0a99b 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.
2026-08-13 18:07:32 +08:00
0813eb4e6d docs: add sub-agent activity WebUI design spec 2026-08-13 16:29:19 +08:00
41 changed files with 1832 additions and 4141 deletions

View File

@ -93,7 +93,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
- **AgentLoop** is stateless across turns; it receives prepared history, drains same-Turn steering only at safe model boundaries, calls LLM providers, executes tools, and returns one result
- **AgentCatalog** is immutable per runtime generation; when orchestration is enabled, candidate preparation strictly validates trusted Markdown definitions, Provider profiles, tool/Skill allowlists, and delegation edges before activation. Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; a built-in general-purpose definition is released to `~/.picobot/agents/` on first run. Background batches and nested background remain restricted
- **AgentCatalog** is immutable per runtime generation; candidate preparation strictly validates trusted Markdown definitions, Provider profiles, tool/Skill allowlists, and delegation edges before activation. Sub-Agent orchestration is an intrinsic, always-on mechanism (no feature switch). Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; a built-in general-purpose definition is released to `~/.picobot/agents/` on first run. Background batches and nested background remain restricted
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
- **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
@ -107,7 +107,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **Provider reasoning state** is private replay data: persist it, replay it only to the matching provider, and never expose it to clients, channels, or logs
- **Tools** are executed by `AgentLoop`; every invocation is normalized to `ToolOutput` and passes through `ToolOutputProcessor`. Plain `ToolResult` implementations use the default conversion, while artifact-producing tools declare model/user audience explicitly; model capability checks, final-reply attachment, channel delivery, and Provider serialization stay outside tools
- **Delegated tool access**: a named Agent's tool set is decided solely by its definition file (admin-authored). `delegate`, `emit_signal`, `get_skill` and `agent_task` are runtime-injected and must never be declared in `tools` (`get_skill` is the scoped-skill switch); `allowed_tools` can only narrow the definition, never expand it
- **Sleep tool** is a cancellable foreground wait bounded to 24 hours; longer or durable delays belong to Scheduler/background work, and cancelling a Turn must normalize active tool blocks to `Cancelled`
- **No foreground wait tool**: Agents wait for asynchronous work by ending the Turn and letting queued completions/signals open a continuation Turn, or by polling status tools; there is no model-callable `sleep`/wait tool. Cancelling a Turn must still normalize active tool blocks to `Cancelled`
- **Stateful tools** receive `ToolExecutionContext`; browser calls without `persistent_id` map each PicoBot dialog to an opaque transient agent-browser session. For long-running work an Agent may autonomously create a persistent identity and must pass its validated ID on every related action; the same ID shares one agent-browser session and serialization gate across dialogs, while different IDs have independent sessions/gates. `browser_profiles` may create IDs, persist bounded semantic labels, list, or delete only validated IDs beneath the configured profile root. Do not introduce a global persistence mode switch, a default persistent ID, automatic per-dialog persistent Profiles, Fantoccini, ChromeDriver, WebDriver, model-controlled raw agent-browser session IDs, or arbitrary Profile paths
- **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, and `/health`; checks must not install/fix dependencies, call Provider APIs, or expose secrets

View File

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

View File

@ -335,7 +335,6 @@ PicoBot 有两类记忆:
| 工具 | 说明 |
|------|------|
| `calculator` | 数学表达式和统计计算 |
| `sleep` | 暂停当前 Agent 工具调用 086400 秒;可由用户停止,不用于持久调度 |
| `file_read` / `file_write` / `file_edit` | 文件读写和编辑;`file_read` 读取受支持图片时可将图片直接提供给多模态模型 |
| `file_search` / `content_search` | 文件名和内容搜索 |
| `bash` | 在 workspace 中执行 Shell 命令 |
@ -375,7 +374,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
| `providers` | LLM Provider 配置 |
| `models` | 模型参数与输入能力 |
| `agents` | Agent 使用哪个 provider/model |
| `agent_orchestration` | 具名子 Agent 定义目录、Root 委托白名单和编排上限;默认关闭 |
| `agent_orchestration` | 具名子 Agent 定义目录与编排上限 |
| `gateway` | HTTP/WebSocket、数据库、调度器、后台任务限制 |
| `client` | CLI 客户端默认 Gateway URL |
| `channels` | 渠道配置,目前主要是飞书/Lark |
@ -408,7 +407,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
### 具名子 AgentPhase 1
启用 `agent_orchestration.enabled` 后,PicoBot 在 Gateway 候选运行代构造时从配置目录下的 `definitions_dir` 加载 `*.md`。相对路径按 `config.json` 所在目录解析,且 canonical path 不得逃逸该目录角色文件、Provider profile、工具、Skill 和委托边任一无效都会拒绝启动或热重载。支持具名 `foreground`(单/批量)与 Root 发起的具名 `background`(单任务或 `tasks[]` 批量):每个 run 独立落库、预留 completion 槽、完成后由主 Agent 的 continuation Turn 单独汇总(空闲时完成即返回),可配合 `emit_signal`queue/steer推送内部信号。子 Agent 发起的 background 尚未开放;未启用编排时无法委托(旧匿名 general 已移除)。
PicoBot 在 Gateway 候选运行代构造时从配置目录下的 `definitions_dir` 加载 `*.md`(子 Agent 编排是内在机制,始终启用)。相对路径按 `config.json` 所在目录解析,且 canonical path 不得逃逸该目录角色文件、Provider profile、工具、Skill 和委托边任一无效都会拒绝启动或热重载。支持具名 `foreground`(单/批量)与 Root 发起的具名 `background`(单任务或 `tasks[]` 批量):每个 run 独立落库、预留 completion 槽、完成后由主 Agent 的 continuation Turn 单独汇总(空闲时完成即返回),可配合 `emit_signal`queue/steer推送内部信号。子 Agent 发起的 background 尚未开放(旧匿名 general 已移除)。
```md
---
@ -430,7 +429,7 @@ limits:
你是一名严谨的研究 Agent只返回与任务有关的结论和证据。
```
每个具名 Agent 的工具集完全由其 Markdown `tools` 列表决定(管理员显式授权),不再有工具侧的可派发门槛;也可内联 `provider`/`model` 直接指定模型(或沿用 `llm_profile` 引用顶层 `agents` key`delegate`/`emit_signal`/`get_skill`/`agent_task` 为运行时注入工具,不能写进 `tools`(分别由 `delegates`/`signal`/`skills` 字段派生),`get_skill` 例外作为启用 scoped skill 的开关。WebUI「子 Agent」页可直接增删改定义、启停并选择工具/Skill/Provider/Model。
每个具名 Agent 的工具集完全由其 Markdown `tools` 列表决定(管理员显式授权),不再有工具侧的可派发门槛;也可内联 `provider`/`model` 直接指定模型(或沿用 `llm_profile` 引用顶层 `agents` key`delegate`/`emit_signal`/`get_skill`/`agent_task` 为运行时注入工具,不能写进 `tools`(分别由 `delegates`/`signal`/`skills` 字段派生),`get_skill` 例外作为启用 scoped skill 的开关。每个定义可用 `enabled: false` 单独禁用(保留在磁盘但不加载)。主 Agent 可委托给任意具名子 Agent子 Agent 能否继续委托由 `delegates` 决定——不写该字段时默认仅可委托内置 `general-purpose`,写 `[]` 表示不可继续委托,写 `["*"]` 表示可委托任意子代理写列表则按列表指定self 与祖先在运行时始终被拒绝)。WebUI「子 Agent」页可直接增删改定义、启停并选择工具/Skill/Provider/Model 与委托范围
更完整的配置字段说明见 [resources/skills/about-picobot/references/config.md](resources/skills/about-picobot/references/config.md)。

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,

View File

@ -2,7 +2,7 @@
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent是代码架构的主入口行为细节仍以代码和测试为最终依据。
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。配置运行代、重载边界和失败语义见 [CONFIG_HOT_RELOAD_DESIGN.md](CONFIG_HOT_RELOAD_DESIGN.md)。具名子 Agent、委托图、后台收件箱、`queue`/`steer` 信号和可唤醒 `sleep` 的升级提案见 [SUB_AGENT_ORCHESTRATION_DESIGN.md](SUB_AGENT_ORCHESTRATION_DESIGN.md)。
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。配置运行代、重载边界和失败语义见 [CONFIG_HOT_RELOAD_DESIGN.md](CONFIG_HOT_RELOAD_DESIGN.md)。具名子 Agent、委托图、后台收件箱、结果传递机制与 `queue`/`steer` 信号的设计见 [SUB_AGENT_DESIGN.md](SUB_AGENT_DESIGN.md)。
## 1. 设计目标
@ -321,7 +321,7 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输WebSocket 只
5. 长操作应有超时;后台执行应交给 SubAgentManager/TaskSupervisor。
6. 需要跨调用保存外部状态时,实现 `execute_with_context` 并按 session 隔离;不得让模型控制底层全局 session ID。
内置 `sleep` 只暂停当前前台工具 Future允许 086400 秒且不持久化;`/stop`、Scheduler/SubAgent 超时和 Supervisor shutdown 通过丢弃外层 Future 取消计时。Turn 进入 `Cancelled` 时必须把仍为 `Running` 的工具块同步归约为 `Cancelled`,避免终态快照继续显示工具执行中。超过 24 小时或需要跨重启的等待必须使用 Scheduler/后台任务。
没有模型可调用的前台 `sleep`/等待工具Agent 等待异步工作时,应结束当前 Turn 让排队完成/信号开启续接 Turn或轮询状态工具。Turn 进入 `Cancelled` 时仍必须把 `Running` 的工具块同步归约为 `Cancelled`。需要跨重启的可靠延迟必须使用 Scheduler/后台任务。
### 新增 Provider

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

@ -0,0 +1,126 @@
# Sub-Agent Activity WebUI Design
## Goal
Restructure the WebUI around sub-agent activity while moving sub-agent *definition* management into the settings page. Concretely:
1. Move sub-agent definition management (CRUD, enable/disable, role prompt) out of the `agents` navigation item into a new "子代理" tab on the settings page, and enlarge the role-prompt editing area.
2. Turn the `agents` navigation item into an activity monitor that shows running sub-agents and historical run results.
3. Add a read-only, chat-like detail view for a single sub-agent run, including a full, incrementally streamed message transcript.
4. Reduce the `tasks` page to scheduled-task information only; move the background-task listing into the sub-agent activity page.
## Non-goals
- No changes to sub-agent orchestration semantics (delegation, budgets, signals, run admission).
- No WebUI routing framework; the detail view is an in-page sub-view.
- No persistence of the main agent's chat history changes; the transcript work is scoped to sub-agent runs.
## Backend
### Schema (v9)
Add a new `agent_run_messages` table for incrementally streamed sub-agent transcripts:
```sql
CREATE TABLE IF NOT EXISTS agent_run_messages (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
reasoning_content TEXT,
tool_call_id TEXT,
tool_name TEXT,
tool_calls_json TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_agent_run_messages_run_seq ON agent_run_messages(run_id, seq);
```
- Bump `SCHEMA_VERSION` from `8` to `9` in `src/storage/mod.rs`.
- Add the table DDL to `src/storage/agent_run.rs::AGENT_SCHEMA_STATEMENTS`.
- **Version-gate the existing agent-table drops.** `migrate_schema` currently drops `agent_runs`/`agent_inbox_events`/`agent_run_groups` unconditionally whenever `current < SCHEMA_VERSION`; on a v8→v9 upgrade this would destroy run history. Gate those drops (and the `background_tasks` drop) on `current < 8` — the batch-group removal is a pre-v8 concern — so a v8→v9 upgrade only adds the new table and preserves existing runs. When the drops do run (pre-v8), drop `agent_run_messages` before `agent_runs` so foreign-key enforcement never blocks the implicit row delete.
- Update the v8 schema test that hardcodes `assert_eq!(version, 8)` (`fresh_database_creates_schema_v8_agent_tables`) to v9 and rename it accordingly.
We deliberately do **not** add a transcript column to `agent_runs`. Incremental append rows in `agent_run_messages` are the single source of truth for transcripts.
### Incremental capture
- Add an optional sink to `AgentLoop`:
- field `transcript_sink: Option<tokio::sync::mpsc::UnboundedSender<ChatMessage>>`
- builder `with_transcript_sink(sender) -> Self`
- at every site where a message is appended to `emitted_messages` (assistant messages and tool result messages), forward a clone to the sink. Convert `append_steering_messages` from an associated function to a method so it can forward too (sub-agents never use steering, but the sink must be consistent for future use).
- `src/agent/sub_agent.rs::execute_resolved`:
- create an `mpsc::unbounded_channel`
- pass the sender via `build_sub_agent_with_provider``with_transcript_sink`
- spawn a writer task that owns the receiver and a `seq` counter; for each message it strips `provider_state` (set to `None`) and does `INSERT INTO agent_run_messages`
- restructure so the sender is dropped and the writer task is awaited in **every** exit path, including the `tokio::select!` cancellation arm that currently returns early without `process_with_context` returning; only after the writer drains does the terminal commit run
- the writer uses `self.storage` (the manager already holds `Option<Arc<Storage>>`); if storage is absent, the writer becomes a no-op
The task prompt is not duplicated into the table; the detail view renders `run.task` as the leading user bubble.
### Storage API
Add to `src/storage/agent_run.rs`:
- `append_agent_run_message(run_id, seq, &message, now) -> Result<()>` — single-row insert.
- `list_agent_run_messages(run_id, limit) -> Result<Vec<AgentRunMessageRecord>>` — ordered by `seq`. The transcript is naturally bounded: one run emits at most a handful of messages per tool iteration and iterations are capped by the definition's `limits.max_iterations` (default 99); default `limit` of `10_000` is a generous ceiling, not a pagination contract. `get_agent_run` uses this same default limit when loading the transcript.
Two distinct types to avoid a storage/protocol collision:
- `AgentRunMessageRecord` (storage, in `src/storage/agent_run.rs`): carries the raw columns — `id, run_id, seq, role, content, reasoning_content, tool_call_id, tool_name, tool_calls_json, created_at`.
- `AgentTranscriptMessage` (protocol, in `src/protocol.rs`): the serialized shape — same fields except `tool_calls_json` is parsed into `Vec<providers::ToolCall>`; a `From<AgentRunMessageRecord>` impl performs the parse.
### HTTP API
- `GET /api/agent-runs/{id}` currently returns `{ "run": AgentRunView }`. Extend the response to `{ "run": ..., "session_id": ..., "transcript": [ ... ] }` where:
- `session_id` is `run.root_session_id` (the `AgentRunView` deliberately omits it, so it is added at this endpoint's response level)
- `transcript` is the ordered list of `AgentTranscriptMessage` rows (exposing `reasoning_content` but never `provider_state`)
- `GET /api/agent-runs/{id}/events` is unchanged (signals/completions).
- `GET /api/tasks` is unchanged and already lists all runs in any status; the activity page consumes it.
## Frontend
### Settings page (`SettingsPage.svelte`)
- Add a "子代理定义" tab to the existing vertical `Tabs` (named to disambiguate from the "子代理" activity nav item). Move the definition list and the editor modal from `AgentsPage.svelte` here verbatim, then:
- enlarge the role-prompt `textarea` (`min-height` ~360px, full-width, monospace)
- widen the editor modal (`min(920px, 94vw)`) and put the role-prompt field on its own row
- keep the existing API calls (`/api/agents`, `/api/agents/options`, POST/DELETE) unchanged
### Sub-agent activity page (`AgentsPage.svelte`, rewritten)
- List view:
- "活动中" section: runs in `queued`/`running`/`waiting_children` status, with a pulse indicator, `agent_id`, prompt excerpt, `mode`/`depth`, and elapsed time.
- "历史活动" section: terminal runs (`completed`/`failed`/`timed_out`/`cancelled`/`interrupted`), newest first, with `StatusBadge`, `agent_id`, prompt excerpt, `tool_calls_count`/`iterations`, timestamps, and a "详情" action.
- Poll `GET /api/tasks?limit=200` every 5s while mounted.
- Detail view (in-page sub-view, back button, read-only):
- metadata header: `agent_id`, `StatusBadge`, `provider/model`, `mode`, `depth`, `tool_calls_count`, `iterations`, `session_id`, `started_at`/`finished_at`, duration, `parent_run_id` if present
- leading "task" bubble from `run.task`
- transcript messages rendered like the chat page: `Markdown` for content, collapsible reasoning block, `ToolCallCard` for tool calls. Pair each assistant message's `tool_calls` with its `tool`-role result by `tool_call_id` (mirroring `ChatPage`'s `toolResult()`), so calls and results remain independently collapsible.
- signal cards from `GET /api/agent-runs/{id}/events` (same rendering as the chat page's agent-event cards)
- final `error` card on failure
- while `status` is non-terminal, poll `GET /api/agent-runs/{id}` + `/events` every 2s to stream the growing transcript; stop polling on terminal status
- No composer, no input, no interactive actions other than navigation/collapse.
### Tasks page (`TasksPage.svelte`)
- Remove the "后台任务" tab and the `Tabs` wrapper; keep only the scheduled-job list (jobs + runs dots), which already comes from `/api/jobs` and `/api/jobs/{id}/runs`.
### Navigation (`App.svelte`)
- Update the `agents` page description to reflect activity monitoring ("查看活动中的子代理与历史运行"); keep the nav label "子代理".
- Update the `tasks` page description to scheduled tasks only ("管理定时任务").
## Error handling
- Missing/empty transcript: detail view renders the task bubble + metadata + error (or a "无转录" placeholder for failed runs).
- Storage write failures in the transcript writer are logged and the run still commits its terminal status; a broken transcript never fails the run.
- API auth/5xx reuse the existing `api()` error handling and `notify` toast path.
## Testing
- Rust: v9 migration from a v8 database **preserves existing `agent_runs` rows** (version-gated drops), and the pre-v8 legacy rebuild path still works; `append_agent_run_message` + `list_agent_run_messages` round-trip with `seq` ordering; `provider_state` is stripped from persisted transcript rows; sink drains on success, timeout, and cancellation before terminal commit (including the early-return cancellation arm); `get_agent_run` returns the transcript and `session_id`; `AgentRunView` list responses remain transcript-free; the v8 hardcoded schema-version assertion is updated to v9.
- Run `cargo test --lib` and `cargo clippy --all-targets --all-features -- -D warnings`.
- Frontend: `cd webui && npm run check && npm run build`, then `cargo build` to verify the `OUT_DIR` embedding path. No external runtime dependency; the browser stays on `/ws` and the existing HTTP endpoints.

View File

@ -9,7 +9,6 @@ tools:
- content_search
- web_fetch
- calculator
- sleep
---
# General Purpose Agent

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

@ -137,7 +137,7 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
| 参数 | 必填 | 说明 |
|------|------|------|
| `target` | 具名 Agent 必填 | `root_delegates` 或当前 Agent Definition 允许的目标 ID |
| `target` | 具名 Agent 必填 | 目标 Agent ID主 Agent 可委托给任意具名子 Agent子 Agent 按自身 Definition 的 `delegates` 白名单决定 |
| `task` | 单任务必填 | 明确、独立、可验收的子任务 |
| `context` | 否 | 子 Agent 所需的显式事实;不会继承完整主会话历史 |
| `mode` | 否 | `foreground`(默认)或 `background`;批量并发不是第三种 mode |
@ -211,12 +211,6 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
用于交互式程序和需要保持状态的长运行命令。`action` 支持 `spawn``write``read``kill``list``write/read/kill` 需要 `session_id`。Gateway 进程退出时 PTY manager 会清理子进程。
## sleep — 前台等待
参数 `seconds` 接受 086400 的整数。工具只暂停当前 Agent 工具调用,不持久化、不发送消息,也不保证跨进程重启继续;用户 `/stop`、Scheduler/SubAgent 超时和 Gateway shutdown 都会取消等待。超过 24 小时或需要可靠延迟执行时应使用 Scheduler。
主 Agentroot interactive Turn的 sleep 是 wake-aware当前 session 收到任何新输入(用户 steer/queue、后台 Agent 的 steer 信号或排队结果都会提前结束等待。Steer 唤醒会告知来源 run/agent 与安全摘要,并在当前 Turn 的下一个安全边界注入queue 唤醒只说明类型与数量,内容不会进入当前 Turn。子 Agent run 与 continuation Turn 没有 session 输入通道,其 sleep 只响应 timer/cancel。
## http_request / web_fetch — HTTP 和 Web 工具
`http_request` 支持 GET/POST/PUT/DELETE/PATCH、headers 和字符串 body`web_fetch` 提取 HTML/JSON 的可读文本。两者校验 URL 与 DNS 解析结果阻止回环、私网、link-local 和本地域名,并禁用自动重定向,以降低 SSRF 风险。

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

@ -344,6 +344,10 @@ pub struct AgentLoop {
context_window: usize,
input_types: Vec<String>,
media_registry: MediaHandlerRegistry,
/// Optional sink receiving a clone of every message appended to
/// `emitted_messages` during `process_inner`. Sub-agent runs use it to
/// persist an incremental transcript; ordinary Turns leave it `None`.
transcript_sink: Option<tokio::sync::mpsc::UnboundedSender<ChatMessage>>,
}
#[derive(Debug, Clone)]
pub struct AgentProcessResult {
@ -402,6 +406,7 @@ impl AgentLoop {
model_name,
input_types,
media_registry: MediaHandlerRegistry::with_defaults(),
transcript_sink: None,
})
}
@ -427,6 +432,7 @@ impl AgentLoop {
model_name,
input_types,
media_registry: MediaHandlerRegistry::with_defaults(),
transcript_sink: None,
})
}
@ -448,6 +454,7 @@ impl AgentLoop {
model_name,
input_types,
media_registry: MediaHandlerRegistry::with_defaults(),
transcript_sink: None,
}
}
@ -470,6 +477,7 @@ impl AgentLoop {
model_name,
input_types,
media_registry: MediaHandlerRegistry::with_defaults(),
transcript_sink: None,
}
}
@ -491,6 +499,26 @@ impl AgentLoop {
self
}
/// Attach a transcript sink that receives a clone of every message this
/// loop appends to `emitted_messages`. Used by sub-agent runs to persist
/// an incremental transcript; ordinary Turns leave it unset.
pub fn with_transcript_sink(
mut self,
sink: tokio::sync::mpsc::UnboundedSender<ChatMessage>,
) -> Self {
self.transcript_sink = Some(sink);
self
}
/// Forward a message to the transcript sink, if one is installed. The
/// sink is unbounded and the receiver outlives this loop, so send failures
/// are impossible in practice; ignore them defensively.
fn forward_to_transcript_sink(&self, message: &ChatMessage) {
if let Some(sink) = &self.transcript_sink {
let _ = sink.send(message.clone());
}
}
/// Preemptive trim: truncate old tool results in-place when history is
/// approaching the context window limit. Old results (outside of `keep_recent`
/// zone) are replaced with a short placeholder; recent results are truncated
@ -676,6 +704,7 @@ impl AgentLoop {
/// user messages: the client renders the durable Signal projection, not
/// a user bubble, while the model still sees the envelope.
fn append_steering_messages(
&self,
messages: &mut Vec<ChatMessage>,
emitted_messages: &mut Vec<ChatMessage>,
consumed_steering: &mut Vec<TurnInput>,
@ -689,7 +718,8 @@ impl AgentLoop {
.into_chat_message(turn.turn_id.clone(), iteration);
consumed_steering.push(input);
messages.push(message.clone());
emitted_messages.push(message);
emitted_messages.push(message.clone());
self.forward_to_transcript_sink(&message);
}
}
@ -953,8 +983,9 @@ impl AgentLoop {
};
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
messages.push(assistant_message.clone());
emitted_messages.push(assistant_message);
Self::append_steering_messages(
emitted_messages.push(assistant_message.clone());
self.forward_to_transcript_sink(&assistant_message);
self.append_steering_messages(
&mut messages,
&mut emitted_messages,
&mut consumed_steering,
@ -968,6 +999,7 @@ impl AgentLoop {
attach_reply_media(&mut assistant_message, &reply_media_refs);
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true);
emitted_messages.push(assistant_message.clone());
self.forward_to_transcript_sink(&assistant_message);
crate::observability::metrics::global_metrics().record_turn(
Some(&accumulated_usage),
turn_start.elapsed().as_millis() as u64,
@ -1014,7 +1046,8 @@ impl AgentLoop {
assistant_message.provider_state = response.provider_state;
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
messages.push(assistant_message.clone());
emitted_messages.push(assistant_message);
emitted_messages.push(assistant_message.clone());
self.forward_to_transcript_sink(&assistant_message);
// Execute tools and add results to messages
let tool_results = match self
@ -1069,7 +1102,8 @@ impl AgentLoop {
);
Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
messages.push(tool_message.clone());
emitted_messages.push(tool_message);
emitted_messages.push(tool_message.clone());
self.forward_to_transcript_sink(&tool_message);
}
LoopDetectionResult::Ok => {
let mut tool_message = ChatMessage::tool_with_media(
@ -1080,7 +1114,8 @@ impl AgentLoop {
);
Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
messages.push(tool_message.clone());
emitted_messages.push(tool_message);
emitted_messages.push(tool_message.clone());
self.forward_to_transcript_sink(&tool_message);
}
}
}
@ -1098,7 +1133,7 @@ impl AgentLoop {
let Some(turn_context) = turn.as_ref() else {
unreachable!("steering messages require a turn context");
};
Self::append_steering_messages(
self.append_steering_messages(
&mut messages,
&mut emitted_messages,
&mut consumed_steering,
@ -1172,6 +1207,7 @@ impl AgentLoop {
true,
);
emitted_messages.push(assistant_message.clone());
self.forward_to_transcript_sink(&assistant_message);
crate::observability::metrics::global_metrics().record_turn(
Some(&accumulated_usage),
turn_start.elapsed().as_millis() as u64,
@ -1211,6 +1247,7 @@ impl AgentLoop {
attach_reply_media(&mut final_message, &reply_media_refs);
Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
emitted_messages.push(final_message.clone());
self.forward_to_transcript_sink(&final_message);
let turn_usage = (accumulated_usage.total_tokens > 0).then_some(&accumulated_usage);
crate::observability::metrics::global_metrics()
.record_turn(turn_usage, turn_start.elapsed().as_millis() as u64);

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),
}
}
/// 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(

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

@ -46,98 +46,6 @@ impl TurnInputSource {
}
}
impl From<&TurnInputSource> for WakeupSource {
fn from(source: &TurnInputSource) -> Self {
match source {
TurnInputSource::User => WakeupSource::UserSteer,
TurnInputSource::AgentSignal { run_id, agent_id } => WakeupSource::AgentSignal {
run_id: run_id.clone(),
agent_id: agent_id.clone(),
},
TurnInputSource::AgentCompletion { run_id, agent_id } => {
WakeupSource::AgentCompletion {
run_id: run_id.clone(),
agent_id: agent_id.clone(),
}
}
}
}
}
/// What woke a root-interactive sleep. Queue wakes carry no content: the
/// model only learns a type/count, never the payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WakeupSource {
UserSteer,
UserQueue,
AgentSignal { run_id: String, agent_id: String },
AgentCompletion { run_id: String, agent_id: String },
AgentQueue,
}
/// Snapshot published to sleeping root Turns whenever a new input is
/// durably admitted anywhere on the session's receive surface.
#[derive(Debug, Clone, Default)]
pub struct TurnWakeupState {
pub revision: u64,
pub pending_user_steer: usize,
pub pending_user_queue: usize,
pub pending_agent_steer: usize,
pub pending_agent_queue: usize,
pub latest_source: Option<WakeupSource>,
/// Safe, model-visible preview for steer wakes only. Queue wakes never
/// carry content.
pub latest_safe_preview: Option<String>,
}
impl TurnWakeupState {
pub fn pending_total(&self) -> usize {
self.pending_user_steer
.saturating_add(self.pending_user_queue)
.saturating_add(self.pending_agent_steer)
.saturating_add(self.pending_agent_queue)
}
}
/// Root-Turn-side receiver used by wake-aware tools (sleep).
#[derive(Debug, Clone)]
pub struct TurnWakeupHandle {
pub receiver: tokio::sync::watch::Receiver<TurnWakeupState>,
}
/// Session-side publisher for the active Turn. Admission points bump the
/// revision and `send_replace` AFTER the durable fact is visible, so a
/// waking sleep can always observe the input it was told about.
#[derive(Debug, Clone)]
pub struct TurnWakeupPublisher {
sender: tokio::sync::watch::Sender<TurnWakeupState>,
}
impl TurnWakeupPublisher {
pub fn new() -> Self {
let (sender, _) = tokio::sync::watch::channel(TurnWakeupState::default());
Self { sender }
}
pub fn subscribe(&self) -> TurnWakeupHandle {
TurnWakeupHandle {
receiver: self.sender.subscribe(),
}
}
pub fn publish(&self, state: TurnWakeupState) {
let mut state = state;
state.revision = state.revision.saturating_add(1);
let _ = self.sender.send_replace(state);
}
}
impl Default for TurnWakeupPublisher {
fn default() -> Self {
Self::new()
}
}
/// How the input reached the mailbox. Queue inputs belong to the next Turn;
/// only Steer entries are drained by the active Turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

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
@ -435,9 +431,12 @@ impl SubAgentManager {
let mut effective_config = config.clone();
effective_config.max_iterations = Some(resolved.max_iterations);
let max_result_chars = resolved.max_result_chars;
let (transcript_tx, transcript_rx) = tokio::sync::mpsc::unbounded_channel();
let agent = self
.build_sub_agent_with_provider(&effective_config, tools, &resolved.provider_config)
.map_err(|e| SubAgentError::ProviderCreation(e.to_string()))?;
.map_err(|e| SubAgentError::ProviderCreation(e.to_string()))?
.with_transcript_sink(transcript_tx);
let history = vec![
ChatMessage::system(system_prompt),
@ -447,65 +446,69 @@ impl SubAgentManager {
let start = Instant::now();
let tool_context = resolved.tool_context;
let result = tokio::select! {
let writer = self.spawn_transcript_writer(task_id, transcript_rx);
let outcome = tokio::select! {
result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
agent.process_with_context(history, tool_context.clone()),
) => result,
_ = tool_context.cancellation.cancelled() => {
return Ok(SubAgentResult {
task_id: task_id.to_string(),
content: String::new(),
content_truncated: false,
full_content: String::new(),
status: TaskStatus::Cancelled,
tool_calls_count: 0,
iterations: 0,
duration_ms: start.elapsed().as_millis() as u64,
});
}
) => match result {
Ok(inner) => ExecutionOutcome::Finished(Box::new(inner)),
Err(_elapsed) => ExecutionOutcome::TimedOut,
},
_ = tool_context.cancellation.cancelled() => ExecutionOutcome::Cancelled,
};
let duration_ms = start.elapsed().as_millis() as u64;
Ok(match result {
Ok(Ok(agent_result)) => {
let (content, truncated) = truncate_sub_agent_result_at(
&agent_result.final_response.content,
max_result_chars,
);
let tool_calls_count = agent_result
.emitted_messages
.iter()
.filter(|m| m.tool_calls.is_some())
.count();
let iterations = agent_result
.emitted_messages
.iter()
.filter(|m| m.role == "assistant" && m.tool_calls.is_some())
.count();
SubAgentResult {
task_id: task_id.to_string(),
content,
content_truncated: truncated,
full_content: agent_result.final_response.content,
status: TaskStatus::Completed,
tool_calls_count,
iterations,
duration_ms,
// Drop the agent (which owns the transcript sender) so the writer can
// drain, then await the writer before the caller's terminal commit so
// the persisted transcript is complete first.
drop(agent);
if let Err(error) = writer.await {
tracing::warn!(run_id = task_id, error = %error, "transcript writer failed");
}
Ok(match outcome {
ExecutionOutcome::Finished(result) => match *result {
Ok(agent_result) => {
let (content, truncated) = truncate_sub_agent_result_at(
&agent_result.final_response.content,
max_result_chars,
);
let tool_calls_count = agent_result
.emitted_messages
.iter()
.filter(|m| m.tool_calls.is_some())
.count();
let iterations = agent_result
.emitted_messages
.iter()
.filter(|m| m.role == "assistant" && m.tool_calls.is_some())
.count();
SubAgentResult {
task_id: task_id.to_string(),
content,
content_truncated: truncated,
full_content: agent_result.final_response.content,
status: TaskStatus::Completed,
tool_calls_count,
iterations,
duration_ms,
}
}
}
Ok(Err(error)) => SubAgentResult {
task_id: task_id.to_string(),
content: String::new(),
content_truncated: false,
full_content: String::new(),
status: terminal_status_from_error(error),
tool_calls_count: 0,
iterations: 0,
duration_ms,
Err(error) => SubAgentResult {
task_id: task_id.to_string(),
content: String::new(),
content_truncated: false,
full_content: String::new(),
status: terminal_status_from_error(error),
tool_calls_count: 0,
iterations: 0,
duration_ms,
},
},
Err(_elapsed) => SubAgentResult {
ExecutionOutcome::TimedOut => SubAgentResult {
task_id: task_id.to_string(),
content: String::new(),
content_truncated: false,
@ -515,8 +518,64 @@ impl SubAgentManager {
iterations: 0,
duration_ms,
},
ExecutionOutcome::Cancelled => SubAgentResult {
task_id: task_id.to_string(),
content: String::new(),
content_truncated: false,
full_content: String::new(),
status: TaskStatus::Cancelled,
tool_calls_count: 0,
iterations: 0,
duration_ms,
},
})
}
/// Spawn a task that drains the transcript channel into
/// `agent_run_messages`, assigning a monotonically increasing `seq` and
/// stripping `provider_state` (which must never be persisted or exposed).
/// With no storage the writer becomes a drain-and-discard no-op.
fn spawn_transcript_writer(
&self,
run_id: &str,
receiver: tokio::sync::mpsc::UnboundedReceiver<ChatMessage>,
) -> tokio::task::JoinHandle<()> {
let storage = self.storage.clone();
let run_id = run_id.to_string();
tokio::spawn(async move {
let Some(storage) = storage else {
let mut receiver = receiver;
while receiver.recv().await.is_some() {}
return;
};
let mut seq = 0i64;
let mut receiver = receiver;
while let Some(mut message) = receiver.recv().await {
message.provider_state = None;
let now = chrono::Utc::now().timestamp_millis();
if let Err(error) = storage
.append_agent_run_message(&run_id, seq, &message, now)
.await
{
tracing::warn!(
run_id = %run_id,
seq,
error = %error,
"failed to append transcript message"
);
}
seq += 1;
}
})
}
}
/// Intermediate outcome of a resolved run, unified so the transcript writer
/// is awaited on every exit path before `execute_resolved` returns.
enum ExecutionOutcome {
Finished(Box<Result<crate::agent::AgentProcessResult, AgentError>>),
TimedOut,
Cancelled,
}
fn terminal_status_from_error(error: AgentError) -> TaskStatus {
@ -628,7 +687,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")
@ -1206,9 +1215,20 @@ pub async fn get_agent_run(
let Some(run) = run else {
return Err(ApiError::not_found(format!("run {id} not found")));
};
Ok(Json(
json!({ "run": crate::protocol::AgentRunView::from_record(&run, 100_000) }),
))
let session_id = run.root_session_id.clone();
let transcript = state
.storage
.list_agent_run_messages(&id, 10_000)
.await
.map_err(ApiError::internal)?
.into_iter()
.map(crate::protocol::AgentTranscriptMessage::from)
.collect::<Vec<_>>();
Ok(Json(json!({
"run": crate::protocol::AgentRunView::from_record(&run, 100_000),
"session_id": session_id,
"transcript": transcript,
})))
}
pub async fn get_agent_run_events(

View File

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

View File

@ -98,6 +98,48 @@ pub struct AgentEventView {
pub created_at: i64,
}
/// Serialized transcript message for a single Agent run, exposed through the
/// HTTP detail endpoint. `reasoning_content` is client-visible here but
/// `provider_state` is never included.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTranscriptMessage {
pub id: String,
pub run_id: String,
pub seq: i64,
pub role: String,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<crate::providers::ToolCall>>,
pub created_at: i64,
}
impl From<crate::storage::agent_run::AgentRunMessageRecord> for AgentTranscriptMessage {
fn from(record: crate::storage::agent_run::AgentRunMessageRecord) -> Self {
let tool_calls = record
.tool_calls_json
.as_deref()
.and_then(|json| serde_json::from_str(json).ok());
Self {
id: record.id,
run_id: record.run_id,
seq: record.seq,
role: record.role,
content: record.content,
reasoning_content: record.reasoning_content,
tool_call_id: record.tool_call_id,
tool_name: record.tool_name,
tool_calls,
created_at: record.created_at,
}
}
}
impl AgentRunView {
pub fn from_record(
record: &crate::storage::agent_run::AgentRunRecord,

View File

@ -663,8 +663,6 @@ struct ActiveTurnEmitter {
/// `AgentTurnContext`; keeping it on the session handle makes admission
/// atomic with `/stop` and worker cleanup.
steering: Arc<TurnMailbox>,
/// Watch publisher for wake-aware tools (sleep) of this root Turn.
wakeup: crate::agent::steering::TurnWakeupPublisher,
/// Original inbound tasks for accepted steering messages. ChatMessage
/// intentionally carries only durable history fields, so this side map
/// preserves channel context and rich MediaItem metadata if a terminal
@ -692,11 +690,7 @@ struct AgentTask {
fn steer_input_from_event(
event: &crate::storage::agent_inbox::AgentInboxEventRecord,
now: i64,
) -> (
TurnInput,
crate::agent::steering::WakeupSource,
Option<String>,
) {
) -> TurnInput {
use crate::agent::steering::InputDelivery;
use crate::storage::agent_inbox::AgentEventType;
let payload: serde_json::Value =
@ -707,7 +701,7 @@ fn steer_input_from_event(
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string();
let (source, wakeup_preview, content) = match event.event_type {
let (source, content) = match event.event_type {
AgentEventType::Signal => {
let severity = event.severity.clone().unwrap_or_else(|| "info".to_string());
let summary = payload
@ -729,7 +723,6 @@ fn steer_input_from_event(
run_id: run_id.clone(),
agent_id: agent_id.clone(),
},
(!summary.is_empty()).then_some(summary),
content,
)
}
@ -748,12 +741,11 @@ fn steer_input_from_event(
run_id: run_id.clone(),
agent_id: agent_id.clone(),
},
None,
content,
)
}
};
let input = TurnInput {
TurnInput {
id: format!("steer:{}", event.id),
sequence: 0,
source,
@ -764,9 +756,7 @@ fn steer_input_from_event(
received_at: now,
message_source: None,
lease_token: Some(event.lease_token.clone().unwrap_or_default()),
};
let wakeup_source = crate::agent::steering::WakeupSource::from(&input.source);
(input, wakeup_source, wakeup_preview)
}
}
/// Move terminally pending steering into the worker's local FIFO. The
@ -1297,7 +1287,6 @@ impl Session {
turn_id: turn_id.to_string(),
emitter,
steering: TurnMailbox::new_shared(),
wakeup: crate::agent::steering::TurnWakeupPublisher::new(),
recovery: StdArc::new(StdMutex::new(HashMap::new())),
});
}
@ -1968,11 +1957,7 @@ impl SessionManager {
)
.map_err(|error| AgentError::Other(format!("failed to load Agent catalog: {error}")))?,
);
let execution_gate = if catalog_preparation.config.enabled {
crate::agent::gate::ExecutionGate::new(&catalog_preparation.config)
} else {
crate::agent::gate::ExecutionGate::unbounded()
};
let execution_gate = crate::agent::gate::ExecutionGate::new(&catalog_preparation.config);
// Create SubAgentManager and register DelegateTool
let sub_agent_manager = Arc::new(
@ -1989,26 +1974,22 @@ impl SessionManager {
let mut delegate_tool = crate::tools::DelegateTool::new(sub_agent_manager.clone());
let inbox_notifier = crate::agent::AgentInboxNotifier::new();
let agent_projection_hub = Arc::new(crate::agent::AgentProjectionHub::new());
let agent_coordinator = if agent_catalog.enabled() {
let coordinator = crate::agent::AgentCoordinator::new(
storage.clone(),
sub_agent_manager.clone(),
work_manager.clone(),
inbox_notifier.clone(),
agent_projection_hub.clone(),
execution_gate.clone(),
admission.clone(),
task_supervisor.clone(),
catalog_preparation.runtime_generation,
&catalog_preparation.config,
);
tools.register(crate::tools::AgentTaskTool::new(coordinator.clone()));
delegate_tool = delegate_tool.with_coordinator(coordinator.clone());
sub_agent_manager.bind_coordinator(&coordinator);
Some(coordinator)
} else {
None
};
let coordinator = crate::agent::AgentCoordinator::new(
storage.clone(),
sub_agent_manager.clone(),
work_manager.clone(),
inbox_notifier.clone(),
agent_projection_hub.clone(),
execution_gate.clone(),
admission.clone(),
task_supervisor.clone(),
catalog_preparation.runtime_generation,
&catalog_preparation.config,
);
tools.register(crate::tools::AgentTaskTool::new(coordinator.clone()));
delegate_tool = delegate_tool.with_coordinator(coordinator.clone());
sub_agent_manager.bind_coordinator(&coordinator);
let agent_coordinator = Some(coordinator);
tools.register(delegate_tool);
tools.register(crate::tools::ReloadConfigTool::new(reload.clone()));
@ -3296,21 +3277,6 @@ impl SessionManager {
);
match active.steering.try_push_user(input) {
Ok(()) => {
// Wake-aware sleep: the input is durably visible
// in the mailbox before the publish.
if let Some(active) = guard.active_turn_emitter.as_ref() {
active
.wakeup
.publish(crate::agent::steering::TurnWakeupState {
pending_user_steer: active.steering.user_pending_count(),
pending_agent_steer: active.steering.agent_pending_count(),
latest_source: Some(
crate::agent::steering::WakeupSource::UserSteer,
),
latest_safe_preview: None,
..Default::default()
});
}
return Ok(HandleResult::AgentProcessing);
}
Err(_) => {
@ -3411,24 +3377,6 @@ impl SessionManager {
AgentError::Other("agent worker spawn+send failed irrecoverably".to_string())
})?;
}
// Wake-aware sleep: a queued user input must wake a sleeping root
// Turn (the content stays in the queue for the next Turn).
if let Some(active) = guard.active_turn_emitter.as_ref() {
let queued = guard
.agent_tx
.as_ref()
.map(|tx| tx.max_capacity() - tx.capacity())
.unwrap_or(1)
.max(1);
active
.wakeup
.publish(crate::agent::steering::TurnWakeupState {
pending_user_queue: queued,
latest_source: Some(crate::agent::steering::WakeupSource::UserQueue),
latest_safe_preview: None,
..Default::default()
});
}
Ok(HandleResult::AgentProcessing)
}
}
@ -3780,12 +3728,10 @@ fn spawn_agent_worker(
let initial_turn = turn_controller.snapshot();
let steering = TurnMailbox::new_shared();
let recovery = StdArc::new(StdMutex::new(HashMap::new()));
let turn_wakeup = crate::agent::steering::TurnWakeupPublisher::new();
guard.active_turn_emitter = Some(ActiveTurnEmitter {
turn_id: initial_turn.id.0.clone(),
emitter: turn_emitter.clone(),
steering: steering.clone(),
wakeup: turn_wakeup,
recovery: recovery.clone(),
});
@ -3984,24 +3930,14 @@ fn spawn_agent_worker(
let pending_turn_deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
let scoped_turn_deliveries = pending_turn_deliveries.clone();
let steering_for_process = steering.clone();
let wakeup_handle = {
let guard = session.lock().await;
guard
.active_turn_emitter
.as_ref()
.map(|active| active.wakeup.subscribe())
};
let process_gate = execution_gate.clone();
let turn_token_for_process = turn_token.clone();
let process_future = async move {
let response_session_id = unified_str2.clone();
let mut tool_context = ToolExecutionContext::for_session(&response_session_id)
let tool_context = ToolExecutionContext::for_session(&response_session_id)
.with_turn_id(agent_turn.turn_id.clone())
.with_cancellation(turn_token_for_process)
.with_execution_gate(process_gate.clone());
if let Some(handle) = wakeup_handle {
tool_context = tool_context.with_turn_wakeup(handle);
}
let process_result = agent
.process_streaming_with_context(
history_out.clone(),
@ -4725,25 +4661,8 @@ impl crate::agent::AgentInboxWakeTarget for SessionManager {
// admission; everything that cannot be admitted stays pending
// for the queue lane.
if has_active_turn {
let outcome = self
.try_steer_inbox_events(&session, session_id, revision)
self.try_steer_inbox_events(&session, session_id, revision)
.await;
// Events remain pending for the queue lane: wake-aware
// sleep must end even though nothing entered the Turn.
if !matches!(outcome, SteerAdmission::Activated) {
let guard = session.lock().await;
if let Some(active) = guard.active_turn_emitter.as_ref() {
active
.wakeup
.publish(crate::agent::steering::TurnWakeupState {
pending_agent_queue: 1,
latest_source: Some(
crate::agent::steering::WakeupSource::AgentQueue,
),
..Default::default()
});
}
}
}
}
let mut guard = session.lock().await;
@ -4823,13 +4742,11 @@ impl SessionManager {
};
let mut rejected = Vec::new();
let mut reserved = Vec::new();
let mut wakeup_sources = Vec::new();
for event in &lease.events {
let (input, wakeup_source, preview) = steer_input_from_event(event, now);
let input = steer_input_from_event(event, now);
match mailbox.try_reserve_steer(input, token.clone()) {
Ok(()) => {
reserved.push(event.id.clone());
wakeup_sources.push((wakeup_source, preview));
}
Err(_) => rejected.push((event.id.clone(), token.clone())),
}
@ -4862,24 +4779,6 @@ impl SessionManager {
};
if still_active {
mailbox.activate_reserved();
// Wake-aware sleep: publish AFTER the durable admit, so a
// waking tool can observe the input it was told about.
let guard = session.lock().await;
if let Some(active) = guard.active_turn_emitter.as_ref() {
let (latest_source, latest_safe_preview) = wakeup_sources
.into_iter()
.next()
.unwrap_or((crate::agent::steering::WakeupSource::AgentQueue, None));
active
.wakeup
.publish(crate::agent::steering::TurnWakeupState {
pending_user_steer: active.steering.user_pending_count(),
pending_agent_steer: active.steering.agent_pending_count(),
latest_source: Some(latest_source),
latest_safe_preview,
..Default::default()
});
}
return SteerAdmission::Activated;
}
rejected.extend(admitted);
@ -5033,7 +4932,7 @@ impl SessionManager {
#[cfg(test)]
mod slash_command_tests {
use super::{
AgentTask, SLASH_COMMANDS, Session, pop_lowest_sequence, prepend_pending_steering,
AgentTask, SLASH_COMMANDS, pop_lowest_sequence, prepend_pending_steering,
resolve_slash_command,
};
use crate::agent::steering::{SteeringPushError, TurnInput, TurnMailbox};
@ -5219,92 +5118,4 @@ mod slash_command_tests {
assert!(mailbox.is_closed());
assert!(mailbox.take_pending().is_empty());
}
#[tokio::test]
async fn active_turn_wakeup_publisher_reaches_sleep_handles() {
use crate::agent::steering::{TurnWakeupState, WakeupSource};
use crate::config::LLMProviderConfig;
use crate::memory::MemoryManager;
use crate::session::UnifiedSessionId;
use crate::tools::ToolRegistry;
use std::collections::HashMap;
use std::path::PathBuf;
let dir = tempfile::tempdir().unwrap();
let storage = Arc::new(
crate::storage::Storage::new(&dir.path().join("wakeup.db"))
.await
.unwrap(),
);
let memory_manager = Arc::new(MemoryManager::new(
storage,
"test".to_string(),
"test".to_string(),
));
let config = LLMProviderConfig {
provider_type: "openai".to_string(),
name: "test".to_string(),
base_url: "http://127.0.0.1".to_string(),
api_key: "test".to_string(),
extra_headers: HashMap::new(),
model_id: "test".to_string(),
temperature: None,
max_tokens: None,
model_extra: HashMap::new(),
max_tool_iterations: 1,
token_limit: 8_192,
workspace_dir: PathBuf::from("."),
input_types: vec!["text".to_string()],
price_input_per_million: None,
price_output_per_million: None,
};
let session = Arc::new(tokio::sync::Mutex::new(
Session::new(
UnifiedSessionId::new("cli_chat", "chat", "dialog"),
config,
Arc::new(ToolRegistry::new()),
None,
String::new(),
"test".to_string(),
memory_manager,
)
.await
.unwrap(),
));
session.lock().await.set_active_turn_for_test("turn-1");
// The emitter's publisher is the same one a sleep handle subscribes
// to via `with_turn_wakeup`.
let handle = {
let guard = session.lock().await;
guard
.active_turn_emitter
.as_ref()
.expect("active turn installed")
.wakeup
.subscribe()
};
let mut rx = handle.receiver.clone();
assert_eq!(rx.borrow_and_update().pending_total(), 0);
// User steer publish (what handle_message performs).
{
let guard = session.lock().await;
guard
.active_turn_emitter
.as_ref()
.unwrap()
.wakeup
.publish(TurnWakeupState {
pending_user_steer: 1,
latest_source: Some(WakeupSource::UserSteer),
..Default::default()
});
}
assert!(rx.changed().await.is_ok());
let state = rx.borrow_and_update();
assert_eq!(state.pending_total(), 1);
assert_eq!(state.latest_source, Some(WakeupSource::UserSteer));
assert!(state.revision > 0);
}
}

View File

@ -600,8 +600,8 @@ mod tests {
.emit(TurnEvent::ToolStarted {
iteration: 0,
call: ToolCall {
id: "sleep-call".into(),
name: "sleep".into(),
id: "long-call".into(),
name: "long_tool".into(),
arguments: serde_json::json!({"seconds": 60}),
},
})
@ -617,7 +617,7 @@ mod tests {
id,
status: ToolStatus::Cancelled,
..
} if id == "sleep-call"
} if id == "long-call"
));
}

View File

@ -61,6 +61,22 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
"CREATE INDEX IF NOT EXISTS idx_agent_runs_parent ON agent_runs(parent_run_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_agent_runs_recovery ON agent_runs(runtime_generation, status, deadline_at)",
r#"
CREATE TABLE IF NOT EXISTS agent_run_messages (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
reasoning_content TEXT,
tool_call_id TEXT,
tool_name TEXT,
tool_calls_json TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE
)
"#,
"CREATE INDEX IF NOT EXISTS idx_agent_run_messages_run_seq ON agent_run_messages(run_id, seq)",
r#"
CREATE TABLE IF NOT EXISTS agent_session_state (
root_session_id TEXT PRIMARY KEY,
revision INTEGER NOT NULL DEFAULT 0,
@ -226,6 +242,23 @@ pub struct AgentRunRecord {
pub updated_at: i64,
}
/// Raw persisted transcript row for an Agent run. Incrementally appended by
/// the run's transcript writer; `tool_calls_json` is stored verbatim and only
/// parsed into `providers::ToolCall` at the protocol boundary.
#[derive(Debug, Clone)]
pub struct AgentRunMessageRecord {
pub id: String,
pub run_id: String,
pub seq: i64,
pub role: String,
pub content: String,
pub reasoning_content: Option<String>,
pub tool_call_id: Option<String>,
pub tool_name: Option<String>,
pub tool_calls_json: Option<String>,
pub created_at: i64,
}
/// One run to admit inside `accept_agent_runs`.
#[derive(Debug, Clone)]
pub struct NewAgentRun {
@ -377,6 +410,23 @@ fn run_record_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<AgentRunRecord,
})
}
fn agent_run_message_record_from_row(
row: &sqlx::sqlite::SqliteRow,
) -> Result<AgentRunMessageRecord, StorageError> {
Ok(AgentRunMessageRecord {
id: row.get("id"),
run_id: row.get("run_id"),
seq: row.get("seq"),
role: row.get("role"),
content: row.get("content"),
reasoning_content: row.get("reasoning_content"),
tool_call_id: row.get("tool_call_id"),
tool_name: row.get("tool_name"),
tool_calls_json: row.get("tool_calls_json"),
created_at: row.get("created_at"),
})
}
impl super::Storage {
/// Admit a batch of runs in one transaction, claiming any referenced
/// plan items atomically. If any plan item was already taken the whole
@ -497,6 +547,62 @@ impl super::Storage {
}
}
/// Append one transcript message for an Agent run. The writer owns the
/// monotonically increasing `seq`; `provider_state` is expected to have
/// been stripped by the caller before this is called.
pub async fn append_agent_run_message(
&self,
run_id: &str,
seq: i64,
message: &crate::bus::ChatMessage,
now: i64,
) -> Result<(), StorageError> {
let tool_calls_json = message
.tool_calls
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|error| StorageError::Migration(format!("serialize tool_calls: {error}")))?;
sqlx::query(
"INSERT INTO agent_run_messages (id, run_id, seq, role, content, \
reasoning_content, tool_call_id, tool_name, tool_calls_json, created_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(run_id)
.bind(seq)
.bind(&message.role)
.bind(&message.content)
.bind(&message.reasoning_content)
.bind(&message.tool_call_id)
.bind(&message.tool_name)
.bind(tool_calls_json)
.bind(now)
.execute(&self.pool)
.await?;
Ok(())
}
/// List the persisted transcript for a run ordered by `seq`. The
/// transcript is naturally bounded by the run's iteration budget; the
/// default `limit` is a generous ceiling, not a pagination contract.
pub async fn list_agent_run_messages(
&self,
run_id: &str,
limit: i64,
) -> Result<Vec<AgentRunMessageRecord>, StorageError> {
let rows = sqlx::query(
"SELECT id, run_id, seq, role, content, reasoning_content, tool_call_id, \
tool_name, tool_calls_json, created_at \
FROM agent_run_messages WHERE run_id = ? ORDER BY seq ASC LIMIT ?",
)
.bind(run_id)
.bind(limit)
.fetch_all(&self.pool)
.await?;
rows.iter().map(agent_run_message_record_from_row).collect()
}
/// List runs for a session ordered by `(created_at DESC, id DESC)`.
/// The cursor is the pair of the last row the client has seen.
pub async fn list_agent_runs(
@ -1061,17 +1167,18 @@ mod tests {
}
#[tokio::test]
async fn fresh_database_creates_schema_v8_agent_tables() {
async fn fresh_database_creates_schema_v9_agent_tables() {
let (storage, _dir) = create_test_storage().await;
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(version, 8);
assert_eq!(version, 9);
for table in [
"agent_runs",
"agent_session_state",
"agent_inbox_events",
"agent_run_messages",
] {
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
@ -1371,4 +1478,47 @@ mod tests {
.unwrap()
);
}
#[tokio::test]
async fn transcript_messages_round_trip_in_seq_order() {
let (storage, _dir) = create_test_storage().await;
storage
.accept_agent_runs(AcceptAgentRequest {
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
now: 100,
})
.await
.unwrap();
let mut assistant = crate::bus::ChatMessage::assistant_with_tool_calls(
"calling".to_string(),
vec![crate::providers::ToolCall {
id: "call-1".to_string(),
name: "bash".to_string(),
arguments: serde_json::json!({}),
}],
);
assistant.reasoning_content = Some("thinking".to_string());
let tool = crate::bus::ChatMessage::tool("call-1", "bash", "output");
storage
.append_agent_run_message("run-1", 0, &assistant, 200)
.await
.unwrap();
storage
.append_agent_run_message("run-1", 1, &tool, 201)
.await
.unwrap();
let messages = storage.list_agent_run_messages("run-1", 10_000).await.unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].seq, 0);
assert_eq!(messages[0].role, "assistant");
assert_eq!(messages[0].reasoning_content.as_deref(), Some("thinking"));
assert!(messages[0].tool_calls_json.is_some());
assert_eq!(messages[1].seq, 1);
assert_eq!(messages[1].role, "tool");
assert_eq!(messages[1].tool_call_id.as_deref(), Some("call-1"));
assert_eq!(messages[1].tool_name.as_deref(), Some("bash"));
}
}

View File

@ -18,7 +18,7 @@ use sqlx::{Pool, Row, Sqlite};
use std::path::Path;
use tokio::time::{Duration, sleep};
const SCHEMA_VERSION: i64 = 8;
const SCHEMA_VERSION: i64 = 9;
const INSERT_MESSAGE_SQL: &str = r#"
INSERT INTO messages (
id, session_id, seq, role, content, reasoning_content, provider_state,
@ -395,25 +395,35 @@ impl Storage {
}
let mut tx = self.pool.begin().await?;
// Legacy table removed in schema v7; drop it so old databases do not
// keep dead rows around.
sqlx::query("DROP TABLE IF EXISTS background_tasks")
.execute(&mut *tx)
.await?;
// Schema v8 removes the batch "group" concept entirely: the
// `agent_run_groups` table is gone, and the run/inbox tables are
// rebuilt without their `group_id`/`scope_kind`/`scope_id` columns.
// Drop in dependency order (inbox -> runs -> groups) so foreign-key
// enforcement never blocks the implicit row delete.
sqlx::query("DROP TABLE IF EXISTS agent_inbox_events")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_runs")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_run_groups")
.execute(&mut *tx)
.await?;
// The legacy drops below are a pre-v8 rebuild concern: the batch
// "group" concept was removed in v8 and the old `background_tasks`
// table in v7. Gate them on `current < 8` so a v8 -> v9 upgrade only
// adds the new transcript table and preserves existing run history.
if current < 8 {
// Legacy table removed in schema v7; drop it so old databases do
// not keep dead rows around.
sqlx::query("DROP TABLE IF EXISTS background_tasks")
.execute(&mut *tx)
.await?;
// Schema v8 removes the batch "group" concept entirely: the
// `agent_run_groups` table is gone, and the run/inbox tables are
// rebuilt without their `group_id`/`scope_kind`/`scope_id` columns.
// Drop the transcript table before runs and the remaining tables in
// dependency order (messages -> inbox -> runs -> groups) so
// foreign-key enforcement never blocks the implicit row delete.
sqlx::query("DROP TABLE IF EXISTS agent_run_messages")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_inbox_events")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_runs")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_run_groups")
.execute(&mut *tx)
.await?;
}
for (table, column, definition) in [
("messages", "source", "source TEXT"),
("messages", "reasoning_content", "reasoning_content TEXT"),
@ -1885,6 +1895,65 @@ mod tests {
);
}
#[tokio::test]
async fn v8_migration_preserves_agent_runs_and_adds_transcript_table() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("v8.db");
let pool = SqlitePoolOptions::new()
.connect_with(
SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true),
)
.await
.unwrap();
// The v8 `agent_runs` shape is unchanged in v9: v9 only adds the
// transcript table. Build a v8 database holding a durable run so the
// upgrade must preserve it rather than dropping the table.
sqlx::query(agent_run::AGENT_SCHEMA_STATEMENTS[0])
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO agent_runs (id, root_session_id, caller_agent_id, caller_scope_id, \
agent_id, definition_hash, provider_profile, provider_name, model_id, mode, \
depth, execution_id, task, budget_json, status, runtime_generation, attempt, \
completion_slot_reserved, deadline_at, revision, created_at, updated_at) \
VALUES ('run-1', 'cli:c:d', 'ROOT', 'turn-1', 'researcher', 'hash', 'profile', \
'test', 'model', 'foreground', 1, 'exec-1', 'task', '{}', 'completed', 1, 1, \
0, 1000, 0, 1, 1)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("PRAGMA user_version = 8")
.execute(&pool)
.await
.unwrap();
drop(pool);
let storage = Storage::new(&db_path).await.unwrap();
let run = storage.get_agent_run("run-1").await.unwrap();
assert!(
run.is_some(),
"v8 agent run must survive the v9 upgrade without a rebuild"
);
assert_eq!(run.unwrap().status.as_str(), "completed");
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(version, 9);
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'agent_run_messages'",
)
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(exists, 1, "agent_run_messages table must be created");
}
#[tokio::test]
async fn test_upsert_and_get_session() {
let (storage, _dir) = create_test_storage().await;

View File

@ -23,7 +23,6 @@ pub mod registry;
pub mod reload_config;
pub mod schema;
pub mod send_message;
pub mod sleep;
pub mod todo;
pub mod traits;
pub mod web_fetch;
@ -49,12 +48,10 @@ pub use pty::{PtyManager, PtyTool};
pub use registry::ToolRegistry;
pub use reload_config::ReloadConfigTool;
pub use send_message::SendMessageTool;
pub use sleep::SleepTool;
pub use todo::TodoTool;
pub use traits::{
InputInterruptPolicy, OutboundDelivery, OutboundMessenger, ProcessedToolOutput, Tool,
ToolArtifact, ToolArtifactAudience, ToolExecutionContext, ToolOutput, ToolOutputProcessor,
ToolResult,
OutboundDelivery, OutboundMessenger, ProcessedToolOutput, Tool, ToolArtifact,
ToolArtifactAudience, ToolExecutionContext, ToolOutput, ToolOutputProcessor, ToolResult,
};
pub use web_fetch::WebFetchTool;
@ -80,7 +77,6 @@ pub fn create_default_tools(
) -> anyhow::Result<ToolRegistry> {
let registry = ToolRegistry::new();
registry.register(CalculatorTool::new());
registry.register(SleepTool::new());
registry.register(FileReadTool::new());
registry.register(FileWriteTool::new());
registry.register(FileEditTool::new());

View File

@ -1,520 +0,0 @@
use super::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::time::Duration;
use crate::agent::steering::{TurnWakeupState, WakeupSource};
const MAX_SLEEP_SECONDS: u64 = 86_400;
pub struct SleepTool;
impl SleepTool {
pub fn new() -> Self {
Self
}
}
impl Default for SleepTool {
fn default() -> Self {
Self::new()
}
}
fn parse_seconds(args: &serde_json::Value) -> Result<u64, String> {
let seconds = args
.get("seconds")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| "seconds must be a non-negative integer".to_string())?;
if seconds > MAX_SLEEP_SECONDS {
return Err(format!(
"seconds must not exceed {MAX_SLEEP_SECONDS} (24 hours)"
));
}
Ok(seconds)
}
#[async_trait]
impl Tool for SleepTool {
fn input_interrupt_policy(&self) -> crate::tools::InputInterruptPolicy {
crate::tools::InputInterruptPolicy::WakeOnly
}
fn name(&self) -> &str {
"sleep"
}
fn description(&self) -> &str {
"Pause the current agent execution for a specified number of whole seconds."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"seconds": {
"type": "integer",
"minimum": 0,
"maximum": MAX_SLEEP_SECONDS,
"description": "Number of whole seconds to wait, up to 24 hours."
}
},
"required": ["seconds"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.execute_with_context(&crate::tools::ToolExecutionContext::default(), args)
.await
.map(|output| output.result)
}
async fn execute_with_context(
&self,
context: &crate::tools::ToolExecutionContext,
args: serde_json::Value,
) -> anyhow::Result<crate::tools::ToolOutput> {
let seconds = match parse_seconds(&args) {
Ok(seconds) => seconds,
Err(error) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error),
}
.into());
}
};
let started = std::time::Instant::now();
let mut wakeup_rx = context
.turn_wakeup
.as_ref()
.map(|handle| handle.receiver.clone());
// Root interactive Turn: if inputs are already pending, do not wait
// at all. The watch revision is monotonic, so an input arriving
// between this check and the select below still fires `changed()`.
if let Some(rx) = wakeup_rx.as_mut() {
let state = rx.borrow_and_update();
if state.pending_total() > 0 {
return Ok(ToolResult {
success: true,
output: wake_message(&state, started.elapsed(), 0),
error: None,
}
.into());
}
}
let outcome = match wakeup_rx.as_mut() {
Some(rx) => {
tokio::select! {
biased;
_ = context.cancellation.cancelled() => {
anyhow::bail!("sleep cancelled");
}
_ = tokio::time::sleep(Duration::from_secs(seconds)) => {
WakeOutcome::Elapsed
}
changed = rx.changed() => {
let _ = changed;
let state = rx.borrow_and_update();
WakeOutcome::InputArrived(state.clone())
}
}
}
// Child runs and continuation Turns have no session input lane:
// their sleep answers only the timer, run cancellation, timeout
// and shutdown.
None => {
tokio::select! {
biased;
_ = context.cancellation.cancelled() => {
anyhow::bail!("sleep cancelled");
}
_ = tokio::time::sleep(Duration::from_secs(seconds)) => {
WakeOutcome::Elapsed
}
}
}
};
let output = match outcome {
WakeOutcome::Elapsed => format!("Slept for {seconds} second(s)."),
WakeOutcome::InputArrived(state) => wake_message(&state, started.elapsed(), seconds),
};
Ok(ToolResult {
success: true,
output,
error: None,
}
.into())
}
}
enum WakeOutcome {
Elapsed,
InputArrived(TurnWakeupState),
}
/// Build the model-visible wake message. Steer wakes describe the source,
/// run identity and a safe preview; queue wakes only state the type/count and
/// explicitly promise the content stays out of the current Turn.
fn wake_message(state: &TurnWakeupState, waited: std::time::Duration, planned: u64) -> String {
let waited_secs = waited.as_secs();
let mut message = format!("Sleep 提前结束:已等待 {waited_secs}");
if planned > 0 {
message.push_str(&format!("(原计划 {planned} 秒)"));
}
message.push('。');
match &state.latest_source {
Some(WakeupSource::UserSteer) => {
message.push_str(" 收到一条新的用户输入,将在当前 Turn 的下一个安全边界注入。");
}
Some(WakeupSource::UserQueue) => {
message.push_str(&format!(
" 收到 {} 条排队输入。内容不会进入当前 Turn将在当前工作结束后的下一 Turn处理。",
state.pending_user_queue.max(1)
));
}
Some(WakeupSource::AgentSignal { run_id, agent_id }) => {
message.push_str(&format!(
" 收到一条 steer AgentSignalrun_id={run_id}, agent={agent_id}"
));
if let Some(preview) = state.latest_safe_preview.as_deref() {
message.push_str(&format!("{preview}"));
}
message.push_str("。该信号将在当前 Turn 的下一个安全边界注入。");
}
Some(WakeupSource::AgentCompletion { run_id, agent_id }) => {
message.push_str(&format!(
" 收到一条 steer AgentCompletionrun_id={run_id}, agent={agent_id}),将在当前 Turn 的下一个安全边界注入。"
));
}
Some(WakeupSource::AgentQueue) | None => {
message.push_str(&format!(
" 收到 {} 条排队输入。内容不会进入当前 Turn将在当前工作结束后的下一 Turn处理。",
state.pending_agent_queue.max(1)
));
}
}
message
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::TurnEvent;
use crate::agent::steering::TurnWakeupPublisher;
use crate::providers::ToolCall;
use crate::session::{ToolStatus, TurnBlock, TurnController, TurnStatus};
use crate::tools::Tool;
use serde_json::json;
use std::time::Duration;
#[test]
fn exposes_sleep_metadata_and_schema() {
let tool = SleepTool::new();
let schema = tool.parameters_schema();
assert_eq!(tool.name(), "sleep");
assert!(tool.description().contains("current agent execution"));
assert!(tool.description().contains("whole seconds"));
assert_eq!(schema["type"], "object");
assert_eq!(schema["required"], json!(["seconds"]));
assert_eq!(schema["properties"]["seconds"]["type"], "integer");
assert_eq!(schema["properties"]["seconds"]["minimum"], 0);
assert_eq!(
schema["properties"]["seconds"]["maximum"],
MAX_SLEEP_SECONDS
);
assert!(schema.get("additionalProperties").is_none());
assert!(!tool.read_only());
assert!(!tool.concurrency_safe());
assert!(!tool.exclusive());
assert_eq!(
tool.input_interrupt_policy(),
crate::tools::InputInterruptPolicy::WakeOnly
);
}
#[tokio::test]
async fn zero_seconds_returns_exact_success() {
let result = SleepTool::new()
.execute(json!({"seconds": 0}))
.await
.unwrap();
assert!(result.success);
assert_eq!(result.output, "Slept for 0 second(s).");
assert_eq!(result.error, None);
}
#[tokio::test]
async fn rejects_invalid_seconds() {
let invalid_args = [
json!({}),
json!({"seconds": -1}),
json!({"seconds": 0.5}),
json!({"seconds": "1"}),
json!({"seconds": 18_446_744_073_709_552_000.0_f64}),
json!({"seconds": MAX_SLEEP_SECONDS + 1}),
];
for args in invalid_args {
let result = SleepTool::new().execute(args).await.unwrap();
assert!(!result.success);
assert!(result.output.is_empty());
assert!(result.error.is_some());
}
}
#[test]
fn accepts_24_hour_boundary() {
assert_eq!(
parse_seconds(&json!({"seconds": MAX_SLEEP_SECONDS})),
Ok(MAX_SLEEP_SECONDS)
);
}
#[tokio::test(start_paused = true)]
async fn waits_for_requested_seconds() {
let handle = tokio::spawn(async { SleepTool::new().execute(json!({"seconds": 2})).await });
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
assert!(!handle.is_finished());
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
assert!(handle.await.unwrap().unwrap().success);
}
#[tokio::test(start_paused = true)]
async fn waits_up_to_24_hour_boundary() {
let handle = tokio::spawn(async {
SleepTool::new()
.execute(json!({"seconds": MAX_SLEEP_SECONDS}))
.await
});
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(MAX_SLEEP_SECONDS - 1)).await;
tokio::task::yield_now().await;
assert!(!handle.is_finished());
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
assert!(handle.await.unwrap().unwrap().success);
}
#[tokio::test(start_paused = true)]
async fn cancellation_drops_an_active_sleep() {
let handle = tokio::spawn(async {
SleepTool::new()
.execute(json!({"seconds": MAX_SLEEP_SECONDS}))
.await
});
tokio::task::yield_now().await;
assert!(!handle.is_finished());
handle.abort();
assert!(handle.await.unwrap_err().is_cancelled());
}
#[tokio::test(start_paused = true)]
async fn user_cancellation_stops_sleep_and_terminalizes_its_tool_block() {
let (controller, emitter, receiver) =
TurnController::start("cli:test:sleep", "assistant-message");
emitter
.emit(TurnEvent::ToolStarted {
iteration: 0,
call: ToolCall {
id: "sleep-call".into(),
name: "sleep".into(),
arguments: json!({"seconds": MAX_SLEEP_SECONDS}),
},
})
.unwrap();
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let tool = SleepTool::new();
tokio::select! {
result = tool.execute(json!({"seconds": MAX_SLEEP_SECONDS})) => {
result.unwrap();
false
}
_ = cancel_rx => {
controller.cancel(Some("stopped by user".into()));
true
}
}
});
tokio::task::yield_now().await;
drop(cancel_tx);
assert!(handle.await.unwrap());
let snapshot = receiver.borrow().clone();
assert_eq!(snapshot.status, TurnStatus::Cancelled);
assert!(matches!(
&snapshot.blocks[0],
TurnBlock::Tool {
id,
status: ToolStatus::Cancelled,
..
} if id == "sleep-call"
));
}
#[tokio::test(start_paused = true)]
async fn cancellation_token_ends_sleep_before_timer() {
let context = crate::tools::ToolExecutionContext::default();
let token = context.cancellation.clone();
let handle = tokio::spawn(async move {
SleepTool::new()
.execute_with_context(&context, json!({"seconds": MAX_SLEEP_SECONDS}))
.await
});
tokio::task::yield_now().await;
assert!(!handle.is_finished());
token.cancel();
tokio::task::yield_now().await;
let error = handle.await.unwrap().unwrap_err();
assert!(error.to_string().contains("cancelled"));
}
#[tokio::test(start_paused = true)]
async fn pre_cancelled_context_never_enters_sleep() {
let context = crate::tools::ToolExecutionContext::default();
context.cancellation.cancel();
let error = SleepTool::new()
.execute_with_context(&context, json!({"seconds": 60}))
.await
.unwrap_err();
assert!(error.to_string().contains("cancelled"));
}
#[tokio::test(start_paused = true)]
async fn pending_input_before_listen_returns_immediately() {
let publisher = TurnWakeupPublisher::new();
let handle = publisher.subscribe();
publisher.publish(TurnWakeupState {
pending_user_steer: 1,
latest_source: Some(WakeupSource::UserSteer),
..Default::default()
});
let context = crate::tools::ToolExecutionContext::default().with_turn_wakeup(handle);
let result = SleepTool::new()
.execute_with_context(&context, json!({"seconds": 3600}))
.await
.unwrap();
assert!(result.result.success);
assert!(result.result.output.contains("提前结束"));
assert!(result.result.output.contains("用户输入"));
}
#[tokio::test(start_paused = true)]
async fn steer_publish_wakes_sleep_with_source_and_preview() {
let publisher = TurnWakeupPublisher::new();
let handle = publisher.subscribe();
let context = crate::tools::ToolExecutionContext::default().with_turn_wakeup(handle);
let tool = SleepTool::new();
let wait = tokio::spawn(async move {
tool.execute_with_context(&context, json!({"seconds": 3600}))
.await
.unwrap()
.result
.output
});
tokio::task::yield_now().await;
assert!(!wait.is_finished());
publisher.publish(TurnWakeupState {
pending_agent_steer: 1,
latest_source: Some(WakeupSource::AgentSignal {
run_id: "run-123".to_string(),
agent_id: "monitor".to_string(),
}),
latest_safe_preview: Some("服务错误率超过 5%".to_string()),
..Default::default()
});
tokio::task::yield_now().await;
let output = wait.await.unwrap();
assert!(output.contains("提前结束"));
assert!(output.contains("run-123"));
assert!(output.contains("服务错误率超过 5%"));
assert!(output.contains("安全边界注入"));
}
#[tokio::test(start_paused = true)]
async fn queue_publish_wakes_sleep_without_content() {
let publisher = TurnWakeupPublisher::new();
let handle = publisher.subscribe();
let context = crate::tools::ToolExecutionContext::default().with_turn_wakeup(handle);
let tool = SleepTool::new();
let wait = tokio::spawn(async move {
tool.execute_with_context(&context, json!({"seconds": 3600}))
.await
.unwrap()
.result
.output
});
tokio::task::yield_now().await;
publisher.publish(TurnWakeupState {
pending_agent_queue: 1,
latest_source: Some(WakeupSource::AgentQueue),
..Default::default()
});
tokio::task::yield_now().await;
let output = wait.await.unwrap();
assert!(output.contains("排队输入"));
assert!(output.contains("不会进入当前 Turn"));
assert!(!output.contains("run-"));
}
#[tokio::test(start_paused = true)]
async fn child_sleep_without_handle_is_not_woken_by_publishes() {
let publisher = TurnWakeupPublisher::new();
let _handle = publisher.subscribe();
let context = crate::tools::ToolExecutionContext::default();
let tool = SleepTool::new();
let wait = tokio::spawn(async move {
tool.execute_with_context(&context, json!({"seconds": 30}))
.await
.unwrap()
.result
.output
});
tokio::task::yield_now().await;
publisher.publish(TurnWakeupState {
pending_agent_steer: 1,
latest_source: Some(WakeupSource::AgentSignal {
run_id: "run-9".to_string(),
agent_id: "a".to_string(),
}),
..Default::default()
});
tokio::task::yield_now().await;
assert!(!wait.is_finished());
tokio::time::advance(Duration::from_secs(30)).await;
tokio::task::yield_now().await;
assert!(wait.await.unwrap().contains("Slept for 30"));
}
#[tokio::test(start_paused = true)]
async fn pre_listen_publish_does_not_lose_the_wake() {
// Publish BEFORE the sleep subscribes its own receiver: watch keeps
// the latest value, so the borrow_and_update pre-check sees it.
let publisher = TurnWakeupPublisher::new();
let handle = publisher.subscribe();
publisher.publish(TurnWakeupState {
pending_agent_queue: 2,
latest_source: Some(WakeupSource::AgentQueue),
..Default::default()
});
let context = crate::tools::ToolExecutionContext::default().with_turn_wakeup(handle);
let result = SleepTool::new()
.execute_with_context(&context, json!({"seconds": 3600}))
.await
.unwrap();
assert!(result.result.success);
assert!(result.result.output.contains("排队输入"));
}
}

View File

@ -10,10 +10,6 @@ pub struct ToolExecutionContext {
pub agent: Option<std::sync::Arc<crate::agent::AgentExecutionContext>>,
pub cancellation: tokio_util::sync::CancellationToken,
pub execution_gate: Option<std::sync::Arc<crate::agent::gate::ExecutionGate>>,
/// Root interactive Turn only. Wake-aware tools (sleep) select on this
/// receiver so a user or Agent input ends the wait early; sub-runs and
/// continuations never receive it.
pub turn_wakeup: Option<crate::agent::steering::TurnWakeupHandle>,
}
impl Default for ToolExecutionContext {
@ -24,7 +20,6 @@ impl Default for ToolExecutionContext {
agent: None,
cancellation: tokio_util::sync::CancellationToken::new(),
execution_gate: None,
turn_wakeup: None,
}
}
}
@ -37,7 +32,6 @@ impl ToolExecutionContext {
agent: None,
cancellation: tokio_util::sync::CancellationToken::new(),
execution_gate: None,
turn_wakeup: None,
}
}
@ -66,18 +60,6 @@ impl ToolExecutionContext {
self.execution_gate = Some(gate);
self
}
pub fn with_turn_wakeup(mut self, handle: crate::agent::steering::TurnWakeupHandle) -> Self {
self.turn_wakeup = Some(handle);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputInterruptPolicy {
Never,
WakeOnly,
CancelSafe,
}
#[derive(Debug, Clone)]
@ -221,11 +203,6 @@ pub trait Tool: Send + Sync + 'static {
false
}
/// Whether new Turn input may interrupt an in-flight invocation.
fn input_interrupt_policy(&self) -> InputInterruptPolicy {
InputInterruptPolicy::Never
}
/// Execute the tool through the unified output envelope. Most tools return
/// only text and use this default conversion.
async fn execute_output(&self, args: serde_json::Value) -> anyhow::Result<ToolOutput> {

View File

@ -1,12 +1,12 @@
{
"name": "picobot-webui",
"version": "1.11.0",
"version": "1.13.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picobot-webui",
"version": "1.11.0",
"version": "1.13.1",
"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.1",
"type": "module",
"engines": {
"node": ">=20"

View File

@ -21,10 +21,10 @@
{ name: "chat", label: "聊天", description: "与 PicoBot 协作并管理会话" },
{ name: "overview", label: "概览", description: "查看运行状态与系统容量" },
{ name: "tools", label: "工具", description: "浏览工具、Skills 与 MCP 连接" },
{ name: "agents", label: "子代理", description: "管理具名子代理定义" },
{ name: "agents", label: "子代理", description: "查看活动中的子代理与历史运行" },
{ name: "logs", label: "日志", description: "检查实时事件与运行记录" },
{ name: "memory", label: "记忆", description: "查找和维护长期记忆" },
{ name: "tasks", label: "任务", description: "跟踪定时任务与后台工作" },
{ name: "tasks", label: "任务", description: "管理定时任务" },
{ name: "settings", label: "配置", description: "管理 Gateway 与 Agent 配置" }
];
const icons = {
@ -153,7 +153,7 @@
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
{:else if current === "overview"}<OverviewPage />
{:else if current === "tools"}<ToolsPage />
{:else if current === "agents"}<AgentsPage notify={(text, error) => toast.show(text, error)} />
{:else if current === "agents"}<AgentsPage />
{:else}<div class="empty-card">即将上线</div>{/if}
</main>
</div>

View File

@ -43,6 +43,8 @@
<path d="m5.25 7.5 4.75 4.75 4.75-4.75" />
{:else if name === "panel"}
<rect x="2.75" y="3.25" width="14.5" height="13.5" rx="2" /><path d="M12.25 3.25v13.5" />
{:else if name === "back"}
<path d="M12.5 4.5 6.25 10l6.25 5.5M7 10h6.5" />
{/if}
</svg>

View File

@ -0,0 +1,393 @@
<script>
import { onMount } from "svelte";
import { api } from "../api.js";
import Icon from "../Icon.svelte";
import StatusBadge from "../StatusBadge.svelte";
let agents = $state([]);
let options = $state({ providers: [], models: [], tools: [], skills: [] });
let loading = $state(true);
let error = $state("");
let editing = $state(null);
let saving = $state(false);
let reloading = $state(false);
let reloadStatus = $state(null);
let pollTimer = null;
let { notify } = $props();
const phaseBadge = $derived.by(() => {
if (!reloadStatus) return "ok";
const map = { active: "ok", preparing: "run", draining: "run", activating: "run", failed: "fail" };
return map[reloadStatus.phase] || "ok";
});
const phaseLabel = $derived.by(() => {
if (!reloadStatus) return "";
const map = { active: "运行中", preparing: "准备中", draining: "等待任务完成", activating: "激活中", failed: "失败" };
return map[reloadStatus.phase] || reloadStatus.phase;
});
const blank = () => ({
id: "",
description: "",
provider: "",
model: "",
token_limit: null,
max_tool_iterations: null,
tools: [],
skills: [],
delegateMode: "default",
delegates: [],
role_prompt: "",
enabled: true,
});
async function load() {
loading = true;
error = "";
try {
const [a, o] = await Promise.all([
api("/api/agents"),
api("/api/agents/options"),
]);
agents = a.agents || [];
options = o;
} catch (caught) {
error = caught.message;
} finally {
loading = false;
}
}
async function reload() {
reloading = true;
try {
const result = await api("/api/config/reload", { method: "POST" });
notify(result.message || "已触发热重载");
await pollReloadStatus();
} catch (caught) {
notify(caught.message, true);
} finally {
reloading = false;
}
}
async function pollReloadStatus() {
try { reloadStatus = await api("/api/config/reload/status"); } catch {}
}
function startPolling() {
stopPolling();
pollReloadStatus();
pollTimer = setInterval(pollReloadStatus, 3000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
function toggleTool(list, name) {
const i = list.indexOf(name);
if (i >= 0) list.splice(i, 1);
else list.push(name);
}
function startNew() {
editing = blank();
}
function editAgent(agent) {
const delegates = agent.delegates;
let delegateMode = "default";
let list = [];
if (delegates == null) {
delegateMode = "default";
} else if (delegates.includes("*")) {
delegateMode = "any";
} else if (delegates.length === 0) {
delegateMode = "none";
} else {
delegateMode = "list";
list = [...delegates];
}
editing = {
id: agent.id,
description: agent.description || "",
provider: agent.provider || "",
model: agent.model || "",
token_limit: agent.token_limit ?? null,
max_tool_iterations: agent.max_tool_iterations ?? null,
tools: [...(agent.tools || [])],
skills: [...(agent.skills || [])],
delegateMode,
delegates: list,
role_prompt: agent.role_prompt || "",
enabled: agent.enabled !== false,
};
}
function cancelEdit() {
editing = null;
}
function delegateLabel(agent) {
const d = agent.delegates;
if (d == null) return "委托: general-purpose默认";
if (d.includes("*")) return "委托: 任意子代理";
if (d.length === 0) return "不可继续委托";
return `委托: ${d.join(", ")}`;
}
async function save() {
if (!editing.id.trim()) {
notify("请填写 Agent ID", true);
return;
}
if (!editing.description.trim()) {
notify("请填写描述", true);
return;
}
if (!editing.role_prompt.trim()) {
notify("请填写角色正文role", true);
return;
}
if (!editing.provider || !editing.model) {
notify("请选择 provider 和 model", true);
return;
}
saving = true;
try {
const payload = {
id: editing.id,
description: editing.description,
provider: editing.provider || null,
model: editing.model || null,
token_limit: editing.token_limit,
max_tool_iterations: editing.max_tool_iterations,
tools: editing.tools,
skills: editing.skills,
role_prompt: editing.role_prompt,
enabled: editing.enabled,
};
if (editing.delegateMode === "none") payload.delegates = [];
else if (editing.delegateMode === "any") payload.delegates = ["*"];
else if (editing.delegateMode === "list") payload.delegates = editing.delegates;
await api("/api/agents", { method: "POST", body: JSON.stringify(payload) });
editing = null;
notify("子代理已保存,点击「热重载」使其生效");
await load();
} catch (caught) {
notify(caught.message, true);
} finally {
saving = false;
}
}
async function toggleEnabled(agent) {
try {
await api("/api/agents", {
method: "POST",
body: JSON.stringify({
id: agent.id,
description: agent.description || "",
provider: agent.provider || null,
model: agent.model || null,
token_limit: agent.token_limit ?? null,
max_tool_iterations: agent.max_tool_iterations ?? null,
tools: agent.tools || [],
skills: agent.skills || [],
role_prompt: agent.role_prompt || "",
enabled: !agent.enabled,
}),
});
agent.enabled = !agent.enabled;
notify(agent.enabled ? "已启用" : "已禁用");
} catch (caught) {
notify(caught.message, true);
}
}
async function remove(agent) {
if (!confirm(`确定删除子代理「${agent.id}」吗?`)) return;
try {
await api(`/api/agents/${encodeURIComponent(agent.id)}`, { method: "DELETE" });
notify("已删除");
await load();
} catch (caught) {
notify(caught.message, true);
}
}
function toolDesc(name) {
const tool = options.tools.find((t) => t.name === name);
return tool?.description || "";
}
onMount(() => {
load();
startPolling();
return stopPolling;
});
</script>
<div class="definitions">
<div class="toolbar">
<div>
<h2 style="margin:0">具名子代理</h2>
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">
子代理由 <code>~/.picobot/agents/*.md</code> 定义工具、Skill、Provider 与模型在此直接指定。保存后点击「热重载」使改动生效。
</p>
</div>
<div class="toolbar-actions">
{#if reloadStatus}
<span class="badge {phaseBadge}" title={reloadStatus.last_error || ""}>{phaseLabel}</span>
{/if}
<button class="secondary" onclick={reload} disabled={reloading}><Icon name="refresh" size={15} />{reloading ? "重载中…" : "热重载"}</button>
<button class="primary" onclick={startNew}><Icon name="add" size={16} />新增子代理</button>
</div>
</div>
{#if loading}
<div class="loading">加载中…</div>
{:else if error}
<div class="empty-card error-text">{error}</div>
{:else if agents.length === 0}
<div class="empty-card">暂无子代理定义</div>
{:else}
<div class="cards">
{#each agents as agent (agent.id)}
<article class="card">
<div class="card-row">
<div>
<h3>{agent.id} <StatusBadge status={agent.enabled ? "enabled" : "disabled"} /></h3>
<p>{agent.description}</p>
<div class="meta">
<span>provider: {agent.provider || agent.llm_profile || "—"}</span>
<span>model: {agent.model || "—"}</span>
{#if agent.tools?.length}<span>{agent.tools.length} 个工具</span>{/if}
{#if agent.skills?.length}<span>{agent.skills.length} 个 Skill</span>{/if}
<span>{delegateLabel(agent)}</span>
</div>
{#if agent.tools?.length}
<div class="tag-row">
{#each agent.tools as tool (tool)}<span class="tag" title={toolDesc(tool)}>{tool}</span>{/each}
</div>
{/if}
</div>
<div class="card-actions">
<button class="secondary" onclick={() => editAgent(agent)}><Icon name="more" size={15} />编辑</button>
<button class="switch" data-state={agent.enabled ? "checked" : "unchecked"} aria-label="启用/禁用" onclick={() => toggleEnabled(agent)}>
<span class="switch-thumb" data-state={agent.enabled ? "checked" : "unchecked"}></span>
</button>
<button class="icon-button danger" aria-label="删除" onclick={() => remove(agent)}><Icon name="dismiss" size={16} /></button>
</div>
</div>
</article>
{/each}
</div>
{/if}
{#if editing}
<button type="button" class="modal-scrim" onclick={cancelEdit} aria-label="关闭" tabindex="-1"></button>
<div class="modal" role="dialog" aria-label="编辑子代理">
<div class="editor-head">
<div><strong>{editing.id ? `编辑 ${editing.id}` : "新增子代理"}</strong><small>保存后点击「热重载」使改动生效</small></div>
<button class="icon-button" aria-label="关闭" onclick={cancelEdit}><Icon name="dismiss" size={18} /></button>
</div>
<div class="agent-form">
<div class="form-row">
<label>ID
<input bind:value={editing.id} placeholder="general-purpose" disabled={!!agents.find((a) => a.id === editing.id)} spellcheck="false" />
</label>
<label>描述
<input bind:value={editing.description} placeholder="通用目的子代理…" />
</label>
</div>
<div class="form-row">
<label>Provider
<select bind:value={editing.provider}>
<option value="">(选择)</option>
{#each options.providers as p (p)}<option value={p}>{p}</option>{/each}
</select>
</label>
<label>Model
<select bind:value={editing.model}>
<option value="">(选择)</option>
{#each options.models as m (m.name)}<option value={m.name}>{m.name}</option>{/each}
</select>
</label>
</div>
<div class="form-row">
<label>token_limit
<input type="number" bind:value={editing.token_limit} placeholder="128000" />
</label>
<label>max_tool_iterations
<input type="number" bind:value={editing.max_tool_iterations} placeholder="99" />
</label>
</div>
<div class="form-label">工具 <small>普通工具可直接启用delegate / emit_signal / agent_task 由运行上下文注入)</small></div>
<div class="tag-row selectable">
{#each options.tools as tool (tool.name)}
<button class="tag pick" class:picked={editing.tools.includes(tool.name)} title={tool.description} onclick={() => toggleTool(editing.tools, tool.name)}>{tool.name}</button>
{/each}
</div>
<div class="form-label">Skills <small>(需要工具集中包含 get_skill</small></div>
<div class="tag-row selectable">
{#each options.skills as skill (skill)}
<button class="tag pick" class:picked={editing.skills.includes(skill)} onclick={() => toggleTool(editing.skills, skill)}>{skill}</button>
{/each}
</div>
<div class="form-label">递归委托 <small>(该子代理可再委托给哪些子代理)</small></div>
<select bind:value={editing.delegateMode}>
<option value="default">默认:仅 general-purpose</option>
<option value="none">不可继续委托</option>
<option value="any">任意子代理</option>
<option value="list">指定列表</option>
</select>
{#if editing.delegateMode === "list"}
<div class="tag-row selectable">
{#each agents.filter((a) => a.id !== editing.id) as agent (agent.id)}
<button class="tag pick" class:picked={editing.delegates.includes(agent.id)} title={agent.description} onclick={() => toggleTool(editing.delegates, agent.id)}>{agent.id}</button>
{/each}
</div>
{/if}
<div class="form-label">角色正文</div>
<textarea bind:value={editing.role_prompt} placeholder="# Role&#10;&#10;你是一名…"></textarea>
</div>
<div class="editor-actions">
<button class="secondary" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="primary" onclick={save} disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div>
</div>
{/if}
</div>
<style>
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.tag { padding: 2px 8px; border: 1px solid var(--line); border-radius: 4px; color: var(--text-soft); background: var(--code-bg); font-size: 11px; font-family: var(--font-mono); }
.tag-row.selectable .tag { cursor: pointer; user-select: none; }
.tag-row.selectable .tag.picked { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
.card-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.card-actions button { white-space: nowrap; flex-shrink: 0; }
.toolbar-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.icon-button.danger:hover { color: var(--danger); border-color: var(--danger-border); }
.modal-scrim { position: fixed; inset: 0; z-index: 40; border: 0; padding: 0; cursor: default; background: rgba(0,0,0,.45); }
.modal { position: fixed; z-index: 41; top: 6vh; left: 50%; transform: translateX(-50%); width: min(920px, 94vw); max-height: 88vh; overflow: auto; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); box-shadow: var(--shadow-16, var(--shadow-8)); }
.editor-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--line); }
.editor-head strong { display: block; font-size: 15px; }
.editor-head small { color: var(--muted); font-size: 11px; }
.agent-form { display: grid; gap: 14px; padding: 18px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; }
input, select, textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--text); background: var(--panel-2); font-size: 13px; }
input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
textarea { min-height: 360px; resize: vertical; font-family: var(--font-mono); line-height: 1.6; }
.form-label { color: var(--muted); font-size: 12px; font-weight: 600; }
.form-label small { font-weight: 400; color: var(--muted); }
.editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 18px; border-top: 1px solid var(--line); }
@media (max-width: 800px) { .form-row { grid-template-columns: 1fr; } }
</style>

View File

@ -1,41 +1,31 @@
<script>
import { onMount } from "svelte";
import { api } from "../lib/api.js";
import { api, formatTime } from "../lib/api.js";
import Icon from "../lib/Icon.svelte";
import StatusBadge from "../lib/StatusBadge.svelte";
import Markdown from "../lib/Markdown.svelte";
import ToolCallCard from "../lib/ToolCallCard.svelte";
let agents = $state([]);
let options = $state({ providers: [], models: [], tools: [], skills: [] });
let tasks = $state([]);
let loading = $state(true);
let error = $state("");
let editing = $state(null);
let saving = $state(false);
let { notify } = $props();
let tick = $state(0);
const blank = () => ({
id: "",
description: "",
provider: "",
model: "",
token_limit: null,
max_tool_iterations: null,
tools: [],
skills: [],
delegates: [],
role_prompt: "",
enabled: true,
});
let selected = $state(null);
let detail = $state(null);
let detailError = $state("");
let detailTimer = null;
async function load() {
loading = true;
error = "";
const activeStatuses = ["queued", "running", "waiting_children"];
function isActive(status) {
return activeStatuses.includes(status);
}
async function loadTasks() {
try {
const [a, o] = await Promise.all([
api("/api/agents"),
api("/api/agents/options"),
]);
agents = a.agents || [];
options = o;
tasks = (await api("/api/tasks?limit=200")).tasks || [];
error = "";
} catch (caught) {
error = caught.message;
} finally {
@ -43,264 +33,290 @@
}
}
function toggleTool(list, name) {
const i = list.indexOf(name);
if (i >= 0) list.splice(i, 1);
else list.push(name);
function stopDetailPoll() {
if (detailTimer) {
clearInterval(detailTimer);
detailTimer = null;
}
}
function startNew() {
editing = blank();
}
function editAgent(agent) {
editing = {
id: agent.id,
description: agent.description || "",
provider: agent.provider || "",
model: agent.model || "",
token_limit: agent.token_limit ?? null,
max_tool_iterations: agent.max_tool_iterations ?? null,
tools: [...(agent.tools || [])],
skills: [...(agent.skills || [])],
delegates: [...(agent.delegates || [])],
role_prompt: agent.role_prompt || "",
enabled: agent.enabled !== false,
};
}
function cancelEdit() {
editing = null;
}
async function save() {
if (!editing.id.trim()) {
notify("请填写 Agent ID", true);
return;
}
if (!editing.description.trim()) {
notify("请填写描述", true);
return;
}
if (!editing.role_prompt.trim()) {
notify("请填写角色正文role", true);
return;
}
if (!editing.provider || !editing.model) {
notify("请选择 provider 和 model", true);
return;
}
saving = true;
async function loadDetail() {
if (!selected) return;
try {
const payload = {
id: editing.id,
description: editing.description,
provider: editing.provider || null,
model: editing.model || null,
token_limit: editing.token_limit,
max_tool_iterations: editing.max_tool_iterations,
tools: editing.tools,
skills: editing.skills,
delegates: editing.delegates,
role_prompt: editing.role_prompt,
enabled: editing.enabled,
const [runRes, eventsRes] = await Promise.all([
api(`/api/agent-runs/${encodeURIComponent(selected)}`),
api(`/api/agent-runs/${encodeURIComponent(selected)}/events?limit=200`),
]);
detail = {
run: runRes.run,
session_id: runRes.session_id,
transcript: runRes.transcript || [],
events: eventsRes.events || [],
};
await api("/api/agents", { method: "POST", body: JSON.stringify(payload) });
editing = null;
notify("子代理已保存(需重载配置生效)");
await load();
detailError = "";
if (!isActive(runRes.run.status)) stopDetailPoll();
} catch (caught) {
notify(caught.message, true);
} finally {
saving = false;
detailError = caught.message;
}
}
async function toggleEnabled(agent) {
function openDetail(run) {
selected = run.id;
detail = null;
detailError = "";
stopDetailPoll();
loadDetail();
detailTimer = setInterval(loadDetail, 2000);
}
function closeDetail() {
selected = null;
detail = null;
detailError = "";
stopDetailPoll();
}
function humanize(ms) {
const secs = Math.floor(ms / 1000);
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`;
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
}
function elapsed(ts) {
void tick;
if (!ts) return "";
const ms = ts < 1e12 ? ts * 1000 : ts;
const diff = Date.now() - ms;
if (diff < 0) return "0s";
return humanize(diff);
}
function durationBetween(start, end) {
if (!start) return "";
const a = start < 1e12 ? start * 1000 : start;
const b = end ? (end < 1e12 ? end * 1000 : end) : Date.now();
if (b < a) return "";
return humanize(b - a);
}
function promptExcerpt(run) {
return (run.prompt || run.task || "").slice(0, 120);
}
function toolResult(callId) {
return (
detail?.transcript.find(
(message) => message.role === "tool" && message.tool_call_id === callId
) || null
);
}
function signalSummary(event) {
try {
await api("/api/agents", {
method: "POST",
body: JSON.stringify({
id: agent.id,
description: agent.description || "",
provider: agent.provider || null,
model: agent.model || null,
token_limit: agent.token_limit ?? null,
max_tool_iterations: agent.max_tool_iterations ?? null,
tools: agent.tools || [],
skills: agent.skills || [],
delegates: agent.delegates || [],
role_prompt: agent.role_prompt || "",
enabled: !agent.enabled,
}),
});
agent.enabled = !agent.enabled;
notify(agent.enabled ? "已启用" : "已禁用");
} catch (caught) {
notify(caught.message, true);
const payload = JSON.parse(event.payload_json);
return payload.summary || payload.status || event.payload_json.slice(0, 200);
} catch {
return event.payload_json.slice(0, 200);
}
}
async function remove(agent) {
if (!confirm(`确定删除子代理「${agent.id}」吗?`)) return;
try {
await api(`/api/agents/${encodeURIComponent(agent.id)}`, { method: "DELETE" });
notify("已删除");
await load();
} catch (caught) {
notify(caught.message, true);
}
}
function toolDesc(name) {
const tool = options.tools.find((t) => t.name === name);
return tool?.description || "";
}
onMount(load);
onMount(() => {
loadTasks();
const listTimer = setInterval(loadTasks, 5000);
const tickTimer = setInterval(() => (tick += 1), 1000);
return () => {
clearInterval(listTimer);
clearInterval(tickTimer);
stopDetailPoll();
};
});
</script>
<section class="page active content-page">
<div class="toolbar">
<div>
<h2 style="margin:0">具名子代理</h2>
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">
子代理由 <code>~/.picobot/agents/*.md</code> 定义工具、Skill、Provider 与模型在此直接指定。改动需热重载后生效。
</p>
{#if selected}
<div class="detail-head">
<button class="secondary" onclick={closeDetail}><Icon name="back" size={15} />返回</button>
{#if detail}
<div class="detail-title">
<span class="mono">{detail.run.agent_id}</span>
<StatusBadge status={detail.run.status} />
</div>
{/if}
</div>
<button class="primary" onclick={startNew}><Icon name="add" size={16} />新增子代理</button>
</div>
{#if loading}
<div class="loading">加载中…</div>
{:else if error}
<div class="empty-card error-text">{error}</div>
{:else if agents.length === 0}
<div class="empty-card">暂无子代理定义</div>
{:else}
<div class="cards">
{#each agents as agent (agent.id)}
{#if !detail && !detailError}
<div class="loading">加载详情…</div>
{:else if detailError}
<div class="empty-card error-text">{detailError}</div>
{:else if detail}
<div class="detail-body">
<article class="card">
<div class="card-row">
<div>
<h3>{agent.id} <StatusBadge status={agent.enabled ? "enabled" : "disabled"} /></h3>
<p>{agent.description}</p>
<div class="meta">
<span>provider: {agent.provider || agent.llm_profile || "—"}</span>
<span>model: {agent.model || "—"}</span>
{#if agent.tools?.length}<span>{agent.tools.length} 个工具</span>{/if}
{#if agent.skills?.length}<span>{agent.skills.length} 个 Skill</span>{/if}
{#if agent.delegates?.length}<span>委托: {agent.delegates.join(", ")}</span>{/if}
</div>
{#if agent.tools?.length}
<div class="tag-row">
{#each agent.tools as tool (tool)}<span class="tag" title={toolDesc(tool)}>{tool}</span>{/each}
</div>
{/if}
</div>
<div class="card-actions">
<button class="secondary" onclick={() => editAgent(agent)}><Icon name="more" size={15} />编辑</button>
<button class="switch" data-state={agent.enabled ? "checked" : "unchecked"} aria-label="启用/禁用" onclick={() => toggleEnabled(agent)}>
<span class="switch-thumb" data-state={agent.enabled ? "checked" : "unchecked"}></span>
</button>
<button class="icon-button danger" aria-label="删除" onclick={() => remove(agent)}><Icon name="dismiss" size={16} /></button>
</div>
<div class="meta">
<span>agent: <b>{detail.run.agent_id}</b></span>
<span>provider: {detail.run.provider_name}</span>
<span>model: {detail.run.model_id}</span>
<span>mode: {detail.run.mode}</span>
<span>depth: {detail.run.depth}</span>
<span>{detail.run.tool_calls_count} 次工具调用 · {detail.run.iterations}</span>
{#if detail.session_id}<span>session: <span class="mono">{detail.session_id}</span></span>{/if}
{#if detail.run.parent_run_id}<span>parent: <span class="mono">{detail.run.parent_run_id}</span></span>{/if}
<span>开始 {formatTime(detail.run.started_at)}</span>
<span>结束 {formatTime(detail.run.finished_at)}</span>
<span>耗时 {durationBetween(detail.run.started_at, detail.run.finished_at)}</span>
</div>
</article>
{/each}
{#if detail.events.length}
<div class="agent-event-cards" aria-label="运行事件">
{#each detail.events as event (event.id)}
<article class:warning={event.severity === "warning"} class:critical={event.severity === "critical"} class="agent-event-card">
<span class="agent-event-icon"><Icon name="bot" size={14} /></span>
<div class="agent-event-body">
<div class="agent-event-title">
{event.event_type === "signal" ? `信号 · ${event.severity || "info"}` : `完成 · ${event.status}`}
<span class="agent-event-delivery">{event.delivery === "steer" ? "steer" : "queue"}</span>
</div>
{#if event.event_type === "signal"}
{#if event.payload_json}<p>{signalSummary(event)}</p>{/if}
{:else}
<p class="agent-event-status">{event.status}{event.last_error ? ` · ${event.last_error}` : ""}</p>
{/if}
</div>
</article>
{/each}
</div>
{/if}
<div class="transcript">
{#if detail.run.task}
<div class="message user">
<div class="avatar">任务</div>
<div class="message-content">
<div class="bubble"><Markdown content={detail.run.task} /></div>
</div>
</div>
{/if}
{#each detail.transcript.filter((m) => m.role !== "tool") as message (message.id)}
<div class:assistant={message.role === "assistant"} class="message">
<div class="avatar">{message.role === "assistant" ? "" : message.role}</div>
<div class="message-content">
{#if message.reasoning_content}
<details class="reasoning-block historical">
<summary>思考过程</summary>
<div class="reasoning-content"><Markdown content={message.reasoning_content} /></div>
</details>
{/if}
{#if message.content}<div class="bubble"><Markdown content={message.content} /></div>{/if}
{#if message.tool_calls?.length}
<div class="tool-calls">
{#each message.tool_calls as call (call.id)}
<ToolCallCard {call} result={toolResult(call.id)} />
{/each}
</div>
{/if}
</div>
</div>
{/each}
{#if detail.transcript.length === 0 && !detail.run.task}
<div class="empty-card">无转录</div>
{/if}
</div>
{#if detail.run.error}
<article class="card error-text">{detail.run.error}</article>
{/if}
</div>
{/if}
{:else}
<div class="toolbar">
<div>
<h2 style="margin:0">子代理活动</h2>
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">
查看活动中的子代理与历史运行;子代理定义在「配置 → 子代理」中管理。
</p>
</div>
<button class="secondary" onclick={loadTasks}><Icon name="refresh" size={16} />刷新</button>
</div>
{/if}
{#if editing}
<button type="button" class="modal-scrim" onclick={cancelEdit} aria-label="关闭" tabindex="-1"></button>
<div class="modal" role="dialog" aria-label="编辑子代理">
<div class="editor-head">
<div><strong>{editing.id ? `编辑 ${editing.id}` : "新增子代理"}</strong><small>保存后需热重载配置生效</small></div>
<button class="icon-button" aria-label="关闭" onclick={cancelEdit}><Icon name="dismiss" size={18} /></button>
</div>
<div class="agent-form">
<div class="form-row">
<label>ID
<input bind:value={editing.id} placeholder="general-purpose" disabled={!!agents.find((a) => a.id === editing.id)} spellcheck="false" />
</label>
<label>描述
<input bind:value={editing.description} placeholder="通用目的子代理…" />
</label>
</div>
<div class="form-row">
<label>Provider
<select bind:value={editing.provider}>
<option value="">(选择)</option>
{#each options.providers as p (p)}<option value={p}>{p}</option>{/each}
</select>
</label>
<label>Model
<select bind:value={editing.model}>
<option value="">(选择)</option>
{#each options.models as m (m.name)}<option value={m.name}>{m.name}</option>{/each}
</select>
</label>
</div>
<div class="form-row">
<label>token_limit
<input type="number" bind:value={editing.token_limit} placeholder="128000" />
</label>
<label>max_tool_iterations
<input type="number" bind:value={editing.max_tool_iterations} placeholder="99" />
</label>
</div>
<div class="form-label">工具 <small>普通工具可直接启用delegate / emit_signal / agent_task 由运行上下文注入)</small></div>
<div class="tag-row selectable">
{#each options.tools as tool (tool.name)}
<button class="tag pick" class:picked={editing.tools.includes(tool.name)} title={tool.description} onclick={() => toggleTool(editing.tools, tool.name)}>{tool.name}</button>
{#if loading}
<div class="loading">加载中…</div>
{:else if error}
<div class="empty-card error-text">{error}</div>
{:else}
{#if tasks.some((t) => isActive(t.status))}
<h3 class="section-label">活动中</h3>
<div class="cards">
{#each tasks.filter((t) => isActive(t.status)) as task (task.id)}
<article class="card">
<div class="card-row">
<div class="task-main">
<div class="run-row">
<span class="pulse"></span>
<span class="agent-tag">{task.agent_id || "general"}</span>
<span class="run-prompt">{promptExcerpt(task)}</span>
<StatusBadge status={task.status} />
</div>
<div class="meta">
<span>mode: {task.mode}</span>
<span>depth: {task.depth}</span>
<span class="elapsed">已运行 {elapsed(task.started_at || task.created_at)}</span>
</div>
</div>
<div class="card-actions">
<button class="secondary" onclick={() => openDetail(task)}><Icon name="more" size={15} />详情</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
<div class="form-label">Skills <small>(需要工具集中包含 get_skill</small></div>
<div class="tag-row selectable">
{#each options.skills as skill (skill)}
<button class="tag pick" class:picked={editing.skills.includes(skill)} onclick={() => toggleTool(editing.skills, skill)}>{skill}</button>
{/each}
</div>
<div class="form-label">可委托的目标代理 <small>(子代理可继续委托给这些代理)</small></div>
<div class="tag-row selectable">
{#each agents.filter((a) => a.id !== editing.id) as agent (agent.id)}
<button class="tag pick" class:picked={editing.delegates.includes(agent.id)} onclick={() => toggleTool(editing.delegates, agent.id)}>{agent.id}</button>
{/each}
</div>
<div class="form-label">角色正文</div>
<textarea bind:value={editing.role_prompt} placeholder="# Role&#10;&#10;你是一名…"></textarea>
<h3 class="section-label">历史活动</h3>
<div class="cards">
{#each tasks.filter((t) => !isActive(t.status)) as task (task.id)}
<article class="card">
<div class="card-row">
<div class="task-main">
<div class="run-row">
<span class="agent-tag">{task.agent_id || "general"}</span>
<span class="run-prompt">{promptExcerpt(task)}</span>
<StatusBadge status={task.status} />
</div>
<div class="meta">
<span>{formatTime(task.created_at)}</span>
<span>{task.tool_calls_count} 次工具调用 · {task.iterations}</span>
{#if task.started_at && task.finished_at}<span>耗时 {durationBetween(task.started_at, task.finished_at)}</span>{/if}
</div>
</div>
<div class="card-actions">
<button class="secondary" onclick={() => openDetail(task)}><Icon name="more" size={15} />详情</button>
</div>
</div>
</article>
{:else}
<div class="empty-card">暂无历史活动</div>
{/each}
</div>
<div class="editor-actions">
<button class="secondary" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="primary" onclick={save} disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div>
</div>
{/if}
{/if}
</section>
<style>
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.tag { padding: 2px 8px; border: 1px solid var(--line); border-radius: 4px; color: var(--text-soft); background: var(--code-bg); font-size: 11px; font-family: var(--font-mono); }
.tag-row.selectable .tag { cursor: pointer; user-select: none; }
.tag-row.selectable .tag.picked { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
.detail-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.detail-title { display: flex; align-items: center; gap: 10px; }
.detail-title .mono { font-size: 13px; color: var(--text-soft); }
.detail-body { display: grid; gap: 12px; }
.transcript { display: grid; gap: 4px; }
.transcript .avatar { width: 28px; height: 28px; font-size: 10px; }
.section-label { margin: 18px 0 8px; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.task-main { flex: 1; min-width: 0; }
.run-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.agent-tag { font-size: 11px; color: var(--accent); background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; padding: 0 6px; font-family: var(--font-mono); }
.run-prompt { flex: 1 1 200px; min-width: 120px; }
.elapsed { color: var(--info); font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
.card-actions { display: flex; align-items: center; gap: 8px; }
.icon-button.danger:hover { color: var(--danger); border-color: var(--danger-border); }
.modal-scrim { position: fixed; inset: 0; z-index: 40; border: 0; padding: 0; cursor: default; background: rgba(0,0,0,.45); }
.modal { position: fixed; z-index: 41; top: 6vh; left: 50%; transform: translateX(-50%); width: min(720px, 94vw); max-height: 88vh; overflow: auto; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); box-shadow: var(--shadow-16, var(--shadow-8)); }
.editor-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--line); }
.editor-head strong { display: block; font-size: 15px; }
.editor-head small { color: var(--muted); font-size: 11px; }
.agent-form { display: grid; gap: 14px; padding: 18px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; }
input, select, textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--text); background: var(--panel-2); font-size: 13px; }
input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
textarea { min-height: 140px; resize: vertical; font-family: var(--font-mono); line-height: 1.6; }
.form-label { color: var(--muted); font-size: 12px; font-weight: 600; }
.form-label small { font-weight: 400; color: var(--muted); }
.editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 18px; border-top: 1px solid var(--line); }
.mono { font-family: var(--font-mono); }
</style>

View File

@ -3,6 +3,7 @@
import { Tabs } from "bits-ui";
import { api } from "../lib/api.js";
import AppearanceSettings from "../lib/components/AppearanceSettings.svelte";
import SubAgentDefinitions from "../lib/components/SubAgentDefinitions.svelte";
let { notify } = $props();
let tab = $state("appearance");
@ -16,6 +17,7 @@
const isConfig = $derived(tab === "config");
const isAppearance = $derived(tab === "appearance");
const isSubagents = $derived(tab === "subagents");
const title = $derived(tab === "config" ? "运行配置" : tab === "user" ? "用户档案" : tab === "agents" ? "Agent 行为准则" : "页面外观");
const isDirty = $derived(content !== original);
@ -34,7 +36,7 @@
async function load() {
loading = true;
if (isAppearance) { loading = false; return; }
if (isAppearance || isSubagents) { loading = false; return; }
try {
if (isConfig) {
const result = await api("/api/config");
@ -119,12 +121,16 @@
<div class="settings-grid">
<Tabs.Root value={tab} onValueChange={changeTab} orientation="vertical">
<Tabs.List class="settings-nav" aria-label="设置分类">
<Tabs.Trigger value="appearance">外观</Tabs.Trigger><Tabs.Trigger value="config">config.json</Tabs.Trigger><Tabs.Trigger value="user">USER.md</Tabs.Trigger><Tabs.Trigger value="agents">AGENTS.md</Tabs.Trigger>
<Tabs.Trigger value="appearance">外观</Tabs.Trigger><Tabs.Trigger value="config">config.json</Tabs.Trigger><Tabs.Trigger value="user">USER.md</Tabs.Trigger><Tabs.Trigger value="agents">AGENTS.md</Tabs.Trigger><Tabs.Trigger value="subagents">子代理</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
{#if isAppearance}
<AppearanceSettings />
{:else if isSubagents}
<div class="subagents-pane">
<SubAgentDefinitions {notify} />
</div>
{:else if isConfig}
<div class="config-layout">
<div class="editor-card">
@ -188,6 +194,7 @@
</section>
<style>
.subagents-pane { min-width: 0; }
.config-layout { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 18px; align-items: start; }
.config-sidebar { display: grid; gap: 14px; }
.sidebar-panel { padding: 14px; }

View File

@ -1,13 +1,10 @@
<script>
import { onMount } from "svelte";
import { Tabs } from "bits-ui";
import { api, formatTime } from "../lib/api.js";
import StatusBadge from "../lib/StatusBadge.svelte";
import Icon from "../lib/Icon.svelte";
let tab = $state("scheduled");
let jobs = $state([]);
let tasks = $state([]);
let runs = $state({});
let loading = $state(true);
let error = $state("");
@ -17,15 +14,11 @@
loading = true;
error = "";
try {
if (tab === "background") {
tasks = (await api("/api/tasks?limit=200")).tasks;
} else {
jobs = (await api("/api/jobs")).jobs;
runs = Object.fromEntries(await Promise.all(jobs.map(async (job) => [
job.id,
await api(`/api/jobs/${encodeURIComponent(job.id)}/runs?limit=10`).then((value) => value.runs).catch(() => [])
])));
}
jobs = (await api("/api/jobs")).jobs;
runs = Object.fromEntries(await Promise.all(jobs.map(async (job) => [
job.id,
await api(`/api/jobs/${encodeURIComponent(job.id)}/runs?limit=10`).then((value) => value.runs).catch(() => [])
])));
} catch (caught) {
error = caught.message;
} finally {
@ -33,11 +26,6 @@
}
}
function changeTab(value) {
tab = value;
load();
}
function countdown(ts) {
void tick;
if (!ts) return "—";
@ -52,17 +40,6 @@
return `${Math.floor(hours / 24)} 天后`;
}
function elapsed(createdAt) {
void tick;
if (!createdAt) return "";
const ms = createdAt < 1e12 ? createdAt * 1000 : createdAt;
const secs = Math.floor((Date.now() - ms) / 1000);
if (secs < 0) return "";
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`;
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
}
function dotColor(status) {
if (status === "completed" || status === "success" || status === "ok") return "var(--signal)";
if (status === "timeout") return "var(--accent)";
@ -79,40 +56,15 @@
<section class="page active content-page">
<div class="toolbar">
<Tabs.Root value={tab} onValueChange={changeTab}>
<Tabs.List class="tabs" aria-label="任务类型">
<Tabs.Trigger value="scheduled">定时任务</Tabs.Trigger>
<Tabs.Trigger value="background">后台任务</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<div>
<h2 style="margin:0">定时任务</h2>
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">管理定时任务与巡检;后台子代理运行请到「子代理」页面查看。</p>
</div>
<button class="secondary" onclick={load}><Icon name="refresh" size={16} />刷新</button>
</div>
<div class="cards">
{#if loading}<div class="loading">加载中…</div>
{:else if error}<div class="empty-card error-text">{error}</div>
{:else if tab === "background"}
{#each tasks as task (task.id)}
<article class="card">
<div class="card-row">
<div class="task-main">
<div class="run-row">
<span class="pulse" class:visible={task.status === "running"}></span>
<span class="agent-tag">{task.agent_id || "general"}</span>
<span class="run-prompt">{task.prompt?.slice(0, 120) || ""}</span>
<StatusBadge status={task.status} />
</div>
<div class="meta">
<span>{task.session_id}</span>
<span>{formatTime(task.created_at)}</span>
{#if task.status === "running"}<span class="elapsed">{elapsed(task.created_at)}</span>{/if}
<span>{task.tool_calls_count} 次工具调用 · {task.iterations}</span>
</div>
</div>
</div>
{#if task.result}<div class="details"><p>{task.result.slice(0, 300)}</p></div>{/if}
{#if task.error}<p class="error-text">{task.error.slice(0, 200)}</p>{/if}
</article>
{:else}<div class="empty-card">暂无后台任务</div>{/each}
{:else}
{#each jobs as job (job.id)}
<article class="card">
@ -147,11 +99,5 @@
<style>
.cron { font-size: 12px; color: var(--text-soft); background: var(--code-bg); padding: 1px 6px; border-radius: 4px; border: 1px solid var(--line); font-variant-numeric: tabular-nums; }
.status-dots { display: flex; gap: 4px; margin-top: 6px; align-items: center; }
.elapsed { color: var(--info); font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
.task-main { flex: 1; min-width: 0; }
.run-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.agent-tag { font-size: 11px; color: var(--accent); background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; padding: 0 6px; font-family: var(--font-mono); }
.run-prompt { flex: 1 1 200px; min-width: 120px; }
.pulse { display: none; }
.pulse.visible { display: inline-block; }
</style>