Compare commits
5 Commits
5501c539fc
...
7de7a40de7
| Author | SHA1 | Date | |
|---|---|---|---|
| 7de7a40de7 | |||
| b13450498b | |||
| b2574dc7af | |||
| b558a0a99b | |||
| 0813eb4e6d |
@ -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
|
- **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
|
- **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
|
- **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 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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "1.11.0"
|
version = "1.13.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -335,7 +335,6 @@ PicoBot 有两类记忆:
|
|||||||
| 工具 | 说明 |
|
| 工具 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `calculator` | 数学表达式和统计计算 |
|
| `calculator` | 数学表达式和统计计算 |
|
||||||
| `sleep` | 暂停当前 Agent 工具调用 0~86400 秒;可由用户停止,不用于持久调度 |
|
|
||||||
| `file_read` / `file_write` / `file_edit` | 文件读写和编辑;`file_read` 读取受支持图片时可将图片直接提供给多模态模型 |
|
| `file_read` / `file_write` / `file_edit` | 文件读写和编辑;`file_read` 读取受支持图片时可将图片直接提供给多模态模型 |
|
||||||
| `file_search` / `content_search` | 文件名和内容搜索 |
|
| `file_search` / `content_search` | 文件名和内容搜索 |
|
||||||
| `bash` | 在 workspace 中执行 Shell 命令 |
|
| `bash` | 在 workspace 中执行 Shell 命令 |
|
||||||
@ -375,7 +374,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
|||||||
| `providers` | LLM Provider 配置 |
|
| `providers` | LLM Provider 配置 |
|
||||||
| `models` | 模型参数与输入能力 |
|
| `models` | 模型参数与输入能力 |
|
||||||
| `agents` | Agent 使用哪个 provider/model |
|
| `agents` | Agent 使用哪个 provider/model |
|
||||||
| `agent_orchestration` | 具名子 Agent 定义目录、Root 委托白名单和编排上限;默认关闭 |
|
| `agent_orchestration` | 具名子 Agent 定义目录与编排上限 |
|
||||||
| `gateway` | HTTP/WebSocket、数据库、调度器、后台任务限制 |
|
| `gateway` | HTTP/WebSocket、数据库、调度器、后台任务限制 |
|
||||||
| `client` | CLI 客户端默认 Gateway URL |
|
| `client` | CLI 客户端默认 Gateway URL |
|
||||||
| `channels` | 渠道配置,目前主要是飞书/Lark |
|
| `channels` | 渠道配置,目前主要是飞书/Lark |
|
||||||
@ -408,7 +407,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
|||||||
|
|
||||||
### 具名子 Agent(Phase 1)
|
### 具名子 Agent(Phase 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
|
```md
|
||||||
---
|
---
|
||||||
@ -430,7 +429,7 @@ limits:
|
|||||||
你是一名严谨的研究 Agent,只返回与任务有关的结论和证据。
|
你是一名严谨的研究 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)。
|
更完整的配置字段说明见 [resources/skills/about-picobot/references/config.md](resources/skills/about-picobot/references/config.md)。
|
||||||
|
|
||||||
|
|||||||
16
config.json
16
config.json
@ -23,6 +23,22 @@
|
|||||||
"token_limit": 128000
|
"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": {
|
"gateway": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 19877,
|
"port": 19877,
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent,是代码架构的主入口;行为细节仍以代码和测试为最终依据。
|
本文档描述 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. 设计目标
|
## 1. 设计目标
|
||||||
|
|
||||||
@ -321,7 +321,7 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只
|
|||||||
5. 长操作应有超时;后台执行应交给 SubAgentManager/TaskSupervisor。
|
5. 长操作应有超时;后台执行应交给 SubAgentManager/TaskSupervisor。
|
||||||
6. 需要跨调用保存外部状态时,实现 `execute_with_context` 并按 session 隔离;不得让模型控制底层全局 session ID。
|
6. 需要跨调用保存外部状态时,实现 `execute_with_context` 并按 session 隔离;不得让模型控制底层全局 session ID。
|
||||||
|
|
||||||
内置 `sleep` 只暂停当前前台工具 Future,允许 0~86400 秒且不持久化;`/stop`、Scheduler/SubAgent 超时和 Supervisor shutdown 通过丢弃外层 Future 取消计时。Turn 进入 `Cancelled` 时必须把仍为 `Running` 的工具块同步归约为 `Cancelled`,避免终态快照继续显示工具执行中。超过 24 小时或需要跨重启的等待必须使用 Scheduler/后台任务。
|
没有模型可调用的前台 `sleep`/等待工具:Agent 等待异步工作时,应结束当前 Turn 让排队完成/信号开启续接 Turn,或轮询状态工具。Turn 进入 `Cancelled` 时仍必须把 `Running` 的工具块同步归约为 `Cancelled`。需要跨重启的可靠延迟必须使用 Scheduler/后台任务。
|
||||||
|
|
||||||
### 新增 Provider
|
### 新增 Provider
|
||||||
|
|
||||||
|
|||||||
279
docs/SUB_AGENT_DESIGN.md
Normal file
279
docs/SUB_AGENT_DESIGN.md
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
# 子 Agent 设计
|
||||||
|
|
||||||
|
本文说明 PicoBot 具名子 Agent(named Agent)的运行时设计,重点是**结果如何从子 Agent 传回主 Agent**,以及实现过程中踩过的坑。文中描述以当前代码为准(`src/agent/`、`src/storage/agent_run.rs`、`src/storage/agent_inbox.rs`、`src/tools/delegate.rs` 等)。
|
||||||
|
|
||||||
|
## 1. 概述与设计目标
|
||||||
|
|
||||||
|
子 Agent 让主 Agent 把一个独立、可验收的子任务交给一个**具名角色**去执行,角色有独立的 Provider/模型、工具集、系统提示词和执行预算。核心目标:
|
||||||
|
|
||||||
|
- **一切可审计**:每次委托(run)先落库再执行,终态、结果、工具调用数、迭代数都持久化,WebUI 可查。
|
||||||
|
- **结果不丢**:后台任务的完成结果即使进程崩溃、收件箱打满、唤醒丢失也能最终送达主 Agent。
|
||||||
|
- **fail-closed**:定义文件、工具集、委托边、信号契约任一处非法都拒绝加载或拒绝执行,绝不悄悄放宽权限。
|
||||||
|
- **有界**:树深度、树内 run 数、并发、信号频率、结果长度全部有上限,模型只能收窄不能扩张。
|
||||||
|
|
||||||
|
## 2. 概念模型
|
||||||
|
|
||||||
|
### 2.1 Agent 定义(Markdown)
|
||||||
|
|
||||||
|
每个子 Agent 是 `definitions_dir`(默认 `agents/`)下的一个 `*.md` 文件,文件名必须等于 `id`。前面是 YAML frontmatter,后面是角色正文(role body):
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
id: researcher
|
||||||
|
description: Research primary sources
|
||||||
|
llm_profile: research # 引用 config.json 顶层 agents 的 key
|
||||||
|
# 或者内联指定(WebUI 首选):
|
||||||
|
# provider: openai
|
||||||
|
# model: gpt-4.1
|
||||||
|
tools: [file_read, file_search, web_fetch]
|
||||||
|
delegates: [coder] # 下一级委托目标;缺省=general-purpose,[]=不可,["*"]=任意
|
||||||
|
skills: [summarize] # get_skill 的作用域
|
||||||
|
limits:
|
||||||
|
timeout_secs: 900
|
||||||
|
max_iterations: 24
|
||||||
|
max_result_chars: 16000
|
||||||
|
signal: # 可选:启用 emit_signal
|
||||||
|
delivery: queue # queue | steer
|
||||||
|
---
|
||||||
|
# Role
|
||||||
|
只返回有证据支撑的结论。
|
||||||
|
```
|
||||||
|
|
||||||
|
关键校验(`src/agent/definition.rs`):
|
||||||
|
|
||||||
|
- `deny_unknown_fields`:frontmatter 出现未知字段直接拒绝。
|
||||||
|
- `enabled`(默认 `true`):单个定义的开关;`enabled: false` 的定义保留在磁盘供管理 UI 查看,但不进入活动 catalog。
|
||||||
|
- `id` 必须匹配文件名、小写字母开头、长度 ≤64,且保留 `root/main/default/general`。
|
||||||
|
- `llm_profile` 或内联 `provider`+`model` 二选一必填;内联的 provider 和 model 必须成对出现。
|
||||||
|
- 文件必须是非符号链接的普通文件,≤256KB;role body 非空且 ≤64K 字符。
|
||||||
|
- 计算 `definition_hash`(canonical frontmatter + role body 的 SHA256),随 run 持久化,用于识别运行代内定义是否变更。
|
||||||
|
|
||||||
|
内置 `general-purpose` 定义在首次启动释放到 `~/.picobot/agents/`,作为 `delegates` 缺省时的默认委托目标。
|
||||||
|
|
||||||
|
### 2.2 Catalog 与委托图
|
||||||
|
|
||||||
|
`AgentCatalog` 每个运行代不可变。加载时把定义解析成 `AgentDefinition`(含解析后的 provider config),并校验:
|
||||||
|
|
||||||
|
- Provider profile 存在、工具名/Skill 名在注册表里、`delegates` 列表里显式列出的目标存在(`*` 与缺省不校验)。
|
||||||
|
- 任一无效 → 整代拒绝启动/热重载。
|
||||||
|
|
||||||
|
委托规则:
|
||||||
|
|
||||||
|
- **主 Agent(ROOT)**:可委托给任意具名子 Agent(`root_can_delegate` 只判断目标是否在 catalog 里)。
|
||||||
|
- **子 Agent 的下一级**:由定义里的 `delegates` 决定,语义如下:
|
||||||
|
|
||||||
|
| `delegates` | 含义 |
|
||||||
|
|-------------|------|
|
||||||
|
| 缺省(不写该字段) | 仅可委托内置 `general-purpose` |
|
||||||
|
| `[]` | 不可继续委托 |
|
||||||
|
| `["*"]` | 可委托任意子代理(除自己) |
|
||||||
|
| `["a", "b"]` | 按列表指定 |
|
||||||
|
|
||||||
|
self 与祖先链上的 Agent 在 `resolve_agent` 时永远被拒绝(循环检测)。`can_delegate(caller, target)` / `root_can_delegate(target)` / `delegate_targets(caller)` 是这套语义的唯一实现点。
|
||||||
|
|
||||||
|
### 2.3 Run 与执行上下文
|
||||||
|
|
||||||
|
一次委托 = 一个 run,持久化在 `agent_runs`。执行上下文 `AgentExecutionContext`(`src/agent/run.rs`)携带:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| `root_session_id` | 整棵委托树所属的会话 |
|
||||||
|
| `run_id` / `execution_id` | run ID 与「执行尝试」ID;首次两者相同,`execution_id` 用于条件状态转换,迟到的旧执行写不进状态 |
|
||||||
|
| `parent_run_id` / `ancestry` | 父 run 与祖先链(用于循环检测、授权) |
|
||||||
|
| `depth` | 委托深度(≥1) |
|
||||||
|
| `budget` | 剩余 run 数与剩余深度 |
|
||||||
|
| `tree_runs` | 整棵树的共享原子计数,强制 `max_runs_per_tree` |
|
||||||
|
| `signal_contract` | 信号契约(`None` 表示该 run 不能发信号) |
|
||||||
|
| `cancellation` | CancellationToken(父取消会向子级联) |
|
||||||
|
|
||||||
|
`child()` 构造子上下文:深度 +1、预算 -1、`parent_run_id` 设为父 run、`ancestry` 追加目标,并**共享** `tree_runs`。
|
||||||
|
|
||||||
|
## 3. 执行模型
|
||||||
|
|
||||||
|
### 3.1 foreground(同步等待)
|
||||||
|
|
||||||
|
`delegate` 工具 `mode=foreground` 时,调用方(主 Agent 或某个子 Agent)阻塞等待结果:
|
||||||
|
|
||||||
|
1. 先解析所有 target(任何非法请求在写库之前失败,不留孤儿行)。
|
||||||
|
2. 一次性持久化所有 run(`accept_agent_runs`,status=queued)。
|
||||||
|
3. 若调用方是具名 Agent,把父 run 置为 `waiting_children`(等待期间不占 step permit)。
|
||||||
|
4. 并发执行(`join_all`),结果**保持请求顺序**返回。
|
||||||
|
5. 每个 run 各自 commit terminal;父 run 恢复 `running`。
|
||||||
|
|
||||||
|
结果直接作为工具返回值回到模型,同时完整结果持久化到 `agent_runs.result`。
|
||||||
|
|
||||||
|
### 3.2 background(异步 + 收件箱)
|
||||||
|
|
||||||
|
`mode=background` 时,`delegate` 只做「接纳」就立即返回 run ID;真正执行在后台 runner 里,结果通过 durable inbox 送达。这是结果传递机制最复杂的部分,见第 4 节。
|
||||||
|
|
||||||
|
只有 ROOT 能发起 background;子 Agent 发起的 background、以及 background 里再 background 都不开放。
|
||||||
|
|
||||||
|
## 4. 结果传递机制(重点)
|
||||||
|
|
||||||
|
foreground 的结果是「调用即返回」,没有跨 Turn 的传递问题。**真正需要设计的是 background 的结果如何可靠地回到主 Agent**——因为 background runner 跑在后台,主 Agent 可能正在忙别的 Turn,甚至已经结束上一个 Turn。
|
||||||
|
|
||||||
|
核心思路:**结果不是直接通知 Channel,而是落进一个持久化收件箱,由主 Agent 的「续接 Turn」(continuation Turn)读取并汇入会话**。
|
||||||
|
|
||||||
|
```
|
||||||
|
background runner
|
||||||
|
└─ terminal commit(原子事务)
|
||||||
|
├─ agent_runs → 终态(execution_id + generation 条件)
|
||||||
|
├─ plan item 完成(若有)
|
||||||
|
└─ 预留槽 → agent_inbox_events 完成事件(completion)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
session 收件箱 worker(queue lane)
|
||||||
|
│ claim(pending→leased)
|
||||||
|
▼
|
||||||
|
continuation Turn(hidden 触发 + 只读工具集)
|
||||||
|
│ commit_continuation_turn(原子)
|
||||||
|
▼
|
||||||
|
可见的 assistant 结果 + 事件 consumed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 完成槽预留(保证不丢)
|
||||||
|
|
||||||
|
接纳 background 批次时,先对每个 run 预留一个完成槽:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE agent_session_state
|
||||||
|
SET reserved_completion_slots = reserved_completion_slots + ?,
|
||||||
|
revision = revision + 1, updated_at = ?
|
||||||
|
WHERE root_session_id = ?
|
||||||
|
AND pending_event_count + reserved_completion_slots + ? <= ?
|
||||||
|
RETURNING revision;
|
||||||
|
```
|
||||||
|
|
||||||
|
- 这是**条件更新**:只有 `pending + reserved + 新增 ≤ 上限` 时才成功,避免并发 `COUNT(*)` 漂移。
|
||||||
|
- 预留成功后才持久化 run;预留失败则整批拒绝。
|
||||||
|
- 意义:background 的完成事件**永远占得住位置**,不会因为收件箱被 signal 打满而丢失。signal 只能在「未预留」的容量里插入(见 4.4)。
|
||||||
|
|
||||||
|
### 4.2 终态提交(单写者)
|
||||||
|
|
||||||
|
`commit_agent_terminal`(`src/storage/agent_run.rs`)在一个事务里完成:
|
||||||
|
|
||||||
|
1. `UPDATE agent_runs SET status=终态, result=?, error=?, usage...`,条件是 `WHERE id=? AND execution_id=? AND runtime_generation=? AND status IN ('queued','running','waiting_children')`。**命中 0 行 = 迟到的旧结果,直接丢弃(返回 `None`)**。
|
||||||
|
2. 若 run 绑定了 plan item,用同一个 `execution_id` 条件完成该子项。
|
||||||
|
3. 若 `completion_slot_reserved`(background),把预留槽**转换成**一条 completion 事件写入 `agent_inbox_events`(status=completed/failed/timed_out/cancelled/interrupted,携带 result/error/signal_ids)。
|
||||||
|
|
||||||
|
三步同一事务提交:要么全部生效,要么全部回滚,**内存与数据库永不分叉**。
|
||||||
|
|
||||||
|
### 4.3 收件箱事件状态机
|
||||||
|
|
||||||
|
`agent_inbox_events` 里每条事件(signal 或 completion)走:
|
||||||
|
|
||||||
|
```
|
||||||
|
pending ──claim──▶ leased ──admit(steer)──▶ admitted ──▶ consumed
|
||||||
|
▲ │ │
|
||||||
|
└──release(backoff)◀──────────────────────────┘
|
||||||
|
pending/leased/admitted ──supersede──▶ superseded(显式取消)
|
||||||
|
pending/leased/admitted ──dead_letter──▶ dead_letter(归档/删除/超限)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **claim**:`pending → leased`,带 `lease_token` + `lease_until` + `attempt_count+1`。claim 条件 `status='pending'`,天然防双租。
|
||||||
|
- **admit**(仅 steer):`leased → admitted`,绑定 `admitted_turn_id`。
|
||||||
|
- **release**:`leased/admitted → pending`,带重试 `next_attempt_at`。lease token 防止别的 worker 已消费后又被释放。
|
||||||
|
- **consume**:在续接 Turn 提交事务里原子完成。
|
||||||
|
- **supersede**:显式取消 run 时,把其未消费 signal 置为 superseded(completion 永不 supersede)。
|
||||||
|
- **dead_letter**:会话归档/删除、或投递超过 `max_inbox_delivery_attempts` 时;最多发一次有界 system fallback 提示。
|
||||||
|
|
||||||
|
### 4.4 两条投递 lane:queue 与 steer
|
||||||
|
|
||||||
|
事件按 `delivery` 分两种语义(`SignalDelivery`,定义在 `signal.delivery`):
|
||||||
|
|
||||||
|
| lane | 语义 | 到达方式 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `queue` | 排队到**下一个** Turn | 收件箱 worker 在调度边界把事件变成续接 Turn |
|
||||||
|
| `steer` | 注入**当前活动** Turn 的安全边界 | 两阶段准入:claim → 预留 mailbox 槽 → DB admit(turn_id) |
|
||||||
|
|
||||||
|
**steer 两阶段准入**(任何一步失败都必须无损回退):
|
||||||
|
|
||||||
|
1. claim(pending→leased,拿到 lease token)。
|
||||||
|
2. 在 TurnMailbox 的 agent lane 预留一个槽(容量独立于 user lane)。
|
||||||
|
3. `admit_inbox_event`(leased→admitted,绑 turn_id)。
|
||||||
|
4. 同一 Turn/代激活。
|
||||||
|
|
||||||
|
失败路径:claim 失败 → 释放 lease 并 wake queue lane;mailbox 满 → 释放 lease 回 pending,等 queue lane 以 continuation 送达。**steer 可靠退化为 queue**:当活动 Turn 关闭时,已 admit 的 steer 事件按 lease token 释放回 pending,绝不静默丢弃。
|
||||||
|
|
||||||
|
### 4.5 续接 Turn(continuation Turn)
|
||||||
|
|
||||||
|
queue lane 的 worker claim 一批事件后,把它们合成为一条**隐藏的触发消息**(`build_continuation_trigger`):completion 事件渲染为「后台任务完成(Agent、状态、Run ID、任务、结果/错误)」,signal 渲染为「后台信号(级别、摘要)」。
|
||||||
|
|
||||||
|
续接 Turn 的特殊性:
|
||||||
|
|
||||||
|
- 触发消息 `client_visibility=hidden`、`turn_origin=agent_continuation`——**不进客户端历史、不进 Channel 投递、只供模型回放**。
|
||||||
|
- 工具集受限为只读:`file_read/file_search/content_search/web_fetch/calculator/agent_task`。续接 Turn 不能写文件、发消息、再委托、调度。
|
||||||
|
- `commit_continuation_turn` 在**一个事务**里写 hidden trigger + 可见 assistant/tool 消息 + usage + 事件 consume + session 计数,客户端永远不会看到「半成品续接」。
|
||||||
|
|
||||||
|
`requires_continuation=false` 的完成事件(如 `/stop` 产生的 cancel 完成)直接写成 consumed,不触发续接。
|
||||||
|
|
||||||
|
### 4.6 唤醒与公平调度
|
||||||
|
|
||||||
|
事件 commit 成功后,Coordinator 通过 `AgentInboxNotifier` 做一次**尽力而为**的 wake(弱引用、late-bound,避免与 SessionManager 形成强引用环)。**wake 丢失不是错误**:durable inbox 是唯一事实源,worker 有周期性重新 claim 的兜底。
|
||||||
|
|
||||||
|
公平调度:空闲(无用户积压)时 due 事件立即 claim(完成即返回);忙碌时,连续处理 `max_user_turn_burst_before_inbox` 个用户 Turn 后,或最老 pending 事件等待超过 `max_inbox_wait_secs`,下一个调度项必须是一批 inbox 事件。当前活动 Turn 从不被 queue 事件抢占。
|
||||||
|
|
||||||
|
### 4.7 emit_signal(信号)
|
||||||
|
|
||||||
|
只在定义声明 `signal` 块时,run 才会被注入 `emit_signal` 工具。契约字段(总量、单条字节、最小间隔、burst、severity allowlist、dedupe 冷却窗、JSON 深度)全部由工具与 Coordinator 强制,模型只提供 key/severity/summary/details/dedupe_key。
|
||||||
|
|
||||||
|
- 结构校验(severity 是否在 allowlist、summary/key 长度、payload 大小与深度)在工具内做,不依赖模型自觉。
|
||||||
|
- 频率限制是每 run 内存态(工具实例为单个 run 的 registry 创建)。
|
||||||
|
- Coordinator `emit_signal` 再校验:run 存在、`execution_id` 匹配、非终态;`insert_agent_signal` 在 `pending + reserved + 1 ≤ 上限` 下条件插入,并做冷却窗 dedupe(`run_id + event_type + event_key` 唯一)。
|
||||||
|
- 信号 ID 记入 `emitted_signals`,最终写进该 run 的 completion 事件 payload,供主 Agent 交叉核对。
|
||||||
|
|
||||||
|
## 5. 持久化模型
|
||||||
|
|
||||||
|
三张 agent 表(schema v8):
|
||||||
|
|
||||||
|
- **`agent_runs`**:每次委托一行。含 run id、root session、父子、caller 身份(`caller_agent_id`/`caller_scope_id`)、agent/definition 快照(`definition_hash`)、provider/model、mode、depth、task/context、budget、signal 契约快照、status(queued/running/waiting_children/终态)、result/error、usage、`execution_id`、`completion_slot_reserved`、时间线、revision。`execution_id` 唯一索引。
|
||||||
|
- **`agent_inbox_events`**:收件箱。`run_id`(NOT NULL,FK)、event_type(signal/completion)、event_key(去重键)、delivery(queue/steer)、requires_continuation、severity、payload、status、attempt/lease、`UNIQUE(run_id, event_type, event_key)`。
|
||||||
|
- **`agent_session_state`**:每根会话一行,权威容量计数(`pending_event_count` + `reserved_completion_slots` + 单调 `revision`)。所有容量增减都是条件 UPDATE。
|
||||||
|
|
||||||
|
结果不复制大文本:完整结果在 `agent_runs.result`,inbox payload 只放有界摘要/元数据。
|
||||||
|
|
||||||
|
## 6. 取消与恢复
|
||||||
|
|
||||||
|
- **取消 run**(`cancel_run`):先按树位置授权、确认非终态,然后 `cancel_agent_run_with_completion`(写终态 + 若预留槽则转换 completion 事件),取消 CancellationToken,并 supersede 未消费 signal。`suppress_continuation=true` 时 completion 写成 consumed(`/stop`/归档后不再续接)。
|
||||||
|
- **取消会话**(`cancel_session`):取消该会话所有非终态 run,完成事件写 consumed。
|
||||||
|
- **启动恢复**(`recover_agent_state`):旧运行代的 queued/running/waiting_children → interrupted(background 转换 failure completion);过期 lease → pending 带 backoff、超限 → dead_letter;按行重算容量计数,差异修复并告警。
|
||||||
|
|
||||||
|
## 7. 授权
|
||||||
|
|
||||||
|
run ID 不是凭证。ROOT 可访问本会话所有 run;具名 Agent 只能访问自己的 run 及其**后代**(沿 `parent_run_id` 向上走到自己)。其他会话一律拒绝读取/取消。
|
||||||
|
|
||||||
|
## 8. 易出错点总结
|
||||||
|
|
||||||
|
实现过程中反复踩坑的地方,按重要程度排序:
|
||||||
|
|
||||||
|
1. **execution_id 条件更新**。终态、running、信号写入都必须带 `execution_id`(和 generation)条件,命中 0 行 = 迟到旧结果,静默丢弃。否则一个超时后被重试的旧 runner 可能覆盖新终态。
|
||||||
|
2. **收件箱容量 = pending + reserved,且必须条件 UPDATE**。signal 不能挤掉 background 的完成预留;用无锁 `COUNT(*)` 推断会并发漂移,必须在同一写事务里 `UPDATE ... WHERE pending+reserved+n ≤ limit RETURNING`。
|
||||||
|
3. **完成槽预留 → 完成事件转换必须在终态提交的同一事务里**。一旦分开,崩溃就会留下「已预留但永远不产出 completion」的槽。
|
||||||
|
4. **wake 是尽力而为,不是正确性来源**。任何依赖「wake 一定到达」的逻辑都会在丢 wake 时漏投。事实源是 durable inbox,wake 只加速,周期性重新 claim 兜底。
|
||||||
|
5. **steer 两阶段准入的无损性**。claim → mailbox 预留 → DB admit 任何一步失败都要释放 lease 并 wake queue lane;Turn 关闭时已 admit 的 steer 要按 lease token 放回 pending(退化为 queue)。绝不静默丢弃。
|
||||||
|
6. **输入归属互斥**(steer / 下一 Turn FIFO / `/stop`)。一条输入要么属于当前活动 Turn,要么进下一 Turn FIFO,`/stop` 两者都丢弃;三者必须无损且互斥。
|
||||||
|
7. **委托循环、预算、深度、树 run 上限要在解析阶段就拦下**。`ancestry` 判环、`budget` 判耗尽、`reserve_tree_run` 用共享原子计数强制 `max_runs_per_tree`。
|
||||||
|
8. **fail-closed 顺序:先解析所有 target 再写库**。否则批量里一个非法 target 会留下前几个 run 的孤儿行。
|
||||||
|
9. **spawn 失败的补偿**。TaskSupervisor 拒绝 spawn(如关机)时,要取消该 run 并释放其完成槽,否则槽永远占着。
|
||||||
|
10. **子 Agent 管理器对 Coordinator 用 `Weak`**。Coordinator 拥有 manager,manager 若强引用 coordinator 会成环。
|
||||||
|
11. **结果两段式:模型看到截断、库存全量**。`max_result_chars` 截断返回给模型的内容并提示「用 agent_task get_result 查全量」;`full_content` 原样持久化。截断要按 `floor_char_boundary`,否则 UTF-8 边界 panic。
|
||||||
|
12. **MIN 聚合无行时返回 NULL 被解成 0**。曾导致 worker 空转;`oldest_pending_due`/`next_pending_due_at` 用 `Option<Option<i64>>` 显式区分「无行」与「值为 0」。
|
||||||
|
13. **claim 的确定性与防双租**。claim 按 `created_at, id` 排序保证确定性;lease token 让 release 只作用于本 worker 租下的事件,防止把别人已消费的又放回 pending。
|
||||||
|
14. **idempotency_key 的部分唯一索引**。`UNIQUE(root_session_id, caller_scope_id, idempotency_key) WHERE idempotency_key IS NOT NULL`——`NULL` 不参与去重,否则 SQLite 里所有 NULL 会互相冲突。
|
||||||
|
15. **signal 冷却窗去重键要含时间窗**(`event_key = signal:{key}:{now/cooldown}`)。否则「窗口内去重、窗口外再发」无法表达。
|
||||||
|
16. **父 run 的 `waiting_children` 状态必须对称恢复**。父等待时释放 step permit,子结束后要 `restore_agent_run_running`,否则父 run 卡在 waiting 状态。
|
||||||
|
|
||||||
|
## 9. 配置项
|
||||||
|
|
||||||
|
`agent_orchestration`(`src/config/mod.rs`):
|
||||||
|
|
||||||
|
| 键 | 默认 | 含义 |
|
||||||
|
|----|------|------|
|
||||||
|
| `definitions_dir` | `agents` | 定义目录(相对 config.json 所在目录) |
|
||||||
|
| `max_tree_depth` | `4` | 委托树最大深度 |
|
||||||
|
| `max_runs_per_tree` | `16` | 一棵树内最大 run 数 |
|
||||||
|
| `max_concurrent_runs` / `max_concurrent_runs_per_session` | `6` / `4` | 全局/每会话并发 run 上限 |
|
||||||
|
| `max_pending_inbox_events_per_session` | `128` | 每会话 pending + reserved 上限 |
|
||||||
|
| `max_inbox_delivery_attempts` | `8` | 投递尝试上限(超过进 dead-letter) |
|
||||||
|
| `max_user_turn_burst_before_inbox` | `4` | 公平调度:连续处理多少个用户 Turn 后必须清 inbox |
|
||||||
|
| `max_inbox_wait_secs` | `30` | 最老事件等待上限(秒) |
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -1,153 +0,0 @@
|
|||||||
# 子 Agent 编排与信号投递设计审核报告
|
|
||||||
|
|
||||||
> 状态:审核完成(2026-08)。审核对象为设计提案 `docs/SUB_AGENT_ORCHESTRATION_DESIGN.md`,该设计尚未实现;本文所有"现状"描述以当前代码和测试为准。
|
|
||||||
>
|
|
||||||
> 本文结合现有实现逐条核实设计的现状诊断,评估架构合理性,并按严重程度列出缺陷与落地前必须补齐的定义。行号基于审核时的代码快照,后续实现合并后可能过时。
|
|
||||||
>
|
|
||||||
> 设计方逐项答复见 [`SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md`](SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md);已接受结论同步写入设计文档。
|
|
||||||
|
|
||||||
## 1. 审核范围与依据
|
|
||||||
|
|
||||||
### 1.1 审核对象
|
|
||||||
|
|
||||||
- 设计文档:`docs/SUB_AGENT_ORCHESTRATION_DESIGN.md`(提案,未实现;`rg` 确认 `src/` 与 `webui/` 中无任何 `AgentCatalog`/`AgentCoordinator`/`agent_inbox`/`emit_signal` 相关实现)
|
|
||||||
- 对照实现:`src/agent/sub_agent.rs`、`src/agent/agent_loop.rs`、`src/agent/steering.rs`、`src/session/session.rs`、`src/session/turn_input.rs`、`src/session/persistence.rs`、`src/tools/delegate.rs`、`src/tools/sleep.rs`、`src/tools/send_message.rs`、`src/tools/traits.rs`、`src/storage/`、`src/work/mod.rs`、`src/scheduler/mod.rs`、`src/task_supervisor.rs`、`src/config/mod.rs`、`src/gateway/reload.rs`
|
|
||||||
|
|
||||||
### 1.2 审核依据
|
|
||||||
|
|
||||||
- `docs/ARCHITECTURE.md` 与 AGENTS.md 中的架构边界和并发不变量
|
|
||||||
- 现有相似机制:Scheduler durable lease(`src/storage/scheduler.rs:310-348`)、WorkManager 乐观并发(`src/work/mod.rs:320-342`)、TurnController"持久化后才 Completed"(`src/session/persistence.rs:147-167`)、RuntimeAdmission(`src/gateway/reload.rs`)
|
|
||||||
|
|
||||||
## 2. 总体结论
|
|
||||||
|
|
||||||
**设计方向合理,可以按分期推进;但存在 5 处与现有代码强耦合的接缝缺口(A1–A5),落地前必须先补齐定义,否则 Phase 2/3 会被迫返工。**
|
|
||||||
|
|
||||||
设计的核心决策——`foreground/background` 与 `queue/steer` 两个正交维度、先持久化后唤醒、SQLite inbox 为权威来源、lease/consumed 事务提交、ancestry 环检查、AgentCatalog 绑定运行代——与 PicoBot 既有不变量一致,且现状诊断(设计 §1 的 9 条)逐条属实(见第 3 节)。主要问题不在方向,而在设计与现有 session worker、`/stop`、AgentLoop 取消机制的衔接处留白过多。
|
|
||||||
|
|
||||||
## 3. 现状诊断核实
|
|
||||||
|
|
||||||
设计 §1 的 9 条诊断全部与代码一致:
|
|
||||||
|
|
||||||
| # | 设计诊断 | 代码证据 | 结论 |
|
|
||||||
|---|----------|----------|------|
|
|
||||||
| 1 | 所有子 Agent 复用同一 `LLMProviderConfig` | `SubAgentManager.provider_config` 单实例(`src/agent/sub_agent.rs:119`),inline/background 均用它创建 Provider(:204、:477) | 属实 |
|
|
||||||
| 2 | 无具名角色文件,工具权限由 `allowed_tools` 临时决定 | `delegate` schema 的 `allowed_tools` 数组(`src/tools/delegate.rs:50-54`);未填时用默认只读集(`sub_agent.rs:34-41`) | 属实 |
|
|
||||||
| 3 | 子 Agent 被统一移除 `delegate` | `filter_tools` 硬编码排除 `delegate`/`todo`/`reload_config`(`sub_agent.rs:177-181`) | 属实 |
|
|
||||||
| 4 | `DelegateContext` 只有 session/channel/chat | `sub_agent.rs:90-95`;无 caller/parent/depth/ancestry | 属实 |
|
|
||||||
| 5 | `parallel` 混淆"委托方是否等待"与"是否并发" | `run_parallel` 就是 `join_all(run_inline)`(`sub_agent.rs:332-346`) | 属实 |
|
|
||||||
| 6 | 后台完成通知直发 `MessageBus.outbound`,不成为主 Agent 输入 | `background-task-notifications` 任务格式化后 `publish_outbound` fire-and-forget(`src/session/session.rs:1757-1776`),不写会话历史、不触发 Turn | 属实 |
|
|
||||||
| 7 | Steering mailbox 只建模用户输入,且继承 `/stop` 丢弃语义 | admission 只推 `SourceKind::UserInput`(`session.rs:2910-2938`);AgentLoop 防御性归一 `role=user`(`src/agent/agent_loop.rs:624-628`);`/stop` 调 `close_and_take_pending` 主动丢弃(`session.rs:2120-2128`、`src/agent/steering.rs:215-229`) | 属实 |
|
|
||||||
| 8 | `SleepTool` 只等定时器 | `tokio::time::sleep` 单一路径(`src/tools/sleep.rs:72`),无 wakeup/cancel 分支 | 属实 |
|
|
||||||
| 9 | `send_message` 同时覆盖跨 Channel、目标会话写入和同 Turn 附件暂存 | `src/tools/send_message.rs` 的 target/content/origin/files 参数;`OutboundDelivery::AttachedToCurrentTurn` 同 Turn 分支(`src/tools/traits.rs:127-131`) | 属实 |
|
|
||||||
|
|
||||||
**补充:设计隐含覆盖了一个现存 bug。** inline 结果截断时提示"完整结果请使用 check_task 查看"(`sub_agent.rs:809`),但 `run_inline` 从不写 `background_tasks` 表(只有 `run_background` 写,`sub_agent.rs:373-398`),`check_task`(`sub_agent.rs:707-713`)查不到 inline 结果。设计 Phase 2"Foreground 结果也持久化"(§23)修复此问题,分期安排正确。
|
|
||||||
|
|
||||||
## 4. 设计合理性评估
|
|
||||||
|
|
||||||
以下决策予以肯定:
|
|
||||||
|
|
||||||
| 设计点 | 评估 |
|
|
||||||
|--------|------|
|
|
||||||
| 执行生命周期与投递方式正交化(§4.1) | ✅ 干净消除 `parallel` 的语义混淆;批量 foreground 并发等价旧 parallel 但不作为第三种模式 |
|
|
||||||
| 先持久化后唤醒、内存 wakeup 仅为加速器(§6.4、§14.4) | ✅ 与"持久化后才 Completed"既有不变量(`persistence.rs:147-167`)同构 |
|
|
||||||
| lease/consumed 与条件更新(§14、§16.4) | ✅ 复用 Scheduler durable lease 与 WorkManager 乐观并发的成熟模式 |
|
|
||||||
| `target not in ancestry` 拒绝 A→B→A(§7.1) | ✅ 以显式 iteration workflow 替代隐式递归,边界正确 |
|
|
||||||
| 授权不依赖 task-local(§8) | ✅ 正确诊断现状 `DELEGATE_CONTEXT` task-local(`sub_agent.rs:19-28`)不是授权事实来源;`tokio::spawn` 不传播 task-local |
|
|
||||||
| AgentCatalog 以 Arc 固定运行代(§5.4、§18.4) | ✅ 与 RuntimeAdmission 现有集成一致(`SubAgentManager` 已接 admission,`sub_agent.rs:157-163、353-358`) |
|
|
||||||
| `llm_profile` 引用现有 `config.agents`(§5.2) | ✅ `Config.agents` 与 `get_provider_config(agent_name)` 已存在(`src/config/mod.rs:45、712-745`),无需配置重构 |
|
|
||||||
| Completion 由运行时自动生成、不依赖模型记得调工具(§12.1) | ✅ 正确;`emit_signal` 无任意目标参数,收敛了权限面 |
|
|
||||||
| 可唤醒 sleep 用 watch revision 而非裸 Notify(§17.2) | ✅ 正确规避"输入先于订阅到达"的丢失唤醒竞态 |
|
|
||||||
| §26 不变量清单 | ✅ 与 ARCHITECTURE.md 一致,可作为实现验收标准 |
|
|
||||||
| 分期顺序(§23) | ✅ Phase 1 纯增量;Phase 2 顺带修复 inline 截断 bug;依赖方向正确 |
|
|
||||||
|
|
||||||
## 5. 缺陷清单
|
|
||||||
|
|
||||||
严重程度:A=主要(落地前必须补齐定义);B=中等(实现对应 Phase 前补齐);C=次要(修订文档即可)。
|
|
||||||
|
|
||||||
### 5.1 A 级:主要缺陷
|
|
||||||
|
|
||||||
**A1 — Session 队列饱和语义与现状冲突(设计 §15.3)**
|
|
||||||
|
|
||||||
现状:session 队列容量 32(`session.rs:27`),满时 `try_send` 失败直接丢弃输入并回复"队列已满"(`session.rs:3001-3007`)。设计要求 durable event 在队列饱和时"保持 durable pending,由 Router 有界重试,不能丢弃",但未定义:
|
|
||||||
|
|
||||||
- agent 事件与用户输入是否共用同一 mpsc(共用则用户流量可长期占满队列,事件重试无收敛界);
|
|
||||||
- Router 重试的退避、deadline 与最终处置;
|
|
||||||
- §15.4 只拆分了 TurnMailbox 的 lane(user 32/64KiB、agent 8/32KiB),session 级队列的 agent lane 容量与优先级未定义。
|
|
||||||
|
|
||||||
**A2 — `/stop` 与 worker 退出时 durable event 的恢复机制缺失(设计 §18.3)**
|
|
||||||
|
|
||||||
现状 `/stop`:`current_cancel.take()`(`session.rs:2117`)→ `close_and_take_pending` 丢弃 steering(:2120-2128)→ `agent_tx.take()` 丢弃全部排队任务(:2136)→ bump generation/state_version(:2139-2140)→ 取消后台子任务(:2144-2148)。mpsc 被 drop 时没有逐项回调。设计未定义:
|
|
||||||
|
|
||||||
- 被丢弃的内部 `AgentTask`(含 `BackgroundAgentResults`)如何触发 inbox lease 释放——只能靠 lease 超时被动收敛(应明说延迟界),或为 AgentTask 增加 Drop guard(未提);
|
|
||||||
- `/stop` 后 worker 退出(`task_rx.recv()` 返回 None 即 break,`session.rs:3150-3152`),被取消 run 异步生成的 cancelled completion 由谁、何时消费,完整链路未写。
|
|
||||||
|
|
||||||
**A3 — `waiting_children` permit 释放在现有结构中无落点(设计 §19.2)**
|
|
||||||
|
|
||||||
AgentLoop 目前没有任何 permit/cancellation 原语(`agent_loop.rs` 无 CancellationToken/select;取消靠 worker 整体 drop future,`session.rs:3778-3804`);TaskSupervisor 也没有并发上限,只在 stopping 时拒绝 spawn(`task_supervisor.rs:60-89`)。设计只给出原则"permit 限制活跃 Provider/工具步骤",未定义:
|
|
||||||
|
|
||||||
- permit 由谁持有与获取/释放(AgentLoop?AgentRunner?Coordinator?);
|
|
||||||
- delegate 在父 run 的 tool batch 内执行(`agent_loop.rs:1187-1242`),父 run 进入等待时释放 permit 的钩子如何嵌入现有批处理流程。
|
|
||||||
|
|
||||||
这是 Phase 2 复杂度最高的部分,只给原则不够。
|
|
||||||
|
|
||||||
**A4 — 结构化取消是前置条件,但 AgentLoop 当前零支持(设计 §18.1)**
|
|
||||||
|
|
||||||
新架构中子 run 是 Coordinator spawn 的独立任务,父 future 被 drop 不再传播取消,必须用显式 CancellationToken 树贯穿 AgentLoop——这是横切重构,设计只在 Phase 2 列了一行"实现结构化取消"。另有一处表述需要澄清:§3 非目标"不默认硬中断正在进行的 Provider 请求"只约束 `steer`;现有 `/stop` 恰是硬 drop(drop `process_future` 连带中断 provider 流,`session.rs:3778-3804`)。文档应显式声明 `/stop` 保持硬语义,避免实现时误读为 `/stop` 也要走安全边界。
|
|
||||||
|
|
||||||
**A5 — 内部 continuation Turn 的消息/持久化/渲染模型未定义(设计 §16.3)**
|
|
||||||
|
|
||||||
现有 Turn 以用户消息为起点:先持久化用户消息(`session.rs:3191`);`prepare_turn_input` 把 runtime context 附加到最后一条 user message(`src/session/turn_input.rs:19-25`);WebUI/TUI 按 user/assistant 交替渲染。设计说"内部输入不显示用户气泡",但未定义:
|
|
||||||
|
|
||||||
- continuation Turn 写什么消息行(无 user 行?系统行?)、历史 replay 给 provider 时的形态;
|
|
||||||
- 客户端如何渲染无用户消息的 Turn(§15.1 的 SourceKind 扩展只解决标记问题);
|
|
||||||
- continuation 输出的投递目标:`AgentTask` 的 channel/chat_id/channel_context 来自 InboundMessage(`session.rs:631-641`),内部任务没有 channel 上下文,应显式规定投递到 session 最近的 channel/chat。
|
|
||||||
|
|
||||||
### 5.2 B 级:中等缺陷
|
|
||||||
|
|
||||||
**B1 — browser/resource scope 隔离是行为破坏,且无共享出口(设计 §10)。** 现状子 Agent 复用父对话的 browser session(`browser_session_id` 回退到 delegate context 的 session_id,`sub_agent.rs:264-279`;background 路径 :505-508 同)。改为 `root_session_id + run_id` 隔离会破坏依赖父会话登录态/cookie 的场景。"确需共享必须由工具定义显式支持"没有给出机制,应指明 `browser_profiles` persistent ID 为官方共享路径。
|
|
||||||
|
|
||||||
**B2 — dead_letter 与重试上限策略空缺(设计 §14.3、§21)。** Phase 3 移除直发通知后,continuation 反复失败转 dead_letter 时结果对用户彻底不可见,文档未定义 dead-letter 后的用户可见行为(如回退一条系统通知)。`max_pending_inbox_events_per_session=128` 打满后新事件的行为同样未定义。
|
|
||||||
|
|
||||||
**B3 — `completion_policy=each` 只有字段没有语义(设计 §14.2)。** 正文只描述了 `all`:一个 run 超时会把全组结果交付拖到 group deadline,缺少 per-run 提前交付或分组拆分策略。
|
|
||||||
|
|
||||||
**B4 — "UI 未读状态"是防饥饿的关键依赖,但不存在且未立项(设计 §16.2)。** 调度优先级把 queue completion 排在用户输入之后,持续用户流量下后台结果会被无限推迟,设计靠"UI 未读状态"兜底;该 WebUI 功能当前不存在,Phase 3 只写了"WebUI 投影",未列为明确工作项。
|
|
||||||
|
|
||||||
**B5 — `cost` 字段假设了不存在的定价配置(设计 §6.5、§14.1)。** `agent_runs.cost` 与 ProviderFactory"复用价格信息"的前提不成立:config 从不填 `price_input/output_per_million`(`src/config/mod.rs:742-743` 硬编码 None,无配置键解析)。要么补定价配置,要么注明 cost 暂为 NULL。
|
|
||||||
|
|
||||||
**B6 — 子 Agent run 内 sleep 的唤醒语义未定义(设计 §17)。** 全章隐含 root session 的 Turn;sub-run 没有"当前 session 用户输入"概念。应显式规定 sub-run 内 sleep 只响应自身 cancellation/timeout,否则 `TurnWakeupHandle` 的来源不明。
|
|
||||||
|
|
||||||
### 5.3 C 级:次要问题
|
|
||||||
|
|
||||||
**C1 — SQLite UNIQUE 与 NULL 语义(设计 §14.3、§9.6)。** `UNIQUE(run_id, event_type, event_key)`:`emit_signal` 未提供 `dedupe_key` 时 `event_key` 的生成规则未定义。`idempotency_key` 的唯一范围 `(root_session_id, caller_run_id, key)` 在 ROOT 调用时 `caller_run_id` 为 NULL,SQLite 中 NULL≠NULL 会导致去重失效,需要哨兵值(如 `root`)。
|
|
||||||
|
|
||||||
**C2 — 授权与上下文的细节留白(设计 §7.1、§9.1、§10)。** definition `max_depth` 与全局 `max_tree_depth` 是否取 min 未明说;`agent_task` 工具能否操作本 root session 任务树之外的 run(跨 session 越权)未明说;skills/memory 是否进入子 Agent 上下文未提(现状子 Agent 可带 skills prompt,`sub_agent.rs:188-197`;memory recall 只在 session Turn,`turn_input.rs:47`)。
|
|
||||||
|
|
||||||
**C3 — `async` 别名是多余假设(设计 §22.1)。** 现代码从未接受 `async`(`delegate.rs:151-165` 只解析 inline/background/parallel),该迁移条目可删。
|
|
||||||
|
|
||||||
**C4 — 客户端协议变更未枚举(设计 §20.2、Phase 4)。** AgentSignal 卡片、"已接纳"状态、continuation Turn 都需要新的 `WsOutbound` 消息类型(`src/protocol.rs`),Phase 4 只写"添加 AgentSignal UI 和任务树",未列协议变更清单。
|
|
||||||
|
|
||||||
## 6. 修订建议
|
|
||||||
|
|
||||||
实现启动前,建议在设计文档中补充五个专项定义(对应 A 级缺陷):
|
|
||||||
|
|
||||||
1. **Durable event 与 session 队列的 lane 划分**:agent 事件是否独立队列、饱和时的重试退避/deadline/dead-letter 策略、与用户输入的优先级关系(A1)。
|
|
||||||
2. **`/stop`、worker 退出与 lease 释放的衔接**:被丢弃内部任务的 lease 释放路径(Drop guard 或明确依赖 lease 超时及延迟界)、`/stop` 后生成的 cancelled completion 的消费链路(A2)。
|
|
||||||
3. **Permit 归属**:执行 permit 在 AgentLoop/AgentRunner/Coordinator 之间的获取与释放点,特别是 `waiting_children` 前后的钩子位置(A3)。
|
|
||||||
4. **Continuation Turn 模型**:消息行写入形态、provider replay 形态、客户端渲染契约、输出投递目标(A5)。
|
|
||||||
5. **取消横切方案**:CancellationToken 贯穿 AgentLoop 的接口设计,并显式声明 `/stop` 保持硬 drop 语义、安全边界注入只约束 `steer`(A4)。
|
|
||||||
|
|
||||||
B 级问题建议在对应 Phase 实现前补齐:B1/B6 在 Phase 1,B5 在 Phase 2,B2/B3/B4 在 Phase 3。
|
|
||||||
|
|
||||||
## 7. 分期实施意见
|
|
||||||
|
|
||||||
| Phase | 风险 | 意见 |
|
|
||||||
|-------|------|------|
|
|
||||||
| 1 具名 Agent 与 Foreground | 低 | 纯增量。`llm_profile` 直接复用 `Config::get_provider_config`(`config/mod.rs:712-745`),无配置重构。注意 B1:browser scope 隔离会改变现有子 Agent 共享父会话 browser 的行为,需要迁移说明 |
|
|
||||||
| 2 统一 Run 持久化 | 高 | 改动面最大:AgentLoop 取消与 permit 均为横切变更。建议先独立原型"CancellationToken 贯穿 AgentLoop",用现有 sleep 取消测试(`sleep.rs:194-237`)与 steering 恢复测试锁定回归基线,再叠加 permit |
|
|
||||||
| 3 Inbox 与 Queue Completion | 中 | UX 拐点:完成通知从直发 Channel 改为主 Agent continuation。建议保留配置开关回退直发通知,覆盖 B2 的 dead-letter 空窗;§25.4 测试矩阵是本 Phase 验收关键 |
|
|
||||||
| 4 Emit Signal 与 Steer | 中 | TurnMailbox 泛化触及 `handle_message` 核心 admission 路径(`session.rs:2815-2953`),与 `/stop` 的原子性必须沿用现有同锁判定模式;先补 C4 协议清单 |
|
|
||||||
| 5 可唤醒 Sleep | 低 | 相对独立。watch revision 方案正确;先补 B6 的 sub-run 语义 |
|
|
||||||
|
|
||||||
## 8. 结论
|
|
||||||
|
|
||||||
设计的现状诊断准确、核心决策与既有架构不变量兼容、分期依赖方向正确,**审核结论为"方向通过,需修订后实现"**。A1–A5 五个接缝缺口不是方向错误,而是设计与 `session worker`/`/stop`/`AgentLoop` 取消机制的衔接定义不足;按第 6 节补齐专项定义后,可按第 7 节顺序分期实施。
|
|
||||||
@ -1,222 +0,0 @@
|
|||||||
# 子 Agent 编排与信号投递设计评审答复
|
|
||||||
|
|
||||||
> 状态:设计方答复(2026-08)。
|
|
||||||
>
|
|
||||||
> 本文逐项回应 `docs/SUB_AGENT_ORCHESTRATION_REVIEW.md`。评审原文作为审核记录保留;已接受的结论同时回写到 `docs/SUB_AGENT_ORCHESTRATION_DESIGN.md`,后者仍是后续实现的规范来源。代码级实施方案见 [`SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md`](SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md)。
|
|
||||||
|
|
||||||
## 1. 总体答复
|
|
||||||
|
|
||||||
接受评审的总体结论:方案方向成立,但 A1–A5 必须在实现前成为明确契约。全部 A、B、C 项均采纳;其中 A2、B2 和 B4 不只补充文字,还调整了原方案的数据流:
|
|
||||||
|
|
||||||
- durable Agent event 不进入普通 session 消息队列,而由 SQLite inbox 保存 payload、独立的合并式 wake lane 只传递“有待处理事件”的提示。
|
|
||||||
- `/stop` 清理瞬时用户工作,但不通过丢弃内存队列来确认 durable event;事件由条件更新立即释放,lease expiry 只作为崩溃兜底。
|
|
||||||
- 后台结果调度采用有界公平策略,UI 未读状态降为可观察性能力,不再承担防饥饿正确性。
|
|
||||||
- inbox 容量在接纳 background run 时预留终态事件空间;signal 可以因容量不足被拒绝,completion 不能在 run 结束时才发现无处落库。
|
|
||||||
- continuation 使用“持久化但对客户端隐藏”的内部触发消息,保证 Provider replay、事务提交和客户端渲染三者一致。
|
|
||||||
|
|
||||||
| 评审项 | 答复 | 设计处理 |
|
|
||||||
|--------|------|----------|
|
|
||||||
| A1 | 接受 | 独立 durable wake lane、claim-on-run、公平调度和有界重试 |
|
|
||||||
| A2 | 接受 | `/stop` 条件释放、lease guard、取消 completion 的 status-only 语义 |
|
|
||||||
| A3 | 接受 | run admission 与 step execution permit 分离 |
|
|
||||||
| A4 | 接受 | CancellationToken 贯穿 AgentLoop;明确 `/stop` 与 `steer` 不同 |
|
|
||||||
| A5 | 接受 | hidden trigger message、Turn origin、稳定 delivery binding |
|
|
||||||
| B1 | 接受 | 默认隔离;persistent browser profile 是唯一显式共享路径 |
|
|
||||||
| B2 | 接受 | 容量预留、dead-letter fallback、重试边界 |
|
|
||||||
| B3 | 接受 | `all` 与 `each` 的事件生成和 deadline 语义 |
|
|
||||||
| B4 | 接受 | worker 有界公平;UI 未读只负责呈现 |
|
|
||||||
| B5 | 接受 | 未配置价格时 `cost=NULL` |
|
|
||||||
| B6 | 接受 | sub-run sleep 只响应 timer/cancellation |
|
|
||||||
| C1 | 接受 | 非空 event key、非空 caller scope、partial unique index |
|
|
||||||
| C2 | 接受 | 深度、task-tree 授权、skills/memory 继承规则 |
|
|
||||||
| C3 | 接受 | 删除不存在的 `async` 迁移别名 |
|
|
||||||
| C4 | 接受 | 枚举 WebSocket 请求、投影和 Turn origin 变更 |
|
|
||||||
|
|
||||||
## 2. A 级意见答复
|
|
||||||
|
|
||||||
### A1 — Session 队列饱和语义
|
|
||||||
|
|
||||||
**答复:接受。durable event 不与用户 `AgentTask` 共用 payload mpsc。**
|
|
||||||
|
|
||||||
实现采用两条不同语义的 lane:
|
|
||||||
|
|
||||||
```text
|
|
||||||
user task lane bounded mpsc(32),保存任务;满时明确拒绝新用户输入
|
|
||||||
agent inbox wake lane watch revision,合并通知;payload 始终留在 SQLite
|
|
||||||
```
|
|
||||||
|
|
||||||
Router 对活动 Turn 的 `steer` 使用 lease → mailbox reservation → durable admitted → activate 的两阶段 admission;reservation 在 durable 更新成功前不可被 AgentLoop 排空,且 SQLite I/O 不跨 Session 锁。失败或 `queue` 事件都恢复/保持 `pending`,只递增 wake revision。worker 在真正准备执行 continuation 时才领取 lease,不先把已 leased 的事件塞进可能被丢弃的 mpsc。
|
|
||||||
|
|
||||||
`watch` 只负责降低延迟:发送失败、revision 被合并或进程退出都不影响事实状态。Gateway 启动、reload 激活和周期恢复扫描会重新发现 `pending`/expired lease。普通 session 队列满不再构成 durable event 丢失或无限重试问题。
|
|
||||||
|
|
||||||
worker 在每个 Turn 调度边界执行有界公平:通常先处理用户任务;连续处理 4 个用户 Turn,或最老 pending event 已等待 30 秒后,必须先领取一批 continuation。它仍不能中断当前不可分割的 Turn,因此时限从下一个调度边界计算。
|
|
||||||
|
|
||||||
### A2 — `/stop`、worker 退出与事件恢复
|
|
||||||
|
|
||||||
**答复:接受。lease expiry 只能是崩溃兜底,不能是正常 `/stop` 的唯一恢复路径。**
|
|
||||||
|
|
||||||
调整后的链路为:
|
|
||||||
|
|
||||||
1. `/stop` 在关闭 TurnMailbox 时取回尚未提交的 durable event IDs。
|
|
||||||
2. 在 session generation 失效后,以 `lease_token`/`admitted_turn_id` 条件更新把这些事件立即恢复为 `pending`。
|
|
||||||
3. continuation 执行持有 `InboxLeaseGuard`;正常失败、取消或 stale generation 会显式 release,只有进程崩溃或任务被强制 abort 才等待 `lease_until` 到期。
|
|
||||||
4. 普通内部 continuation 不作为 payload 存在 session mpsc 中,因此 `agent_tx.take()` 不会吞掉 leased event;worker 领取后才在本地构造 typed task source。
|
|
||||||
5. Coordinator 独立拥有 background run。`/stop` 取消 run token 后,Coordinator 仍负责用条件事务写入 `cancelled` 终态,迟到的 completed 结果不能覆盖它。
|
|
||||||
|
|
||||||
由本次 `/stop` 自身造成的 cancelled completion 设为 `requires_continuation=false`:事件和 run 状态会持久化并投影到任务树,但事件在同一事务中记为 status-only consumed,不会在 `/stop` 后反向启动一个“任务已取消”的主 Agent Turn。`/stop` 前已经存在、尚未处理的 signal/completion 不被确认或删除,恢复为 pending 后仍可继续投递。
|
|
||||||
|
|
||||||
### A3 — `waiting_children` permit 归属
|
|
||||||
|
|
||||||
**答复:接受。permit 不由整个 Agent Run 持有,也不由 `delegate` 等编排工具持有。**
|
|
||||||
|
|
||||||
Coordinator 管理两类限制:
|
|
||||||
|
|
||||||
- **run admission quota**:限制树、session、Agent 的已接纳/未终态 run 数量;可以跨 `waiting_children` 持有。
|
|
||||||
- **step execution permit**:限制当前正在占用 Provider 或普通工具执行资源的步骤;只在一个步骤期间持有。
|
|
||||||
|
|
||||||
AgentLoop 在每次 Provider 请求前按固定顺序获取 global → session → agent provider permits,流结束或取消后立即释放。普通工具调用由 tool executor 获取 tool permit;`delegate`、`agent_task`、`emit_signal` 等 runtime-control 工具不获取这种稀缺执行 permit。
|
|
||||||
|
|
||||||
foreground `delegate` 在创建 child 前通过状态 guard 把父 run 从 `running` 条件更新为 `waiting_children`,等待期间没有 provider/tool permit。children 终态后 guard 把父状态恢复为 `running`;父 Agent 的下一次模型迭代重新竞争 permit。这样即使 provider 并发上限为 1,父等待 child 也不会死锁。
|
|
||||||
|
|
||||||
### A4 — AgentLoop 结构化取消
|
|
||||||
|
|
||||||
**答复:接受,并把它提升为 Phase 2 的独立前置里程碑。**
|
|
||||||
|
|
||||||
`AgentLoop` 的执行入口将显式接收 cancellation context,而不是只依赖父 future 被 drop:
|
|
||||||
|
|
||||||
```text
|
|
||||||
root Turn token
|
|
||||||
└── foreground run token
|
|
||||||
└── descendant foreground run token
|
|
||||||
|
|
||||||
root session token ── independently owns background run tokens
|
|
||||||
```
|
|
||||||
|
|
||||||
Provider stream、可取消等待和工具批次外层都观察 token;AgentRunner 的终结路径把取消归一为类型化 `cancelled`,Coordinator 再用 execution ID 条件提交。父取消、run timeout、reload/shutdown 可以组合为任一触发即取消。
|
|
||||||
|
|
||||||
“不默认硬中断”只约束普通 `steer`:它等待安全边界,不取消 Provider 或副作用工具。`/stop` 保持现有强停止语义,会取消 token 并使 root Turn future 失效;未在宽限期内自行退出的独立 child task 由 Coordinator/Supervisor abort。无论 future 如何结束,terminal condition update 都阻止迟到结果提交。
|
|
||||||
|
|
||||||
### A5 — continuation Turn 模型
|
|
||||||
|
|
||||||
**答复:接受。continuation 需要同时满足 durable replay、无伪用户气泡和正常 Turn 投递。**
|
|
||||||
|
|
||||||
每个 continuation 生成一条内部触发消息:
|
|
||||||
|
|
||||||
- 数据库 role 使用 Provider 可兼容的 `user`,source 为 `agent_signal`/`agent_result`。
|
|
||||||
- 增加 `client_visibility=hidden` 和 `turn_origin=agent_continuation`;普通历史 API 和 `turn_committed.messages` 不投影这条消息。
|
|
||||||
- 内容是有界、带 event/run reference 的 runtime envelope,不伪造外部 sender,也不直接采用子 Agent 输出中的指令优先级。
|
|
||||||
- Provider 历史 replay 会包含该隐藏消息,因此后续 assistant 回复不会成为无来源的悬空历史。
|
|
||||||
- 隐藏触发消息、assistant/tool 结果、usage 和 inbox `consumed` 在同一事务中提交;失败时全部不确认。
|
|
||||||
|
|
||||||
客户端继续使用 `turn_updated`/`turn_committed` 展示 assistant Turn,但帧增加 `turn_origin`,从而可以显示“后台结果处理”标记且不创建用户气泡。
|
|
||||||
|
|
||||||
内部 Turn 的出站目标来自 root session 的 durable delivery binding:`channel`、`chat_id` 以及可复用的 thread/root 上下文。一次性 `reply_to` 不得复用。若没有可用的外部 binding,结果仍持久化并供 WebUI/TUI 历史读取,不猜测其他目标。
|
|
||||||
|
|
||||||
## 3. B 级意见答复
|
|
||||||
|
|
||||||
### B1 — browser/resource scope 隔离
|
|
||||||
|
|
||||||
**答复:接受。** 新具名 Agent 默认使用 `root_session_id + run_id` 的瞬时资源 scope,不继承父会话 browser cookie。需要共享登录态时,官方路径是由 Root 创建/选择经过校验的 `browser_profiles` persistent ID,并把该 ID 作为显式 task/artifact reference 交给获准使用 browser 的子 Agent;子 Agent必须在每次相关调用中显式传入该 ID。
|
|
||||||
|
|
||||||
为平滑迁移,由无 target 的旧调用映射出的内置 `general` 兼容 Agent 可在弃用期保留父 session transient scope;具名 Agent不继承这个例外。文档和 tool result 会明确提示两种 scope 的差异。
|
|
||||||
|
|
||||||
### B2 — dead letter、重试和 inbox 上限
|
|
||||||
|
|
||||||
**答复:接受。** 接纳 background run 时按 completion policy 预留不可抢占的终态 event slot:`each` 每个 run 一个,`all` 每个 group 一个。容量不足时 `delegate` 在创建 run 前拒绝。signal 只使用未预留容量,满时 `emit_signal` 返回 `inbox_full`,但不终止 run。因此已接纳 run 的 completion 永远不会在终结时因 inbox 满而丢失。
|
|
||||||
|
|
||||||
暂定恢复策略为 8 次可配置尝试,退避 `1s/5s/30s/2m/10m` 后封顶 10 分钟,并同时受 event TTL 限制。瞬时 Storage/worker/Provider continuation 失败可重试;session 已删除、授权事实失效或 payload 永久损坏立即 dead-letter。lease timeout 不单独计作永久错误,但会记录 attempt 和原因。
|
|
||||||
|
|
||||||
事件进入 dead-letter 后:
|
|
||||||
|
|
||||||
1. 保存最终原因和 `dead_lettered_at`,在任务树/API 中持续可见。
|
|
||||||
2. 通过 OutboundDispatcher 最多发送一次有界 system fallback,内容只包含 run ID、终态和查询提示,不复制大结果。
|
|
||||||
3. 用 `fallback_notified_at` 保证 fallback 幂等;渠道也失败时仍以 SQLite 记录和管理 UI 为最终可诊断出口。
|
|
||||||
|
|
||||||
### B3 — `completion_policy=each`
|
|
||||||
|
|
||||||
**答复:接受。** 语义修订为:
|
|
||||||
|
|
||||||
- `each`:每个 run 进入终态即创建独立 completion event;Router 可在 300–500ms debounce 窗口合并一次 continuation,但不能等待其他 sibling。
|
|
||||||
- `all`:单 run 终态只更新 group 计数,不创建可投递 completion;全部终态或 group deadline 到达后创建一个 `group_completion` event,包含全部逐项状态和 result references。
|
|
||||||
- group deadline 到达时,未终态 children 被取消并条件更新为 `timed_out`,随后生成唯一 group completion。
|
|
||||||
|
|
||||||
因此 inbox schema 允许 run-scoped 或 group-scoped event 二选一,而不是强制 `run_id NOT NULL`。
|
|
||||||
|
|
||||||
### B4 — UI 未读状态与防饥饿
|
|
||||||
|
|
||||||
**答复:接受。** 正确性由 A1 的 worker 有界公平策略保证;UI 未读只呈现尚未汇总/已 dead-letter 的事件数量,不参与调度。Phase 3 明确包含未读计数、event revision 和 reconnect 后全量校准。
|
|
||||||
|
|
||||||
### B5 — `cost` 与定价配置
|
|
||||||
|
|
||||||
**答复:接受。** Phase 2 保留 nullable `cost` 字段,但只有 Provider profile 明确提供 input/output/cache 价格时才计算;当前配置没有价格来源,因此写 `NULL`。usage token 仍照常持久化。价格配置和历史重算不属于本次编排功能的前置条件。
|
|
||||||
|
|
||||||
### B6 — sub-run 内 sleep
|
|
||||||
|
|
||||||
**答复:接受。** `TurnWakeupHandle` 只存在于 root interactive Turn。sub-run 的 `ToolExecutionContext.turn_wakeup=None`,其 `sleep` 只等待 timer、run cancellation、timeout 或 shutdown;不会监听 root session 用户输入或其他 Agent signal。需要被主 Agent立即控制时使用 `agent_task.cancel`,由 cancellation token 唤醒。
|
|
||||||
|
|
||||||
## 4. C 级意见答复
|
|
||||||
|
|
||||||
### C1 — SQLite NULL 与事件去重
|
|
||||||
|
|
||||||
**答复:接受。** 所有参与唯一约束的 scope/key 都规范化为非空值:
|
|
||||||
|
|
||||||
- background idempotency 使用 `caller_scope_id TEXT NOT NULL`;Root 固定为字面量 `ROOT`。
|
|
||||||
- `idempotency_key` 仍可为空,但使用 `CREATE UNIQUE INDEX ... WHERE idempotency_key IS NOT NULL` 的 partial unique index。
|
|
||||||
- 无 `dedupe_key` 的 signal 使用 `signal:<event_uuid>`。
|
|
||||||
- 有 `dedupe_key` 的 signal 使用 `signal:<normalized-key>:<cooldown-window-id>`,只在冷却窗口内去重,不会永久压制同类告警。
|
|
||||||
- run completion 使用固定 `completion:terminal-v1`;group completion 使用 `group-completion:terminal-v1`。
|
|
||||||
|
|
||||||
### C2 — 深度、task-tree 授权和上下文继承
|
|
||||||
|
|
||||||
**答复:接受。**
|
|
||||||
|
|
||||||
- 全局 `max_tree_depth` 是 root-relative 硬上限;Definition `limits.max_depth` 是该 Agent可继续创建的最大相对后代深度。child 的 remaining depth 为 `min(parent_remaining - 1, target_definition.max_depth)`,任何一项为 0 都不能继续委托。
|
|
||||||
- `agent_task` 查询必须匹配当前 `root_session_id`。Root 可操作本 session 的整棵树;子 Agent只能读取自身与后代,只能取消其未终态后代,不能通过猜测 run ID 跨 session 或操作祖先/sibling。
|
|
||||||
- 子 Agent不继承主会话 history、memory recall 或临时 activated skills。第一版仅在 Definition 工具集中包含 `get_skill` 时注入受信任 Skill catalog;调用方只能通过显式 task/context 传递事实。若未来开放 memory,必须新增管理员配置的只读 scope,不能默认继承。
|
|
||||||
|
|
||||||
### C3 — `async` 别名
|
|
||||||
|
|
||||||
**答复:接受。** 删除 `async → background`。迁移只接受代码中确实存在的 `inline`、`parallel`、`background`;新 prompt/schema 只公布 `foreground`、`background`。
|
|
||||||
|
|
||||||
### C4 — 客户端协议清单
|
|
||||||
|
|
||||||
**答复:接受。** 协议按通用运行投影设计,不为 Signal 单独复制一套模型:
|
|
||||||
|
|
||||||
- `WsInbound::GetAgentRuns { session_id, cursor, limit }`
|
|
||||||
- `WsInbound::GetAgentRun { session_id, run_id }`
|
|
||||||
- `WsOutbound::SessionAgentRuns { session_id, revision, runs, next_cursor }`
|
|
||||||
- `WsOutbound::AgentRunUpdated { session_id, revision, run }`
|
|
||||||
- `WsOutbound::AgentEventUpdated { session_id, revision, event }`
|
|
||||||
- `TurnSnapshot` 与 `WsOutbound::TurnCommitted` 增加 `turn_origin = user | agent_continuation | scheduled`
|
|
||||||
|
|
||||||
`AgentEventUpdated` 同时承载 accepted、admitted、consumed、dead-letter 等状态,按 `(session_id, revision, event_id)` 幂等合并。断线重连后客户端用 `GetAgentRuns` 全量校准,实时帧只是增量。取消操作第一版继续通过 `/stop`、`agent_task.cancel` 或受保护管理 API,不额外开放一个缺少权限上下文的裸 WebSocket cancel 帧。
|
|
||||||
|
|
||||||
## 5. 对分期的调整
|
|
||||||
|
|
||||||
| Phase | 调整后的完成条件 |
|
|
||||||
|-------|------------------|
|
|
||||||
| 1 | 除原内容外,明确 browser 兼容 scope、skills/memory 规则和 sub-run sleep 行为 |
|
|
||||||
| 2A | CancellationToken 贯穿 AgentLoop,先以现有 root Turn/sleep/Provider tests 锁定取消语义 |
|
|
||||||
| 2B | run 持久化、step execution gate、foreground child cancellation 和结果查询 |
|
|
||||||
| 3 | durable wake lane、capacity reservation、hidden continuation trigger、bounded fairness、dead-letter fallback 与 WebSocket run/event projection |
|
|
||||||
| 4 | typed TurnMailbox、emit_signal、steer admission,以及同一 `AgentEventUpdated` 的 Signal 卡片呈现 |
|
|
||||||
| 5 | root Turn wake-aware sleep;sub-run 保持 timer/cancellation-only |
|
|
||||||
|
|
||||||
Phase 3 上线时保留旧 direct notification 的受控兼容开关,但仅作为 rollback 手段,默认路径必须是 inbox/continuation;不能同时投递两条用户通知。开关移除前必须验证 dead-letter fallback、重启恢复和 reconnect 校准。
|
|
||||||
|
|
||||||
## 6. 最终结论
|
|
||||||
|
|
||||||
评审结论“方向通过,需修订后实现”成立。修订后的关键边界是:
|
|
||||||
|
|
||||||
```text
|
|
||||||
SQLite inbox 保存事实
|
|
||||||
├── direct steer admission → 当前 TurnMailbox
|
|
||||||
└── coalesced wake → worker claim → hidden continuation Turn
|
|
||||||
|
|
||||||
普通 user mpsc 满 ≠ durable event 丢失
|
|
||||||
/stop 丢弃瞬时用户工作 ≠ 确认 durable event
|
|
||||||
run 存活配额 ≠ Provider/tool step permit
|
|
||||||
UI 未读提示 ≠ 调度正确性
|
|
||||||
```
|
|
||||||
|
|
||||||
在 A1–A5 的专项契约和上述 schema/protocol 调整落地前,不应开始 Phase 3/4 的生产实现。
|
|
||||||
@ -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.
|
||||||
@ -9,7 +9,6 @@ tools:
|
|||||||
- content_search
|
- content_search
|
||||||
- web_fetch
|
- web_fetch
|
||||||
- calculator
|
- calculator
|
||||||
- sleep
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# General Purpose Agent
|
# General Purpose Agent
|
||||||
|
|||||||
@ -54,13 +54,11 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
|
|
||||||
## agent_orchestration 字段
|
## 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 目录 |
|
| `definitions_dir` | agents | 第一层 `*.md` Definition 目录 |
|
||||||
| `root_delegates` | [] | Root 可委托的具名 Agent ID |
|
|
||||||
| `max_tree_depth` | 4 | Root-relative 委托深度硬上限 |
|
| `max_tree_depth` | 4 | Root-relative 委托深度硬上限 |
|
||||||
| `max_runs_per_tree` | 16 | 单任务树 run 预算(树级原子计数强制) |
|
| `max_runs_per_tree` | 16 | 单任务树 run 预算(树级原子计数强制) |
|
||||||
| `max_concurrent_runs` / `max_concurrent_runs_per_session` | 6 / 4 | background run 接纳配额(global→session 顺序获取,runner 持有至 terminal commit;foreground 不占) |
|
| `max_concurrent_runs` / `max_concurrent_runs_per_session` | 6 / 4 | background run 接纳配额(global→session 顺序获取,runner 持有至 terminal commit;foreground 不占) |
|
||||||
|
|||||||
@ -137,7 +137,7 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
|
|||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
| 参数 | 必填 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `target` | 具名 Agent 必填 | `root_delegates` 或当前 Agent Definition 允许的目标 ID |
|
| `target` | 具名 Agent 必填 | 目标 Agent ID;主 Agent 可委托给任意具名子 Agent,子 Agent 按自身 Definition 的 `delegates` 白名单决定 |
|
||||||
| `task` | 单任务必填 | 明确、独立、可验收的子任务 |
|
| `task` | 单任务必填 | 明确、独立、可验收的子任务 |
|
||||||
| `context` | 否 | 子 Agent 所需的显式事实;不会继承完整主会话历史 |
|
| `context` | 否 | 子 Agent 所需的显式事实;不会继承完整主会话历史 |
|
||||||
| `mode` | 否 | `foreground`(默认)或 `background`;批量并发不是第三种 mode |
|
| `mode` | 否 | `foreground`(默认)或 `background`;批量并发不是第三种 mode |
|
||||||
@ -211,12 +211,6 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
|
|||||||
|
|
||||||
用于交互式程序和需要保持状态的长运行命令。`action` 支持 `spawn`、`write`、`read`、`kill`、`list`;`write/read/kill` 需要 `session_id`。Gateway 进程退出时 PTY manager 会清理子进程。
|
用于交互式程序和需要保持状态的长运行命令。`action` 支持 `spawn`、`write`、`read`、`kill`、`list`;`write/read/kill` 需要 `session_id`。Gateway 进程退出时 PTY manager 会清理子进程。
|
||||||
|
|
||||||
## sleep — 前台等待
|
|
||||||
|
|
||||||
参数 `seconds` 接受 0~86400 的整数。工具只暂停当前 Agent 工具调用,不持久化、不发送消息,也不保证跨进程重启继续;用户 `/stop`、Scheduler/SubAgent 超时和 Gateway shutdown 都会取消等待。超过 24 小时或需要可靠延迟执行时应使用 Scheduler。
|
|
||||||
|
|
||||||
主 Agent(root 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 / web_fetch — HTTP 和 Web 工具
|
||||||
|
|
||||||
`http_request` 支持 GET/POST/PUT/DELETE/PATCH、headers 和字符串 body;`web_fetch` 提取 HTML/JSON 的可读文本。两者校验 URL 与 DNS 解析结果,阻止回环、私网、link-local 和本地域名,并禁用自动重定向,以降低 SSRF 风险。
|
`http_request` 支持 GET/POST/PUT/DELETE/PATCH、headers 和字符串 body;`web_fetch` 提取 HTML/JSON 的可读文本。两者校验 URL 与 DNS 解析结果,阻止回环、私网、link-local 和本地域名,并禁用自动重定向,以降低 SSRF 风险。
|
||||||
|
|||||||
@ -47,9 +47,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"agent_orchestration": {
|
"agent_orchestration": {
|
||||||
"enabled": false,
|
|
||||||
"definitions_dir": "agents",
|
"definitions_dir": "agents",
|
||||||
"root_delegates": ["general-purpose"],
|
|
||||||
"max_tree_depth": 4,
|
"max_tree_depth": 4,
|
||||||
"max_runs_per_tree": 16,
|
"max_runs_per_tree": 16,
|
||||||
"max_concurrent_runs": 6,
|
"max_concurrent_runs": 6,
|
||||||
|
|||||||
@ -344,6 +344,10 @@ pub struct AgentLoop {
|
|||||||
context_window: usize,
|
context_window: usize,
|
||||||
input_types: Vec<String>,
|
input_types: Vec<String>,
|
||||||
media_registry: MediaHandlerRegistry,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AgentProcessResult {
|
pub struct AgentProcessResult {
|
||||||
@ -402,6 +406,7 @@ impl AgentLoop {
|
|||||||
model_name,
|
model_name,
|
||||||
input_types,
|
input_types,
|
||||||
media_registry: MediaHandlerRegistry::with_defaults(),
|
media_registry: MediaHandlerRegistry::with_defaults(),
|
||||||
|
transcript_sink: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -427,6 +432,7 @@ impl AgentLoop {
|
|||||||
model_name,
|
model_name,
|
||||||
input_types,
|
input_types,
|
||||||
media_registry: MediaHandlerRegistry::with_defaults(),
|
media_registry: MediaHandlerRegistry::with_defaults(),
|
||||||
|
transcript_sink: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -448,6 +454,7 @@ impl AgentLoop {
|
|||||||
model_name,
|
model_name,
|
||||||
input_types,
|
input_types,
|
||||||
media_registry: MediaHandlerRegistry::with_defaults(),
|
media_registry: MediaHandlerRegistry::with_defaults(),
|
||||||
|
transcript_sink: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -470,6 +477,7 @@ impl AgentLoop {
|
|||||||
model_name,
|
model_name,
|
||||||
input_types,
|
input_types,
|
||||||
media_registry: MediaHandlerRegistry::with_defaults(),
|
media_registry: MediaHandlerRegistry::with_defaults(),
|
||||||
|
transcript_sink: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -491,6 +499,26 @@ impl AgentLoop {
|
|||||||
self
|
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
|
/// Preemptive trim: truncate old tool results in-place when history is
|
||||||
/// approaching the context window limit. Old results (outside of `keep_recent`
|
/// approaching the context window limit. Old results (outside of `keep_recent`
|
||||||
/// zone) are replaced with a short placeholder; recent results are truncated
|
/// 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
|
/// user messages: the client renders the durable Signal projection, not
|
||||||
/// a user bubble, while the model still sees the envelope.
|
/// a user bubble, while the model still sees the envelope.
|
||||||
fn append_steering_messages(
|
fn append_steering_messages(
|
||||||
|
&self,
|
||||||
messages: &mut Vec<ChatMessage>,
|
messages: &mut Vec<ChatMessage>,
|
||||||
emitted_messages: &mut Vec<ChatMessage>,
|
emitted_messages: &mut Vec<ChatMessage>,
|
||||||
consumed_steering: &mut Vec<TurnInput>,
|
consumed_steering: &mut Vec<TurnInput>,
|
||||||
@ -689,7 +718,8 @@ impl AgentLoop {
|
|||||||
.into_chat_message(turn.turn_id.clone(), iteration);
|
.into_chat_message(turn.turn_id.clone(), iteration);
|
||||||
consumed_steering.push(input);
|
consumed_steering.push(input);
|
||||||
messages.push(message.clone());
|
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);
|
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
|
||||||
messages.push(assistant_message.clone());
|
messages.push(assistant_message.clone());
|
||||||
emitted_messages.push(assistant_message);
|
emitted_messages.push(assistant_message.clone());
|
||||||
Self::append_steering_messages(
|
self.forward_to_transcript_sink(&assistant_message);
|
||||||
|
self.append_steering_messages(
|
||||||
&mut messages,
|
&mut messages,
|
||||||
&mut emitted_messages,
|
&mut emitted_messages,
|
||||||
&mut consumed_steering,
|
&mut consumed_steering,
|
||||||
@ -968,6 +999,7 @@ impl AgentLoop {
|
|||||||
attach_reply_media(&mut assistant_message, &reply_media_refs);
|
attach_reply_media(&mut assistant_message, &reply_media_refs);
|
||||||
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true);
|
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true);
|
||||||
emitted_messages.push(assistant_message.clone());
|
emitted_messages.push(assistant_message.clone());
|
||||||
|
self.forward_to_transcript_sink(&assistant_message);
|
||||||
crate::observability::metrics::global_metrics().record_turn(
|
crate::observability::metrics::global_metrics().record_turn(
|
||||||
Some(&accumulated_usage),
|
Some(&accumulated_usage),
|
||||||
turn_start.elapsed().as_millis() as u64,
|
turn_start.elapsed().as_millis() as u64,
|
||||||
@ -1014,7 +1046,8 @@ impl AgentLoop {
|
|||||||
assistant_message.provider_state = response.provider_state;
|
assistant_message.provider_state = response.provider_state;
|
||||||
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
|
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
|
||||||
messages.push(assistant_message.clone());
|
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
|
// Execute tools and add results to messages
|
||||||
let tool_results = match self
|
let tool_results = match self
|
||||||
@ -1069,7 +1102,8 @@ impl AgentLoop {
|
|||||||
);
|
);
|
||||||
Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
|
Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
|
||||||
messages.push(tool_message.clone());
|
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 => {
|
LoopDetectionResult::Ok => {
|
||||||
let mut tool_message = ChatMessage::tool_with_media(
|
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);
|
Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
|
||||||
messages.push(tool_message.clone());
|
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 {
|
let Some(turn_context) = turn.as_ref() else {
|
||||||
unreachable!("steering messages require a turn context");
|
unreachable!("steering messages require a turn context");
|
||||||
};
|
};
|
||||||
Self::append_steering_messages(
|
self.append_steering_messages(
|
||||||
&mut messages,
|
&mut messages,
|
||||||
&mut emitted_messages,
|
&mut emitted_messages,
|
||||||
&mut consumed_steering,
|
&mut consumed_steering,
|
||||||
@ -1172,6 +1207,7 @@ impl AgentLoop {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
emitted_messages.push(assistant_message.clone());
|
emitted_messages.push(assistant_message.clone());
|
||||||
|
self.forward_to_transcript_sink(&assistant_message);
|
||||||
crate::observability::metrics::global_metrics().record_turn(
|
crate::observability::metrics::global_metrics().record_turn(
|
||||||
Some(&accumulated_usage),
|
Some(&accumulated_usage),
|
||||||
turn_start.elapsed().as_millis() as u64,
|
turn_start.elapsed().as_millis() as u64,
|
||||||
@ -1211,6 +1247,7 @@ impl AgentLoop {
|
|||||||
attach_reply_media(&mut final_message, &reply_media_refs);
|
attach_reply_media(&mut final_message, &reply_media_refs);
|
||||||
Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
|
Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
|
||||||
emitted_messages.push(final_message.clone());
|
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);
|
let turn_usage = (accumulated_usage.total_tokens > 0).then_some(&accumulated_usage);
|
||||||
crate::observability::metrics::global_metrics()
|
crate::observability::metrics::global_metrics()
|
||||||
.record_turn(turn_usage, turn_start.elapsed().as_millis() as u64);
|
.record_turn(turn_usage, turn_start.elapsed().as_millis() as u64);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@ -10,6 +10,12 @@ use crate::tools::ToolRegistry;
|
|||||||
|
|
||||||
use super::definition::{AgentDefinition, AgentDefinitionError, parse_definition};
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum AgentCatalogError {
|
pub enum AgentCatalogError {
|
||||||
#[error("invalid Agent orchestration config: {0}")]
|
#[error("invalid Agent orchestration config: {0}")]
|
||||||
@ -39,9 +45,7 @@ pub enum AgentCatalogError {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AgentCatalog {
|
pub struct AgentCatalog {
|
||||||
definitions: BTreeMap<String, Arc<AgentDefinition>>,
|
definitions: BTreeMap<String, Arc<AgentDefinition>>,
|
||||||
root_delegates: BTreeSet<String>,
|
|
||||||
runtime_generation: u64,
|
runtime_generation: u64,
|
||||||
enabled: bool,
|
|
||||||
max_tree_depth: u16,
|
max_tree_depth: u16,
|
||||||
max_runs_per_tree: usize,
|
max_runs_per_tree: usize,
|
||||||
}
|
}
|
||||||
@ -50,9 +54,7 @@ impl AgentCatalog {
|
|||||||
pub fn legacy() -> Self {
|
pub fn legacy() -> Self {
|
||||||
Self {
|
Self {
|
||||||
definitions: BTreeMap::new(),
|
definitions: BTreeMap::new(),
|
||||||
root_delegates: BTreeSet::new(),
|
|
||||||
runtime_generation: 0,
|
runtime_generation: 0,
|
||||||
enabled: false,
|
|
||||||
max_tree_depth: 4,
|
max_tree_depth: 4,
|
||||||
max_runs_per_tree: 16,
|
max_runs_per_tree: 16,
|
||||||
}
|
}
|
||||||
@ -71,9 +73,6 @@ impl AgentCatalog {
|
|||||||
runtime_generation: u64,
|
runtime_generation: u64,
|
||||||
) -> Result<Self, AgentCatalogError> {
|
) -> Result<Self, AgentCatalogError> {
|
||||||
config.validate().map_err(AgentCatalogError::Config)?;
|
config.validate().map_err(AgentCatalogError::Config)?;
|
||||||
if !config.enabled {
|
|
||||||
return Ok(Self::legacy());
|
|
||||||
}
|
|
||||||
|
|
||||||
let trusted_root = config_dir.canonicalize().map_err(|error| {
|
let trusted_root = config_dir.canonicalize().map_err(|error| {
|
||||||
AgentCatalogError::Directory(format!("{}: {error}", config_dir.display()))
|
AgentCatalogError::Directory(format!("{}: {error}", config_dir.display()))
|
||||||
@ -103,14 +102,12 @@ impl AgentCatalog {
|
|||||||
.map(|(name, _)| name)
|
.map(|(name, _)| name)
|
||||||
.collect();
|
.collect();
|
||||||
let mut definitions = BTreeMap::new();
|
let mut definitions = BTreeMap::new();
|
||||||
let mut disabled_ids = HashSet::new();
|
|
||||||
|
|
||||||
for path in paths {
|
for path in paths {
|
||||||
let spec = read_provider_spec(&path)?;
|
let spec = read_provider_spec(&path)?;
|
||||||
// Disabled definitions stay on disk for the management UI but
|
// Disabled definitions stay on disk for the management UI but
|
||||||
// never enter the active catalog.
|
// never enter the active catalog.
|
||||||
if !spec.enabled {
|
if !spec.enabled {
|
||||||
disabled_ids.insert(spec.id);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let provider =
|
let provider =
|
||||||
@ -144,7 +141,14 @@ impl AgentCatalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for definition in definitions.values() {
|
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) {
|
if !definitions.contains_key(target) {
|
||||||
return Err(AgentCatalogError::UnknownDelegate {
|
return Err(AgentCatalogError::UnknownDelegate {
|
||||||
agent: definition.id.clone(),
|
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 {
|
Ok(Self {
|
||||||
definitions,
|
definitions,
|
||||||
root_delegates,
|
|
||||||
runtime_generation,
|
runtime_generation,
|
||||||
enabled: true,
|
|
||||||
max_tree_depth: config.max_tree_depth,
|
max_tree_depth: config.max_tree_depth,
|
||||||
max_runs_per_tree: config.max_runs_per_tree,
|
max_runs_per_tree: config.max_runs_per_tree,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn enabled(&self) -> bool {
|
|
||||||
self.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn runtime_generation(&self) -> u64 {
|
pub fn runtime_generation(&self) -> u64 {
|
||||||
self.runtime_generation
|
self.runtime_generation
|
||||||
}
|
}
|
||||||
@ -199,21 +182,61 @@ impl AgentCatalog {
|
|||||||
self.definitions.get(id).cloned()
|
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 {
|
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 {
|
pub fn can_delegate(&self, caller: &str, target: &str) -> bool {
|
||||||
self.definitions
|
if caller == target {
|
||||||
.get(caller)
|
return false;
|
||||||
.is_some_and(|definition| definition.delegates.iter().any(|id| id == target))
|
}
|
||||||
|
let Some(definition) = self.definitions.get(caller) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match definition.delegates.as_deref() {
|
||||||
|
None => target == DEFAULT_DELEGATE && self.definitions.contains_key(target),
|
||||||
|
Some(list) if list.iter().any(|entry| entry == "*") => {
|
||||||
|
self.definitions.contains_key(target)
|
||||||
|
}
|
||||||
|
Some(list) => list.iter().any(|entry| entry == target),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn root_targets(&self) -> Vec<Arc<AgentDefinition>> {
|
/// Concrete delegation targets for `agent_id` after applying the
|
||||||
self.root_delegates
|
/// 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()
|
.iter()
|
||||||
.filter_map(|id| self.get(id))
|
.filter(|entry| entry.as_str() != agent_id)
|
||||||
.collect()
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every named Agent, exposed to the root `delegate` tool schema.
|
||||||
|
pub fn root_targets(&self) -> Vec<Arc<AgentDefinition>> {
|
||||||
|
self.definitions.values().cloned().collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,9 +265,9 @@ fn definition_paths(directory: &Path) -> Result<Vec<PathBuf>, AgentCatalogError>
|
|||||||
Ok(paths)
|
Ok(paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider/model/enabled fields read from a definition's frontmatter before
|
/// Provider/model fields read from a definition's frontmatter before the
|
||||||
/// the full definition is parsed, so the catalog can resolve the provider
|
/// full definition is parsed, so the catalog can resolve the provider config
|
||||||
/// config and skip disabled definitions in one pass.
|
/// and skip disabled definitions in one pass.
|
||||||
struct ProviderSpec {
|
struct ProviderSpec {
|
||||||
id: String,
|
id: String,
|
||||||
llm_profile: Option<String>,
|
llm_profile: Option<String>,
|
||||||
@ -423,14 +446,14 @@ mod tests {
|
|||||||
|
|
||||||
fn config() -> AgentOrchestrationConfig {
|
fn config() -> AgentOrchestrationConfig {
|
||||||
AgentOrchestrationConfig {
|
AgentOrchestrationConfig {
|
||||||
enabled: true,
|
|
||||||
definitions_dir: "agents".to_string(),
|
definitions_dir: "agents".to_string(),
|
||||||
root_delegates: vec!["researcher".to_string()],
|
|
||||||
..Default::default()
|
..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(|| {
|
let tools = (!tools.is_empty()).then(|| {
|
||||||
format!(
|
format!(
|
||||||
"tools:\n{}\n",
|
"tools:\n{}\n",
|
||||||
@ -441,15 +464,25 @@ mod tests {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
let delegates = (!delegates.is_empty()).then(|| {
|
let delegates = delegates.map(|delegates| {
|
||||||
|
if delegates.is_empty() {
|
||||||
|
"delegates: []\n".to_string()
|
||||||
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"delegates:\n{}\n",
|
"delegates:\n{}\n",
|
||||||
delegates
|
delegates
|
||||||
.iter()
|
.iter()
|
||||||
.map(|name| format!(" - {name}"))
|
.map(|name| {
|
||||||
|
if *name == "*" {
|
||||||
|
" - \"*\"".to_string()
|
||||||
|
} else {
|
||||||
|
format!(" - {name}")
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n")
|
.join("\n")
|
||||||
)
|
)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
root.join("agents").join(format!("{id}.md")),
|
root.join("agents").join(format!("{id}.md")),
|
||||||
@ -466,8 +499,8 @@ mod tests {
|
|||||||
fn catalog_loads_provider_tools_and_delegation_graph() {
|
fn catalog_loads_provider_tools_and_delegation_graph() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
std::fs::create_dir(root.path().join("agents")).unwrap();
|
std::fs::create_dir(root.path().join("agents")).unwrap();
|
||||||
write_agent(root.path(), "researcher", &["calculator"], &["reviewer"]);
|
write_agent(root.path(), "researcher", &["calculator"], Some(&["reviewer"]));
|
||||||
write_agent(root.path(), "reviewer", &["calculator"], &[]);
|
write_agent(root.path(), "reviewer", &["calculator"], None);
|
||||||
let tools = ToolRegistry::new();
|
let tools = ToolRegistry::new();
|
||||||
tools.register(CalculatorTool::new());
|
tools.register(CalculatorTool::new());
|
||||||
let loader = SkillsLoader::new_for_testing(
|
let loader = SkillsLoader::new_for_testing(
|
||||||
@ -498,13 +531,72 @@ mod tests {
|
|||||||
assert_eq!(catalog.runtime_generation(), 7);
|
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]
|
#[test]
|
||||||
fn catalog_accepts_any_ordinary_tool_but_rejects_runtime_injected() {
|
fn catalog_accepts_any_ordinary_tool_but_rejects_runtime_injected() {
|
||||||
// Ordinary tools (including side-effecting ones like file_write) are
|
// Ordinary tools (including side-effecting ones like file_write) are
|
||||||
// now accepted purely by the definition file.
|
// now accepted purely by the definition file.
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
std::fs::create_dir(root.path().join("agents")).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();
|
let tools = ToolRegistry::new();
|
||||||
tools.register(crate::tools::FileWriteTool::new());
|
tools.register(crate::tools::FileWriteTool::new());
|
||||||
let loader = SkillsLoader::new_for_testing(
|
let loader = SkillsLoader::new_for_testing(
|
||||||
@ -529,7 +621,7 @@ mod tests {
|
|||||||
// definition's `tools` list; get_skill remains the one exception.
|
// definition's `tools` list; get_skill remains the one exception.
|
||||||
let root2 = tempfile::tempdir().unwrap();
|
let root2 = tempfile::tempdir().unwrap();
|
||||||
std::fs::create_dir(root2.path().join("agents")).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();
|
let tools2 = ToolRegistry::new();
|
||||||
tools2.register(GetSkillTool::new(Arc::new(
|
tools2.register(GetSkillTool::new(Arc::new(
|
||||||
crate::skills::SkillsLoader::new_for_testing(
|
crate::skills::SkillsLoader::new_for_testing(
|
||||||
|
|||||||
@ -1151,9 +1151,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let profiles = HashMap::from([("research".to_string(), provider_config())]);
|
let profiles = HashMap::from([("research".to_string(), provider_config())]);
|
||||||
let config = crate::config::AgentOrchestrationConfig {
|
let config = crate::config::AgentOrchestrationConfig {
|
||||||
enabled: true,
|
|
||||||
definitions_dir: "agents".to_string(),
|
definitions_dir: "agents".to_string(),
|
||||||
root_delegates: vec!["researcher".to_string()],
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
AgentCatalog::load(
|
AgentCatalog::load(
|
||||||
@ -1200,14 +1198,12 @@ mod tests {
|
|||||||
let notifier = crate::agent::AgentInboxNotifier::new();
|
let notifier = crate::agent::AgentInboxNotifier::new();
|
||||||
let supervisor = crate::task_supervisor::TaskSupervisor::new();
|
let supervisor = crate::task_supervisor::TaskSupervisor::new();
|
||||||
let orchestration = crate::config::AgentOrchestrationConfig {
|
let orchestration = crate::config::AgentOrchestrationConfig {
|
||||||
enabled: true,
|
|
||||||
max_pending_inbox_events_per_session: max_pending,
|
max_pending_inbox_events_per_session: max_pending,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let gate = match max_concurrent_runs {
|
let gate = match max_concurrent_runs {
|
||||||
Some(limit) => {
|
Some(limit) => {
|
||||||
let config = crate::config::AgentOrchestrationConfig {
|
let config = crate::config::AgentOrchestrationConfig {
|
||||||
enabled: true,
|
|
||||||
max_concurrent_runs: limit,
|
max_concurrent_runs: limit,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@ -1726,7 +1722,6 @@ mod tests {
|
|||||||
let notifier = crate::agent::AgentInboxNotifier::new();
|
let notifier = crate::agent::AgentInboxNotifier::new();
|
||||||
let supervisor = crate::task_supervisor::TaskSupervisor::new();
|
let supervisor = crate::task_supervisor::TaskSupervisor::new();
|
||||||
let orchestration = crate::config::AgentOrchestrationConfig {
|
let orchestration = crate::config::AgentOrchestrationConfig {
|
||||||
enabled: true,
|
|
||||||
max_pending_inbox_events_per_session: 1,
|
max_pending_inbox_events_per_session: 1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@ -220,13 +220,17 @@ pub struct AgentFrontmatter {
|
|||||||
pub token_limit: Option<usize>,
|
pub token_limit: Option<usize>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub max_tool_iterations: Option<usize>,
|
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.
|
/// Disabled definitions stay on disk but never load into the catalog.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
/// Which Agents this one may further delegate to. `None` (field absent)
|
||||||
pub tools: Vec<String>,
|
/// defaults to the built-in `general-purpose`; `Some([])` forbids further
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
/// delegation; a list containing `*` allows any other Agent; an explicit
|
||||||
pub delegates: Vec<String>,
|
/// 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")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub skills: Vec<String>,
|
pub skills: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -249,7 +253,7 @@ pub struct AgentDefinition {
|
|||||||
pub provider_config: Arc<LLMProviderConfig>,
|
pub provider_config: Arc<LLMProviderConfig>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub tools: Vec<String>,
|
pub tools: Vec<String>,
|
||||||
pub delegates: Vec<String>,
|
pub delegates: Option<Vec<String>>,
|
||||||
pub skills: Vec<String>,
|
pub skills: Vec<String>,
|
||||||
pub limits: AgentLimits,
|
pub limits: AgentLimits,
|
||||||
pub signal_contract: Option<SignalContract>,
|
pub signal_contract: Option<SignalContract>,
|
||||||
@ -427,7 +431,9 @@ fn read_frontmatter(path: &Path) -> Result<(AgentFrontmatter, String), AgentDefi
|
|||||||
signal.validate()?;
|
signal.validate()?;
|
||||||
}
|
}
|
||||||
reject_duplicates("tools", &frontmatter.tools)?;
|
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)?;
|
reject_duplicates("skills", &frontmatter.skills)?;
|
||||||
let file_stem = path.file_stem().and_then(|value| value.to_str());
|
let file_stem = path.file_stem().and_then(|value| value.to_str());
|
||||||
if file_stem != Some(frontmatter.id.as_str()) {
|
if file_stem != Some(frontmatter.id.as_str()) {
|
||||||
@ -551,4 +557,41 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(parse_definition(&path, provider()).is_err());
|
assert!(parse_definition(&path, provider()).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delegates_round_trip_preserves_all_forms() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let path = directory.path().join("researcher.md");
|
||||||
|
for (label, delegates) in [
|
||||||
|
("unset", None),
|
||||||
|
("empty", Some(Vec::<String>::new())),
|
||||||
|
("any", Some(vec!["*".to_string()])),
|
||||||
|
(
|
||||||
|
"list",
|
||||||
|
Some(vec!["coder".to_string(), "reviewer".to_string()]),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let info = AgentDefinitionInfo {
|
||||||
|
frontmatter: AgentFrontmatter {
|
||||||
|
id: "researcher".to_string(),
|
||||||
|
description: "test".to_string(),
|
||||||
|
llm_profile: None,
|
||||||
|
provider: Some("openai".to_string()),
|
||||||
|
model: Some("m".to_string()),
|
||||||
|
token_limit: None,
|
||||||
|
max_tool_iterations: None,
|
||||||
|
tools: Vec::new(),
|
||||||
|
enabled: true,
|
||||||
|
delegates: delegates.clone(),
|
||||||
|
skills: Vec::new(),
|
||||||
|
limits: AgentLimits::default(),
|
||||||
|
signal: None,
|
||||||
|
},
|
||||||
|
role_prompt: "# Role\n\nwork".to_string(),
|
||||||
|
};
|
||||||
|
std::fs::write(&path, serialize_definition(&info)).unwrap();
|
||||||
|
let parsed = parse_definition_info(&path).unwrap();
|
||||||
|
assert_eq!(parsed.frontmatter.delegates, delegates, "{label}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -224,7 +224,6 @@ mod tests {
|
|||||||
/// is global -> session by design).
|
/// is global -> session by design).
|
||||||
fn gate(provider_session: usize, tool_session: usize) -> Arc<ExecutionGate> {
|
fn gate(provider_session: usize, tool_session: usize) -> Arc<ExecutionGate> {
|
||||||
let config = AgentOrchestrationConfig {
|
let config = AgentOrchestrationConfig {
|
||||||
enabled: true,
|
|
||||||
max_concurrent_runs: 8,
|
max_concurrent_runs: 8,
|
||||||
max_concurrent_runs_per_session: 1,
|
max_concurrent_runs_per_session: 1,
|
||||||
max_concurrent_provider_steps: 8,
|
max_concurrent_provider_steps: 8,
|
||||||
|
|||||||
@ -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;
|
/// How the input reached the mailbox. Queue inputs belong to the next Turn;
|
||||||
/// only Steer entries are drained by the active Turn.
|
/// only Steer entries are drained by the active Turn.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
@ -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
|
let definition = self
|
||||||
.catalog
|
.catalog
|
||||||
.get(target)
|
.get(target)
|
||||||
@ -302,13 +297,14 @@ impl SubAgentManager {
|
|||||||
} else {
|
} else {
|
||||||
None
|
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(|| {
|
let delegate = self.full_tools.get("delegate").ok_or_else(|| {
|
||||||
SubAgentError::Other("delegate runtime tool is unavailable".to_string())
|
SubAgentError::Other("delegate runtime tool is unavailable".to_string())
|
||||||
})?;
|
})?;
|
||||||
runtime_tools.push(Arc::new(crate::tools::delegate::ScopedDelegateTool::new(
|
runtime_tools.push(Arc::new(crate::tools::delegate::ScopedDelegateTool::new(
|
||||||
delegate,
|
delegate,
|
||||||
definition.delegates.clone(),
|
delegate_targets,
|
||||||
)) as Arc<dyn crate::tools::Tool>);
|
)) as Arc<dyn crate::tools::Tool>);
|
||||||
}
|
}
|
||||||
// The signal tool is contract-bound: it exists only when the
|
// The signal tool is contract-bound: it exists only when the
|
||||||
@ -435,9 +431,12 @@ impl SubAgentManager {
|
|||||||
let mut effective_config = config.clone();
|
let mut effective_config = config.clone();
|
||||||
effective_config.max_iterations = Some(resolved.max_iterations);
|
effective_config.max_iterations = Some(resolved.max_iterations);
|
||||||
let max_result_chars = resolved.max_result_chars;
|
let max_result_chars = resolved.max_result_chars;
|
||||||
|
|
||||||
|
let (transcript_tx, transcript_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let agent = self
|
let agent = self
|
||||||
.build_sub_agent_with_provider(&effective_config, tools, &resolved.provider_config)
|
.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![
|
let history = vec![
|
||||||
ChatMessage::system(system_prompt),
|
ChatMessage::system(system_prompt),
|
||||||
@ -447,29 +446,32 @@ impl SubAgentManager {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let tool_context = resolved.tool_context;
|
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(
|
result = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(timeout_secs),
|
std::time::Duration::from_secs(timeout_secs),
|
||||||
agent.process_with_context(history, tool_context.clone()),
|
agent.process_with_context(history, tool_context.clone()),
|
||||||
) => result,
|
) => match result {
|
||||||
_ = tool_context.cancellation.cancelled() => {
|
Ok(inner) => ExecutionOutcome::Finished(Box::new(inner)),
|
||||||
return Ok(SubAgentResult {
|
Err(_elapsed) => ExecutionOutcome::TimedOut,
|
||||||
task_id: task_id.to_string(),
|
},
|
||||||
content: String::new(),
|
_ = tool_context.cancellation.cancelled() => ExecutionOutcome::Cancelled,
|
||||||
content_truncated: false,
|
|
||||||
full_content: String::new(),
|
|
||||||
status: TaskStatus::Cancelled,
|
|
||||||
tool_calls_count: 0,
|
|
||||||
iterations: 0,
|
|
||||||
duration_ms: start.elapsed().as_millis() as u64,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let duration_ms = start.elapsed().as_millis() as u64;
|
let duration_ms = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
Ok(match result {
|
// Drop the agent (which owns the transcript sender) so the writer can
|
||||||
Ok(Ok(agent_result)) => {
|
// 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(
|
let (content, truncated) = truncate_sub_agent_result_at(
|
||||||
&agent_result.final_response.content,
|
&agent_result.final_response.content,
|
||||||
max_result_chars,
|
max_result_chars,
|
||||||
@ -495,7 +497,7 @@ impl SubAgentManager {
|
|||||||
duration_ms,
|
duration_ms,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Err(error)) => SubAgentResult {
|
Err(error) => SubAgentResult {
|
||||||
task_id: task_id.to_string(),
|
task_id: task_id.to_string(),
|
||||||
content: String::new(),
|
content: String::new(),
|
||||||
content_truncated: false,
|
content_truncated: false,
|
||||||
@ -505,7 +507,8 @@ impl SubAgentManager {
|
|||||||
iterations: 0,
|
iterations: 0,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
},
|
},
|
||||||
Err(_elapsed) => SubAgentResult {
|
},
|
||||||
|
ExecutionOutcome::TimedOut => SubAgentResult {
|
||||||
task_id: task_id.to_string(),
|
task_id: task_id.to_string(),
|
||||||
content: String::new(),
|
content: String::new(),
|
||||||
content_truncated: false,
|
content_truncated: false,
|
||||||
@ -515,8 +518,64 @@ impl SubAgentManager {
|
|||||||
iterations: 0,
|
iterations: 0,
|
||||||
duration_ms,
|
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 {
|
fn terminal_status_from_error(error: AgentError) -> TaskStatus {
|
||||||
@ -628,7 +687,7 @@ mod tests {
|
|||||||
Err(error) => error,
|
Err(error) => error,
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
matches!(error, SubAgentError::Other(message) if message.contains("orchestration"))
|
matches!(error, SubAgentError::Other(message) if message.contains("unknown Agent target"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -181,9 +181,7 @@ fn default_token_limit() -> usize {
|
|||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct AgentOrchestrationConfig {
|
pub struct AgentOrchestrationConfig {
|
||||||
pub enabled: bool,
|
|
||||||
pub definitions_dir: String,
|
pub definitions_dir: String,
|
||||||
pub root_delegates: Vec<String>,
|
|
||||||
pub max_tree_depth: u16,
|
pub max_tree_depth: u16,
|
||||||
pub max_runs_per_tree: usize,
|
pub max_runs_per_tree: usize,
|
||||||
pub max_concurrent_runs: usize,
|
pub max_concurrent_runs: usize,
|
||||||
@ -202,12 +200,7 @@ pub struct AgentOrchestrationConfig {
|
|||||||
impl Default for AgentOrchestrationConfig {
|
impl Default for AgentOrchestrationConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
|
||||||
definitions_dir: "agents".to_string(),
|
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_tree_depth: 4,
|
||||||
max_runs_per_tree: 16,
|
max_runs_per_tree: 16,
|
||||||
max_concurrent_runs: 6,
|
max_concurrent_runs: 6,
|
||||||
@ -227,9 +220,6 @@ impl Default for AgentOrchestrationConfig {
|
|||||||
|
|
||||||
impl AgentOrchestrationConfig {
|
impl AgentOrchestrationConfig {
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
if !self.enabled {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let positive = [
|
let positive = [
|
||||||
("max_tree_depth", usize::from(self.max_tree_depth)),
|
("max_tree_depth", usize::from(self.max_tree_depth)),
|
||||||
("max_runs_per_tree", self.max_runs_per_tree),
|
("max_runs_per_tree", self.max_runs_per_tree),
|
||||||
@ -1151,7 +1141,6 @@ mod tests {
|
|||||||
25 * 1024 * 1024
|
25 * 1024 * 1024
|
||||||
);
|
);
|
||||||
assert!(config.browser.enabled);
|
assert!(config.browser.enabled);
|
||||||
assert!(!config.agent_orchestration.enabled);
|
|
||||||
let browser: BrowserConfig = serde_json::from_str("{}").unwrap();
|
let browser: BrowserConfig = serde_json::from_str("{}").unwrap();
|
||||||
assert!(browser.enabled);
|
assert!(browser.enabled);
|
||||||
assert!(
|
assert!(
|
||||||
@ -1165,13 +1154,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn orchestration_config_enforces_hierarchical_limits() {
|
fn orchestration_config_enforces_hierarchical_limits() {
|
||||||
let valid = AgentOrchestrationConfig {
|
let valid = AgentOrchestrationConfig {
|
||||||
enabled: true,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert!(valid.validate().is_ok());
|
assert!(valid.validate().is_ok());
|
||||||
|
|
||||||
let invalid = AgentOrchestrationConfig {
|
let invalid = AgentOrchestrationConfig {
|
||||||
enabled: true,
|
|
||||||
max_concurrent_runs: 1,
|
max_concurrent_runs: 1,
|
||||||
max_concurrent_runs_per_session: 2,
|
max_concurrent_runs_per_session: 2,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
@ -1082,7 +1082,16 @@ fn agent_info_from_json(
|
|||||||
.map(|v| v as usize),
|
.map(|v| v as usize),
|
||||||
enabled: body.get("enabled").and_then(Value::as_bool).unwrap_or(true),
|
enabled: body.get("enabled").and_then(Value::as_bool).unwrap_or(true),
|
||||||
tools: string_array(body, "tools"),
|
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"),
|
skills: string_array(body, "skills"),
|
||||||
limits: body
|
limits: body
|
||||||
.get("limits")
|
.get("limits")
|
||||||
@ -1206,9 +1215,20 @@ pub async fn get_agent_run(
|
|||||||
let Some(run) = run else {
|
let Some(run) = run else {
|
||||||
return Err(ApiError::not_found(format!("run {id} not found")));
|
return Err(ApiError::not_found(format!("run {id} not found")));
|
||||||
};
|
};
|
||||||
Ok(Json(
|
let session_id = run.root_session_id.clone();
|
||||||
json!({ "run": crate::protocol::AgentRunView::from_record(&run, 100_000) }),
|
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(
|
pub async fn get_agent_run_events(
|
||||||
|
|||||||
@ -173,8 +173,7 @@ impl GatewayState {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
let health = Arc::new(crate::health::HealthService::new(config.clone()));
|
let health = Arc::new(crate::health::HealthService::new(config.clone()));
|
||||||
let provider_profiles = if config.agent_orchestration.enabled {
|
let provider_profiles: std::collections::HashMap<String, _> = config
|
||||||
config
|
|
||||||
.agents
|
.agents
|
||||||
.keys()
|
.keys()
|
||||||
.filter_map(|name| {
|
.filter_map(|name| {
|
||||||
@ -183,10 +182,7 @@ impl GatewayState {
|
|||||||
.ok()
|
.ok()
|
||||||
.map(|profile| (name.clone(), profile))
|
.map(|profile| (name.clone(), profile))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
} else {
|
|
||||||
std::collections::HashMap::new()
|
|
||||||
};
|
|
||||||
let config_dir = config_path
|
let config_dir = config_path
|
||||||
.parent()
|
.parent()
|
||||||
.unwrap_or_else(|| std::path::Path::new("."))
|
.unwrap_or_else(|| std::path::Path::new("."))
|
||||||
|
|||||||
@ -98,6 +98,48 @@ pub struct AgentEventView {
|
|||||||
pub created_at: i64,
|
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 {
|
impl AgentRunView {
|
||||||
pub fn from_record(
|
pub fn from_record(
|
||||||
record: &crate::storage::agent_run::AgentRunRecord,
|
record: &crate::storage::agent_run::AgentRunRecord,
|
||||||
|
|||||||
@ -663,8 +663,6 @@ struct ActiveTurnEmitter {
|
|||||||
/// `AgentTurnContext`; keeping it on the session handle makes admission
|
/// `AgentTurnContext`; keeping it on the session handle makes admission
|
||||||
/// atomic with `/stop` and worker cleanup.
|
/// atomic with `/stop` and worker cleanup.
|
||||||
steering: Arc<TurnMailbox>,
|
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
|
/// Original inbound tasks for accepted steering messages. ChatMessage
|
||||||
/// intentionally carries only durable history fields, so this side map
|
/// intentionally carries only durable history fields, so this side map
|
||||||
/// preserves channel context and rich MediaItem metadata if a terminal
|
/// preserves channel context and rich MediaItem metadata if a terminal
|
||||||
@ -692,11 +690,7 @@ struct AgentTask {
|
|||||||
fn steer_input_from_event(
|
fn steer_input_from_event(
|
||||||
event: &crate::storage::agent_inbox::AgentInboxEventRecord,
|
event: &crate::storage::agent_inbox::AgentInboxEventRecord,
|
||||||
now: i64,
|
now: i64,
|
||||||
) -> (
|
) -> TurnInput {
|
||||||
TurnInput,
|
|
||||||
crate::agent::steering::WakeupSource,
|
|
||||||
Option<String>,
|
|
||||||
) {
|
|
||||||
use crate::agent::steering::InputDelivery;
|
use crate::agent::steering::InputDelivery;
|
||||||
use crate::storage::agent_inbox::AgentEventType;
|
use crate::storage::agent_inbox::AgentEventType;
|
||||||
let payload: serde_json::Value =
|
let payload: serde_json::Value =
|
||||||
@ -707,7 +701,7 @@ fn steer_input_from_event(
|
|||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
let (source, wakeup_preview, content) = match event.event_type {
|
let (source, content) = match event.event_type {
|
||||||
AgentEventType::Signal => {
|
AgentEventType::Signal => {
|
||||||
let severity = event.severity.clone().unwrap_or_else(|| "info".to_string());
|
let severity = event.severity.clone().unwrap_or_else(|| "info".to_string());
|
||||||
let summary = payload
|
let summary = payload
|
||||||
@ -729,7 +723,6 @@ fn steer_input_from_event(
|
|||||||
run_id: run_id.clone(),
|
run_id: run_id.clone(),
|
||||||
agent_id: agent_id.clone(),
|
agent_id: agent_id.clone(),
|
||||||
},
|
},
|
||||||
(!summary.is_empty()).then_some(summary),
|
|
||||||
content,
|
content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -748,12 +741,11 @@ fn steer_input_from_event(
|
|||||||
run_id: run_id.clone(),
|
run_id: run_id.clone(),
|
||||||
agent_id: agent_id.clone(),
|
agent_id: agent_id.clone(),
|
||||||
},
|
},
|
||||||
None,
|
|
||||||
content,
|
content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let input = TurnInput {
|
TurnInput {
|
||||||
id: format!("steer:{}", event.id),
|
id: format!("steer:{}", event.id),
|
||||||
sequence: 0,
|
sequence: 0,
|
||||||
source,
|
source,
|
||||||
@ -764,9 +756,7 @@ fn steer_input_from_event(
|
|||||||
received_at: now,
|
received_at: now,
|
||||||
message_source: None,
|
message_source: None,
|
||||||
lease_token: Some(event.lease_token.clone().unwrap_or_default()),
|
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
|
/// Move terminally pending steering into the worker's local FIFO. The
|
||||||
@ -1297,7 +1287,6 @@ impl Session {
|
|||||||
turn_id: turn_id.to_string(),
|
turn_id: turn_id.to_string(),
|
||||||
emitter,
|
emitter,
|
||||||
steering: TurnMailbox::new_shared(),
|
steering: TurnMailbox::new_shared(),
|
||||||
wakeup: crate::agent::steering::TurnWakeupPublisher::new(),
|
|
||||||
recovery: StdArc::new(StdMutex::new(HashMap::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}")))?,
|
.map_err(|error| AgentError::Other(format!("failed to load Agent catalog: {error}")))?,
|
||||||
);
|
);
|
||||||
let execution_gate = if catalog_preparation.config.enabled {
|
let execution_gate = crate::agent::gate::ExecutionGate::new(&catalog_preparation.config);
|
||||||
crate::agent::gate::ExecutionGate::new(&catalog_preparation.config)
|
|
||||||
} else {
|
|
||||||
crate::agent::gate::ExecutionGate::unbounded()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create SubAgentManager and register DelegateTool
|
// Create SubAgentManager and register DelegateTool
|
||||||
let sub_agent_manager = Arc::new(
|
let sub_agent_manager = Arc::new(
|
||||||
@ -1989,7 +1974,6 @@ impl SessionManager {
|
|||||||
let mut delegate_tool = crate::tools::DelegateTool::new(sub_agent_manager.clone());
|
let mut delegate_tool = crate::tools::DelegateTool::new(sub_agent_manager.clone());
|
||||||
let inbox_notifier = crate::agent::AgentInboxNotifier::new();
|
let inbox_notifier = crate::agent::AgentInboxNotifier::new();
|
||||||
let agent_projection_hub = Arc::new(crate::agent::AgentProjectionHub::new());
|
let agent_projection_hub = Arc::new(crate::agent::AgentProjectionHub::new());
|
||||||
let agent_coordinator = if agent_catalog.enabled() {
|
|
||||||
let coordinator = crate::agent::AgentCoordinator::new(
|
let coordinator = crate::agent::AgentCoordinator::new(
|
||||||
storage.clone(),
|
storage.clone(),
|
||||||
sub_agent_manager.clone(),
|
sub_agent_manager.clone(),
|
||||||
@ -2005,10 +1989,7 @@ impl SessionManager {
|
|||||||
tools.register(crate::tools::AgentTaskTool::new(coordinator.clone()));
|
tools.register(crate::tools::AgentTaskTool::new(coordinator.clone()));
|
||||||
delegate_tool = delegate_tool.with_coordinator(coordinator.clone());
|
delegate_tool = delegate_tool.with_coordinator(coordinator.clone());
|
||||||
sub_agent_manager.bind_coordinator(&coordinator);
|
sub_agent_manager.bind_coordinator(&coordinator);
|
||||||
Some(coordinator)
|
let agent_coordinator = Some(coordinator);
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
tools.register(delegate_tool);
|
tools.register(delegate_tool);
|
||||||
tools.register(crate::tools::ReloadConfigTool::new(reload.clone()));
|
tools.register(crate::tools::ReloadConfigTool::new(reload.clone()));
|
||||||
|
|
||||||
@ -3296,21 +3277,6 @@ impl SessionManager {
|
|||||||
);
|
);
|
||||||
match active.steering.try_push_user(input) {
|
match active.steering.try_push_user(input) {
|
||||||
Ok(()) => {
|
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);
|
return Ok(HandleResult::AgentProcessing);
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@ -3411,24 +3377,6 @@ impl SessionManager {
|
|||||||
AgentError::Other("agent worker spawn+send failed irrecoverably".to_string())
|
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)
|
Ok(HandleResult::AgentProcessing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3780,12 +3728,10 @@ fn spawn_agent_worker(
|
|||||||
let initial_turn = turn_controller.snapshot();
|
let initial_turn = turn_controller.snapshot();
|
||||||
let steering = TurnMailbox::new_shared();
|
let steering = TurnMailbox::new_shared();
|
||||||
let recovery = StdArc::new(StdMutex::new(HashMap::new()));
|
let recovery = StdArc::new(StdMutex::new(HashMap::new()));
|
||||||
let turn_wakeup = crate::agent::steering::TurnWakeupPublisher::new();
|
|
||||||
guard.active_turn_emitter = Some(ActiveTurnEmitter {
|
guard.active_turn_emitter = Some(ActiveTurnEmitter {
|
||||||
turn_id: initial_turn.id.0.clone(),
|
turn_id: initial_turn.id.0.clone(),
|
||||||
emitter: turn_emitter.clone(),
|
emitter: turn_emitter.clone(),
|
||||||
steering: steering.clone(),
|
steering: steering.clone(),
|
||||||
wakeup: turn_wakeup,
|
|
||||||
recovery: recovery.clone(),
|
recovery: recovery.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -3984,24 +3930,14 @@ fn spawn_agent_worker(
|
|||||||
let pending_turn_deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
|
let pending_turn_deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
let scoped_turn_deliveries = pending_turn_deliveries.clone();
|
let scoped_turn_deliveries = pending_turn_deliveries.clone();
|
||||||
let steering_for_process = steering.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 process_gate = execution_gate.clone();
|
||||||
let turn_token_for_process = turn_token.clone();
|
let turn_token_for_process = turn_token.clone();
|
||||||
let process_future = async move {
|
let process_future = async move {
|
||||||
let response_session_id = unified_str2.clone();
|
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_turn_id(agent_turn.turn_id.clone())
|
||||||
.with_cancellation(turn_token_for_process)
|
.with_cancellation(turn_token_for_process)
|
||||||
.with_execution_gate(process_gate.clone());
|
.with_execution_gate(process_gate.clone());
|
||||||
if let Some(handle) = wakeup_handle {
|
|
||||||
tool_context = tool_context.with_turn_wakeup(handle);
|
|
||||||
}
|
|
||||||
let process_result = agent
|
let process_result = agent
|
||||||
.process_streaming_with_context(
|
.process_streaming_with_context(
|
||||||
history_out.clone(),
|
history_out.clone(),
|
||||||
@ -4725,25 +4661,8 @@ impl crate::agent::AgentInboxWakeTarget for SessionManager {
|
|||||||
// admission; everything that cannot be admitted stays pending
|
// admission; everything that cannot be admitted stays pending
|
||||||
// for the queue lane.
|
// for the queue lane.
|
||||||
if has_active_turn {
|
if has_active_turn {
|
||||||
let outcome = self
|
self.try_steer_inbox_events(&session, session_id, revision)
|
||||||
.try_steer_inbox_events(&session, session_id, revision)
|
|
||||||
.await;
|
.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;
|
let mut guard = session.lock().await;
|
||||||
@ -4823,13 +4742,11 @@ impl SessionManager {
|
|||||||
};
|
};
|
||||||
let mut rejected = Vec::new();
|
let mut rejected = Vec::new();
|
||||||
let mut reserved = Vec::new();
|
let mut reserved = Vec::new();
|
||||||
let mut wakeup_sources = Vec::new();
|
|
||||||
for event in &lease.events {
|
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()) {
|
match mailbox.try_reserve_steer(input, token.clone()) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
reserved.push(event.id.clone());
|
reserved.push(event.id.clone());
|
||||||
wakeup_sources.push((wakeup_source, preview));
|
|
||||||
}
|
}
|
||||||
Err(_) => rejected.push((event.id.clone(), token.clone())),
|
Err(_) => rejected.push((event.id.clone(), token.clone())),
|
||||||
}
|
}
|
||||||
@ -4862,24 +4779,6 @@ impl SessionManager {
|
|||||||
};
|
};
|
||||||
if still_active {
|
if still_active {
|
||||||
mailbox.activate_reserved();
|
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;
|
return SteerAdmission::Activated;
|
||||||
}
|
}
|
||||||
rejected.extend(admitted);
|
rejected.extend(admitted);
|
||||||
@ -5033,7 +4932,7 @@ impl SessionManager {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod slash_command_tests {
|
mod slash_command_tests {
|
||||||
use super::{
|
use super::{
|
||||||
AgentTask, SLASH_COMMANDS, Session, pop_lowest_sequence, prepend_pending_steering,
|
AgentTask, SLASH_COMMANDS, pop_lowest_sequence, prepend_pending_steering,
|
||||||
resolve_slash_command,
|
resolve_slash_command,
|
||||||
};
|
};
|
||||||
use crate::agent::steering::{SteeringPushError, TurnInput, TurnMailbox};
|
use crate::agent::steering::{SteeringPushError, TurnInput, TurnMailbox};
|
||||||
@ -5219,92 +5118,4 @@ mod slash_command_tests {
|
|||||||
assert!(mailbox.is_closed());
|
assert!(mailbox.is_closed());
|
||||||
assert!(mailbox.take_pending().is_empty());
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -600,8 +600,8 @@ mod tests {
|
|||||||
.emit(TurnEvent::ToolStarted {
|
.emit(TurnEvent::ToolStarted {
|
||||||
iteration: 0,
|
iteration: 0,
|
||||||
call: ToolCall {
|
call: ToolCall {
|
||||||
id: "sleep-call".into(),
|
id: "long-call".into(),
|
||||||
name: "sleep".into(),
|
name: "long_tool".into(),
|
||||||
arguments: serde_json::json!({"seconds": 60}),
|
arguments: serde_json::json!({"seconds": 60}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -617,7 +617,7 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
status: ToolStatus::Cancelled,
|
status: ToolStatus::Cancelled,
|
||||||
..
|
..
|
||||||
} if id == "sleep-call"
|
} if id == "long-call"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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_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)",
|
"CREATE INDEX IF NOT EXISTS idx_agent_runs_recovery ON agent_runs(runtime_generation, status, deadline_at)",
|
||||||
r#"
|
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 (
|
CREATE TABLE IF NOT EXISTS agent_session_state (
|
||||||
root_session_id TEXT PRIMARY KEY,
|
root_session_id TEXT PRIMARY KEY,
|
||||||
revision INTEGER NOT NULL DEFAULT 0,
|
revision INTEGER NOT NULL DEFAULT 0,
|
||||||
@ -226,6 +242,23 @@ pub struct AgentRunRecord {
|
|||||||
pub updated_at: i64,
|
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`.
|
/// One run to admit inside `accept_agent_runs`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewAgentRun {
|
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 {
|
impl super::Storage {
|
||||||
/// Admit a batch of runs in one transaction, claiming any referenced
|
/// Admit a batch of runs in one transaction, claiming any referenced
|
||||||
/// plan items atomically. If any plan item was already taken the whole
|
/// 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)`.
|
/// 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.
|
/// The cursor is the pair of the last row the client has seen.
|
||||||
pub async fn list_agent_runs(
|
pub async fn list_agent_runs(
|
||||||
@ -1061,17 +1167,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 (storage, _dir) = create_test_storage().await;
|
||||||
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
|
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
|
||||||
.fetch_one(storage.pool())
|
.fetch_one(storage.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(version, 8);
|
assert_eq!(version, 9);
|
||||||
for table in [
|
for table in [
|
||||||
"agent_runs",
|
"agent_runs",
|
||||||
"agent_session_state",
|
"agent_session_state",
|
||||||
"agent_inbox_events",
|
"agent_inbox_events",
|
||||||
|
"agent_run_messages",
|
||||||
] {
|
] {
|
||||||
let exists: i64 = sqlx::query_scalar(
|
let exists: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||||
@ -1371,4 +1478,47 @@ mod tests {
|
|||||||
.unwrap()
|
.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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ use sqlx::{Pool, Row, Sqlite};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
const SCHEMA_VERSION: i64 = 8;
|
const SCHEMA_VERSION: i64 = 9;
|
||||||
const INSERT_MESSAGE_SQL: &str = r#"
|
const INSERT_MESSAGE_SQL: &str = r#"
|
||||||
INSERT INTO messages (
|
INSERT INTO messages (
|
||||||
id, session_id, seq, role, content, reasoning_content, provider_state,
|
id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||||
@ -395,16 +395,25 @@ impl Storage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
// Legacy table removed in schema v7; drop it so old databases do not
|
// The legacy drops below are a pre-v8 rebuild concern: the batch
|
||||||
// keep dead rows around.
|
// "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")
|
sqlx::query("DROP TABLE IF EXISTS background_tasks")
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
// Schema v8 removes the batch "group" concept entirely: the
|
// Schema v8 removes the batch "group" concept entirely: the
|
||||||
// `agent_run_groups` table is gone, and the run/inbox tables are
|
// `agent_run_groups` table is gone, and the run/inbox tables are
|
||||||
// rebuilt without their `group_id`/`scope_kind`/`scope_id` columns.
|
// rebuilt without their `group_id`/`scope_kind`/`scope_id` columns.
|
||||||
// Drop in dependency order (inbox -> runs -> groups) so foreign-key
|
// Drop the transcript table before runs and the remaining tables in
|
||||||
// enforcement never blocks the implicit row delete.
|
// 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")
|
sqlx::query("DROP TABLE IF EXISTS agent_inbox_events")
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
@ -414,6 +423,7 @@ impl Storage {
|
|||||||
sqlx::query("DROP TABLE IF EXISTS agent_run_groups")
|
sqlx::query("DROP TABLE IF EXISTS agent_run_groups")
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
for (table, column, definition) in [
|
for (table, column, definition) in [
|
||||||
("messages", "source", "source TEXT"),
|
("messages", "source", "source TEXT"),
|
||||||
("messages", "reasoning_content", "reasoning_content 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]
|
#[tokio::test]
|
||||||
async fn test_upsert_and_get_session() {
|
async fn test_upsert_and_get_session() {
|
||||||
let (storage, _dir) = create_test_storage().await;
|
let (storage, _dir) = create_test_storage().await;
|
||||||
|
|||||||
@ -23,7 +23,6 @@ pub mod registry;
|
|||||||
pub mod reload_config;
|
pub mod reload_config;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod send_message;
|
pub mod send_message;
|
||||||
pub mod sleep;
|
|
||||||
pub mod todo;
|
pub mod todo;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
pub mod web_fetch;
|
pub mod web_fetch;
|
||||||
@ -49,12 +48,10 @@ pub use pty::{PtyManager, PtyTool};
|
|||||||
pub use registry::ToolRegistry;
|
pub use registry::ToolRegistry;
|
||||||
pub use reload_config::ReloadConfigTool;
|
pub use reload_config::ReloadConfigTool;
|
||||||
pub use send_message::SendMessageTool;
|
pub use send_message::SendMessageTool;
|
||||||
pub use sleep::SleepTool;
|
|
||||||
pub use todo::TodoTool;
|
pub use todo::TodoTool;
|
||||||
pub use traits::{
|
pub use traits::{
|
||||||
InputInterruptPolicy, OutboundDelivery, OutboundMessenger, ProcessedToolOutput, Tool,
|
OutboundDelivery, OutboundMessenger, ProcessedToolOutput, Tool, ToolArtifact,
|
||||||
ToolArtifact, ToolArtifactAudience, ToolExecutionContext, ToolOutput, ToolOutputProcessor,
|
ToolArtifactAudience, ToolExecutionContext, ToolOutput, ToolOutputProcessor, ToolResult,
|
||||||
ToolResult,
|
|
||||||
};
|
};
|
||||||
pub use web_fetch::WebFetchTool;
|
pub use web_fetch::WebFetchTool;
|
||||||
|
|
||||||
@ -80,7 +77,6 @@ pub fn create_default_tools(
|
|||||||
) -> anyhow::Result<ToolRegistry> {
|
) -> anyhow::Result<ToolRegistry> {
|
||||||
let registry = ToolRegistry::new();
|
let registry = ToolRegistry::new();
|
||||||
registry.register(CalculatorTool::new());
|
registry.register(CalculatorTool::new());
|
||||||
registry.register(SleepTool::new());
|
|
||||||
registry.register(FileReadTool::new());
|
registry.register(FileReadTool::new());
|
||||||
registry.register(FileWriteTool::new());
|
registry.register(FileWriteTool::new());
|
||||||
registry.register(FileEditTool::new());
|
registry.register(FileEditTool::new());
|
||||||
|
|||||||
@ -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 AgentSignal(run_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 AgentCompletion(run_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("排队输入"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -10,10 +10,6 @@ pub struct ToolExecutionContext {
|
|||||||
pub agent: Option<std::sync::Arc<crate::agent::AgentExecutionContext>>,
|
pub agent: Option<std::sync::Arc<crate::agent::AgentExecutionContext>>,
|
||||||
pub cancellation: tokio_util::sync::CancellationToken,
|
pub cancellation: tokio_util::sync::CancellationToken,
|
||||||
pub execution_gate: Option<std::sync::Arc<crate::agent::gate::ExecutionGate>>,
|
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 {
|
impl Default for ToolExecutionContext {
|
||||||
@ -24,7 +20,6 @@ impl Default for ToolExecutionContext {
|
|||||||
agent: None,
|
agent: None,
|
||||||
cancellation: tokio_util::sync::CancellationToken::new(),
|
cancellation: tokio_util::sync::CancellationToken::new(),
|
||||||
execution_gate: None,
|
execution_gate: None,
|
||||||
turn_wakeup: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -37,7 +32,6 @@ impl ToolExecutionContext {
|
|||||||
agent: None,
|
agent: None,
|
||||||
cancellation: tokio_util::sync::CancellationToken::new(),
|
cancellation: tokio_util::sync::CancellationToken::new(),
|
||||||
execution_gate: None,
|
execution_gate: None,
|
||||||
turn_wakeup: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,18 +60,6 @@ impl ToolExecutionContext {
|
|||||||
self.execution_gate = Some(gate);
|
self.execution_gate = Some(gate);
|
||||||
self
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
@ -221,11 +203,6 @@ pub trait Tool: Send + Sync + 'static {
|
|||||||
false
|
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
|
/// Execute the tool through the unified output envelope. Most tools return
|
||||||
/// only text and use this default conversion.
|
/// only text and use this default conversion.
|
||||||
async fn execute_output(&self, args: serde_json::Value) -> anyhow::Result<ToolOutput> {
|
async fn execute_output(&self, args: serde_json::Value) -> anyhow::Result<ToolOutput> {
|
||||||
|
|||||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"version": "1.11.0",
|
"version": "1.13.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"version": "1.11.0",
|
"version": "1.13.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bits-ui": "^2.0.0",
|
"bits-ui": "^2.0.0",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.4.12",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.11.0",
|
"version": "1.13.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@ -21,10 +21,10 @@
|
|||||||
{ name: "chat", label: "聊天", description: "与 PicoBot 协作并管理会话" },
|
{ name: "chat", label: "聊天", description: "与 PicoBot 协作并管理会话" },
|
||||||
{ name: "overview", label: "概览", description: "查看运行状态与系统容量" },
|
{ name: "overview", label: "概览", description: "查看运行状态与系统容量" },
|
||||||
{ name: "tools", label: "工具", description: "浏览工具、Skills 与 MCP 连接" },
|
{ name: "tools", label: "工具", description: "浏览工具、Skills 与 MCP 连接" },
|
||||||
{ name: "agents", label: "子代理", description: "管理具名子代理定义" },
|
{ name: "agents", label: "子代理", description: "查看活动中的子代理与历史运行" },
|
||||||
{ name: "logs", label: "日志", description: "检查实时事件与运行记录" },
|
{ name: "logs", label: "日志", description: "检查实时事件与运行记录" },
|
||||||
{ name: "memory", label: "记忆", description: "查找和维护长期记忆" },
|
{ name: "memory", label: "记忆", description: "查找和维护长期记忆" },
|
||||||
{ name: "tasks", label: "任务", description: "跟踪定时任务与后台工作" },
|
{ name: "tasks", label: "任务", description: "管理定时任务" },
|
||||||
{ name: "settings", label: "配置", description: "管理 Gateway 与 Agent 配置" }
|
{ name: "settings", label: "配置", description: "管理 Gateway 与 Agent 配置" }
|
||||||
];
|
];
|
||||||
const icons = {
|
const icons = {
|
||||||
@ -153,7 +153,7 @@
|
|||||||
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
|
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
|
||||||
{:else if current === "overview"}<OverviewPage />
|
{:else if current === "overview"}<OverviewPage />
|
||||||
{:else if current === "tools"}<ToolsPage />
|
{: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}
|
{:else}<div class="empty-card">即将上线</div>{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -43,6 +43,8 @@
|
|||||||
<path d="m5.25 7.5 4.75 4.75 4.75-4.75" />
|
<path d="m5.25 7.5 4.75 4.75 4.75-4.75" />
|
||||||
{:else if name === "panel"}
|
{: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" />
|
<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}
|
{/if}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
|||||||
393
webui/src/lib/components/SubAgentDefinitions.svelte
Normal file
393
webui/src/lib/components/SubAgentDefinitions.svelte
Normal 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 你是一名…"></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>
|
||||||
@ -1,41 +1,31 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { api } from "../lib/api.js";
|
import { api, formatTime } from "../lib/api.js";
|
||||||
import Icon from "../lib/Icon.svelte";
|
import Icon from "../lib/Icon.svelte";
|
||||||
import StatusBadge from "../lib/StatusBadge.svelte";
|
import StatusBadge from "../lib/StatusBadge.svelte";
|
||||||
|
import Markdown from "../lib/Markdown.svelte";
|
||||||
|
import ToolCallCard from "../lib/ToolCallCard.svelte";
|
||||||
|
|
||||||
let agents = $state([]);
|
let tasks = $state([]);
|
||||||
let options = $state({ providers: [], models: [], tools: [], skills: [] });
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let editing = $state(null);
|
let tick = $state(0);
|
||||||
let saving = $state(false);
|
|
||||||
let { notify } = $props();
|
|
||||||
|
|
||||||
const blank = () => ({
|
let selected = $state(null);
|
||||||
id: "",
|
let detail = $state(null);
|
||||||
description: "",
|
let detailError = $state("");
|
||||||
provider: "",
|
let detailTimer = null;
|
||||||
model: "",
|
|
||||||
token_limit: null,
|
|
||||||
max_tool_iterations: null,
|
|
||||||
tools: [],
|
|
||||||
skills: [],
|
|
||||||
delegates: [],
|
|
||||||
role_prompt: "",
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function load() {
|
const activeStatuses = ["queued", "running", "waiting_children"];
|
||||||
loading = true;
|
|
||||||
error = "";
|
function isActive(status) {
|
||||||
|
return activeStatuses.includes(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
try {
|
try {
|
||||||
const [a, o] = await Promise.all([
|
tasks = (await api("/api/tasks?limit=200")).tasks || [];
|
||||||
api("/api/agents"),
|
error = "";
|
||||||
api("/api/agents/options"),
|
|
||||||
]);
|
|
||||||
agents = a.agents || [];
|
|
||||||
options = o;
|
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
error = caught.message;
|
error = caught.message;
|
||||||
} finally {
|
} finally {
|
||||||
@ -43,167 +33,240 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTool(list, name) {
|
function stopDetailPoll() {
|
||||||
const i = list.indexOf(name);
|
if (detailTimer) {
|
||||||
if (i >= 0) list.splice(i, 1);
|
clearInterval(detailTimer);
|
||||||
else list.push(name);
|
detailTimer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startNew() {
|
async function loadDetail() {
|
||||||
editing = blank();
|
if (!selected) return;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const [runRes, eventsRes] = await Promise.all([
|
||||||
id: editing.id,
|
api(`/api/agent-runs/${encodeURIComponent(selected)}`),
|
||||||
description: editing.description,
|
api(`/api/agent-runs/${encodeURIComponent(selected)}/events?limit=200`),
|
||||||
provider: editing.provider || null,
|
]);
|
||||||
model: editing.model || null,
|
detail = {
|
||||||
token_limit: editing.token_limit,
|
run: runRes.run,
|
||||||
max_tool_iterations: editing.max_tool_iterations,
|
session_id: runRes.session_id,
|
||||||
tools: editing.tools,
|
transcript: runRes.transcript || [],
|
||||||
skills: editing.skills,
|
events: eventsRes.events || [],
|
||||||
delegates: editing.delegates,
|
|
||||||
role_prompt: editing.role_prompt,
|
|
||||||
enabled: editing.enabled,
|
|
||||||
};
|
};
|
||||||
await api("/api/agents", { method: "POST", body: JSON.stringify(payload) });
|
detailError = "";
|
||||||
editing = null;
|
if (!isActive(runRes.run.status)) stopDetailPoll();
|
||||||
notify("子代理已保存(需重载配置生效)");
|
|
||||||
await load();
|
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
notify(caught.message, true);
|
detailError = caught.message;
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
await api("/api/agents", {
|
const payload = JSON.parse(event.payload_json);
|
||||||
method: "POST",
|
return payload.summary || payload.status || event.payload_json.slice(0, 200);
|
||||||
body: JSON.stringify({
|
} catch {
|
||||||
id: agent.id,
|
return event.payload_json.slice(0, 200);
|
||||||
description: agent.description || "",
|
}
|
||||||
provider: agent.provider || null,
|
}
|
||||||
model: agent.model || null,
|
|
||||||
token_limit: agent.token_limit ?? null,
|
onMount(() => {
|
||||||
max_tool_iterations: agent.max_tool_iterations ?? null,
|
loadTasks();
|
||||||
tools: agent.tools || [],
|
const listTimer = setInterval(loadTasks, 5000);
|
||||||
skills: agent.skills || [],
|
const tickTimer = setInterval(() => (tick += 1), 1000);
|
||||||
delegates: agent.delegates || [],
|
return () => {
|
||||||
role_prompt: agent.role_prompt || "",
|
clearInterval(listTimer);
|
||||||
enabled: !agent.enabled,
|
clearInterval(tickTimer);
|
||||||
}),
|
stopDetailPoll();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
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);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="page active content-page">
|
<section class="page active content-page">
|
||||||
|
{#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>
|
||||||
|
|
||||||
|
{#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="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>
|
||||||
|
|
||||||
|
{#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 class="toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="margin:0">具名子代理</h2>
|
<h2 style="margin:0">子代理活动</h2>
|
||||||
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">
|
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">
|
||||||
子代理由 <code>~/.picobot/agents/*.md</code> 定义;工具、Skill、Provider 与模型在此直接指定。改动需热重载后生效。
|
查看活动中的子代理与历史运行;子代理定义在「配置 → 子代理」中管理。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="primary" onclick={startNew}><Icon name="add" size={16} />新增子代理</button>
|
<button class="secondary" onclick={loadTasks}><Icon name="refresh" size={16} />刷新</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">加载中…</div>
|
<div class="loading">加载中…</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="empty-card error-text">{error}</div>
|
<div class="empty-card error-text">{error}</div>
|
||||||
{:else if agents.length === 0}
|
|
||||||
<div class="empty-card">暂无子代理定义</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if tasks.some((t) => isActive(t.status))}
|
||||||
|
<h3 class="section-label">活动中</h3>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
{#each agents as agent (agent.id)}
|
{#each tasks.filter((t) => isActive(t.status)) as task (task.id)}
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-row">
|
<div class="card-row">
|
||||||
<div>
|
<div class="task-main">
|
||||||
<h3>{agent.id} <StatusBadge status={agent.enabled ? "enabled" : "disabled"} /></h3>
|
<div class="run-row">
|
||||||
<p>{agent.description}</p>
|
<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">
|
<div class="meta">
|
||||||
<span>provider: {agent.provider || agent.llm_profile || "—"}</span>
|
<span>mode: {task.mode}</span>
|
||||||
<span>model: {agent.model || "—"}</span>
|
<span>depth: {task.depth}</span>
|
||||||
{#if agent.tools?.length}<span>{agent.tools.length} 个工具</span>{/if}
|
<span class="elapsed">已运行 {elapsed(task.started_at || task.created_at)}</span>
|
||||||
{#if agent.skills?.length}<span>{agent.skills.length} 个 Skill</span>{/if}
|
|
||||||
{#if agent.delegates?.length}<span>委托: {agent.delegates.join(", ")}</span>{/if}
|
|
||||||
</div>
|
</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>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class="secondary" onclick={() => editAgent(agent)}><Icon name="more" size={15} />编辑</button>
|
<button class="secondary" onclick={() => openDetail(task)}><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>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@ -211,96 +274,49 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if editing}
|
<h3 class="section-label">历史活动</h3>
|
||||||
<button type="button" class="modal-scrim" onclick={cancelEdit} aria-label="关闭" tabindex="-1"></button>
|
<div class="cards">
|
||||||
<div class="modal" role="dialog" aria-label="编辑子代理">
|
{#each tasks.filter((t) => !isActive(t.status)) as task (task.id)}
|
||||||
<div class="editor-head">
|
<article class="card">
|
||||||
<div><strong>{editing.id ? `编辑 ${editing.id}` : "新增子代理"}</strong><small>保存后需热重载配置生效</small></div>
|
<div class="card-row">
|
||||||
<button class="icon-button" aria-label="关闭" onclick={cancelEdit}><Icon name="dismiss" size={18} /></button>
|
<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>
|
||||||
<div class="agent-form">
|
<div class="meta">
|
||||||
<div class="form-row">
|
<span>{formatTime(task.created_at)}</span>
|
||||||
<label>ID
|
<span>{task.tool_calls_count} 次工具调用 · {task.iterations} 轮</span>
|
||||||
<input bind:value={editing.id} placeholder="general-purpose" disabled={!!agents.find((a) => a.id === editing.id)} spellcheck="false" />
|
{#if task.started_at && task.finished_at}<span>耗时 {durationBetween(task.started_at, task.finished_at)}</span>{/if}
|
||||||
</label>
|
|
||||||
<label>描述
|
|
||||||
<input bind:value={editing.description} placeholder="通用目的子代理…" />
|
|
||||||
</label>
|
|
||||||
</div>
|
</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>
|
||||||
<div class="form-row">
|
<div class="card-actions">
|
||||||
<label>token_limit
|
<button class="secondary" onclick={() => openDetail(task)}><Icon name="more" size={15} />详情</button>
|
||||||
<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>
|
||||||
|
</div>
|
||||||
<div class="form-label">工具 <small>(普通工具可直接启用;delegate / emit_signal / agent_task 由运行上下文注入)</small></div>
|
</article>
|
||||||
<div class="tag-row selectable">
|
{:else}
|
||||||
{#each options.tools as tool (tool.name)}
|
<div class="empty-card">暂无历史活动</div>
|
||||||
<button class="tag pick" class:picked={editing.tools.includes(tool.name)} title={tool.description} onclick={() => toggleTool(editing.tools, tool.name)}>{tool.name}</button>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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 你是一名…"></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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
.detail-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||||
.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); }
|
.detail-title { display: flex; align-items: center; gap: 10px; }
|
||||||
.tag-row.selectable .tag { cursor: pointer; user-select: none; }
|
.detail-title .mono { font-size: 13px; color: var(--text-soft); }
|
||||||
.tag-row.selectable .tag.picked { border-color: var(--accent-border); color: var(--accent); background: var(--accent-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; }
|
.card-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
.icon-button.danger:hover { color: var(--danger); border-color: var(--danger-border); }
|
.mono { font-family: var(--font-mono); }
|
||||||
.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); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { Tabs } from "bits-ui";
|
import { Tabs } from "bits-ui";
|
||||||
import { api } from "../lib/api.js";
|
import { api } from "../lib/api.js";
|
||||||
import AppearanceSettings from "../lib/components/AppearanceSettings.svelte";
|
import AppearanceSettings from "../lib/components/AppearanceSettings.svelte";
|
||||||
|
import SubAgentDefinitions from "../lib/components/SubAgentDefinitions.svelte";
|
||||||
|
|
||||||
let { notify } = $props();
|
let { notify } = $props();
|
||||||
let tab = $state("appearance");
|
let tab = $state("appearance");
|
||||||
@ -16,6 +17,7 @@
|
|||||||
|
|
||||||
const isConfig = $derived(tab === "config");
|
const isConfig = $derived(tab === "config");
|
||||||
const isAppearance = $derived(tab === "appearance");
|
const isAppearance = $derived(tab === "appearance");
|
||||||
|
const isSubagents = $derived(tab === "subagents");
|
||||||
const title = $derived(tab === "config" ? "运行配置" : tab === "user" ? "用户档案" : tab === "agents" ? "Agent 行为准则" : "页面外观");
|
const title = $derived(tab === "config" ? "运行配置" : tab === "user" ? "用户档案" : tab === "agents" ? "Agent 行为准则" : "页面外观");
|
||||||
const isDirty = $derived(content !== original);
|
const isDirty = $derived(content !== original);
|
||||||
|
|
||||||
@ -34,7 +36,7 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
if (isAppearance) { loading = false; return; }
|
if (isAppearance || isSubagents) { loading = false; return; }
|
||||||
try {
|
try {
|
||||||
if (isConfig) {
|
if (isConfig) {
|
||||||
const result = await api("/api/config");
|
const result = await api("/api/config");
|
||||||
@ -119,12 +121,16 @@
|
|||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<Tabs.Root value={tab} onValueChange={changeTab} orientation="vertical">
|
<Tabs.Root value={tab} onValueChange={changeTab} orientation="vertical">
|
||||||
<Tabs.List class="settings-nav" aria-label="设置分类">
|
<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.List>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
|
|
||||||
{#if isAppearance}
|
{#if isAppearance}
|
||||||
<AppearanceSettings />
|
<AppearanceSettings />
|
||||||
|
{:else if isSubagents}
|
||||||
|
<div class="subagents-pane">
|
||||||
|
<SubAgentDefinitions {notify} />
|
||||||
|
</div>
|
||||||
{:else if isConfig}
|
{:else if isConfig}
|
||||||
<div class="config-layout">
|
<div class="config-layout">
|
||||||
<div class="editor-card">
|
<div class="editor-card">
|
||||||
@ -188,6 +194,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.subagents-pane { min-width: 0; }
|
||||||
.config-layout { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 18px; align-items: start; }
|
.config-layout { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 18px; align-items: start; }
|
||||||
.config-sidebar { display: grid; gap: 14px; }
|
.config-sidebar { display: grid; gap: 14px; }
|
||||||
.sidebar-panel { padding: 14px; }
|
.sidebar-panel { padding: 14px; }
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { Tabs } from "bits-ui";
|
|
||||||
import { api, formatTime } from "../lib/api.js";
|
import { api, formatTime } from "../lib/api.js";
|
||||||
import StatusBadge from "../lib/StatusBadge.svelte";
|
import StatusBadge from "../lib/StatusBadge.svelte";
|
||||||
import Icon from "../lib/Icon.svelte";
|
import Icon from "../lib/Icon.svelte";
|
||||||
|
|
||||||
let tab = $state("scheduled");
|
|
||||||
let jobs = $state([]);
|
let jobs = $state([]);
|
||||||
let tasks = $state([]);
|
|
||||||
let runs = $state({});
|
let runs = $state({});
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
@ -17,15 +14,11 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = "";
|
error = "";
|
||||||
try {
|
try {
|
||||||
if (tab === "background") {
|
|
||||||
tasks = (await api("/api/tasks?limit=200")).tasks;
|
|
||||||
} else {
|
|
||||||
jobs = (await api("/api/jobs")).jobs;
|
jobs = (await api("/api/jobs")).jobs;
|
||||||
runs = Object.fromEntries(await Promise.all(jobs.map(async (job) => [
|
runs = Object.fromEntries(await Promise.all(jobs.map(async (job) => [
|
||||||
job.id,
|
job.id,
|
||||||
await api(`/api/jobs/${encodeURIComponent(job.id)}/runs?limit=10`).then((value) => value.runs).catch(() => [])
|
await api(`/api/jobs/${encodeURIComponent(job.id)}/runs?limit=10`).then((value) => value.runs).catch(() => [])
|
||||||
])));
|
])));
|
||||||
}
|
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
error = caught.message;
|
error = caught.message;
|
||||||
} finally {
|
} finally {
|
||||||
@ -33,11 +26,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeTab(value) {
|
|
||||||
tab = value;
|
|
||||||
load();
|
|
||||||
}
|
|
||||||
|
|
||||||
function countdown(ts) {
|
function countdown(ts) {
|
||||||
void tick;
|
void tick;
|
||||||
if (!ts) return "—";
|
if (!ts) return "—";
|
||||||
@ -52,17 +40,6 @@
|
|||||||
return `${Math.floor(hours / 24)} 天后`;
|
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) {
|
function dotColor(status) {
|
||||||
if (status === "completed" || status === "success" || status === "ok") return "var(--signal)";
|
if (status === "completed" || status === "success" || status === "ok") return "var(--signal)";
|
||||||
if (status === "timeout") return "var(--accent)";
|
if (status === "timeout") return "var(--accent)";
|
||||||
@ -79,40 +56,15 @@
|
|||||||
|
|
||||||
<section class="page active content-page">
|
<section class="page active content-page">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<Tabs.Root value={tab} onValueChange={changeTab}>
|
<div>
|
||||||
<Tabs.List class="tabs" aria-label="任务类型">
|
<h2 style="margin:0">定时任务</h2>
|
||||||
<Tabs.Trigger value="scheduled">定时任务</Tabs.Trigger>
|
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">管理定时任务与巡检;后台子代理运行请到「子代理」页面查看。</p>
|
||||||
<Tabs.Trigger value="background">后台任务</Tabs.Trigger>
|
</div>
|
||||||
</Tabs.List>
|
|
||||||
</Tabs.Root>
|
|
||||||
<button class="secondary" onclick={load}><Icon name="refresh" size={16} />刷新</button>
|
<button class="secondary" onclick={load}><Icon name="refresh" size={16} />刷新</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
{#if loading}<div class="loading">加载中…</div>
|
{#if loading}<div class="loading">加载中…</div>
|
||||||
{:else if error}<div class="empty-card error-text">{error}</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}
|
{:else}
|
||||||
{#each jobs as job (job.id)}
|
{#each jobs as job (job.id)}
|
||||||
<article class="card">
|
<article class="card">
|
||||||
@ -147,11 +99,5 @@
|
|||||||
<style>
|
<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; }
|
.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; }
|
.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; }
|
.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>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user