feat: remove agent run groups, add WebUI agent definition management

- drop agent_run_groups table and group_id/scope_kind/scope_id columns (schema v8)
- remove group_id from AgentExecutionContext and recovery group counters
- flatten TasksPage background tab into a per-run list
- add WebUI Agents page with definition CRUD and inline provider/model
- bump version to 1.11.0
This commit is contained in:
xiaoxixi 2026-08-13 14:03:01 +08:00
parent 78d6e29672
commit 5501c539fc
56 changed files with 1676 additions and 1688 deletions

View File

@ -93,7 +93,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context - **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, delegated tools, Skill allowlists, and delegation edges before activation. Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; background batches, nested background, and legacy general background (kept for one transition version) remain restricted - **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
- **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
@ -106,7 +106,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **Providers** are pure HTTP clients; no bus/session/channel awareness - **Providers** are pure HTTP clients; no bus/session/channel awareness
- **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** fails closed: new tools default to `RootOnly`; only code-reviewed `Delegatable` tools may appear in Agent Markdown, while `delegate` and scoped `get_skill` are runtime-injected. Call parameters cannot expand a named Agent's tool set - **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` - **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`
- **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

View File

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

View File

@ -408,7 +408,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
### 具名子 AgentPhase 1 ### 具名子 AgentPhase 1
启用 `agent_orchestration.enabled`PicoBot 会在 Gateway 候选运行代构造时从配置目录下的 `definitions_dir` 加载 `*.md`。相对路径按 `config.json` 所在目录解析,且 canonical path 不得逃逸该目录角色文件、Provider profile、工具、Skill 和委托边任一无效都会拒绝启动或热重载。支持具名 `foreground`(单/批量)与 Root 发起的具名 `background` 单任务background 结果经 durable inbox 由主 Agent 的 continuation Turn 汇总,可配合 `emit_signal`queue/steer推送内部信号。background 批量与子 Agent 发起的 background 尚未开放;未启用时,旧 general 单任务 background 仍作为兼容路径存在(带迁移提示)。 启用 `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 已移除)。
```md ```md
--- ---
@ -430,7 +430,7 @@ limits:
你是一名严谨的研究 Agent只返回与任务有关的结论和证据。 你是一名严谨的研究 Agent只返回与任务有关的结论和证据。
``` ```
`llm_profile` 引用顶层 `agents` 的 key因此每个具名 Agent 可以使用不同 Provider/Model。工具权限由 Markdown 固定,并与代码内 `DelegationPolicy` 取交集;调用时的 `allowed_tools` 只能收窄旧 general Agent不能给具名 Agent 扩权。当前可委托工具包括只读文件/内容搜索、`web_fetch`、calculator、普通 browser 动作与 sleep写文件、Shell、HTTP 写请求、外部发送、计划和管理工具默认仅 Root 可用。`get_skill` 只读取 Definition 声明的 Skill allowlist 每个具名 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
更完整的配置字段说明见 [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)。

View File

@ -24,12 +24,7 @@ fn main() {
if path.extension().and_then(|e| e.to_str()) != Some("md") { if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue; continue;
} }
let agent_name = path let agent_name = path.file_stem().unwrap().to_str().unwrap().to_string();
.file_stem()
.unwrap()
.to_str()
.unwrap()
.to_string();
fs::copy(&path, agents_out_dir.join(format!("{agent_name}.md"))).unwrap(); fs::copy(&path, agents_out_dir.join(format!("{agent_name}.md"))).unwrap();
agents.push(agent_name); agents.push(agent_name);
} }

View File

@ -25,7 +25,7 @@
}, },
"gateway": { "gateway": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 19876, "port": 19877,
"require_pairing": true "require_pairing": true
}, },
"channels": {}, "channels": {},

View File

@ -206,9 +206,9 @@ Session ID 格式为:
SessionManager 负责组装会话上下文系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。`session::turn_input` 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑,也不得丢失已经从 mailbox 取出的 steering。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态,但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。 SessionManager 负责组装会话上下文系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。`session::turn_input` 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑,也不得丢失已经从 mailbox 取出的 steering。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态,但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。
当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`,不能另建字符串 notification 通道重复投递。具名子 Agent 的 Phase 1 基础已接入:候选运行代从受信任配置目录严格加载不可变 `AgentCatalog`Definition 固定 Provider profile、工具/Skill allowlist、委托边和执行限制`delegate` 使用 `foreground/background` 两个 canonical 生命周期词,批量并发与生命周期正交。具名 foreground 支持不同 Provider、批量并发、显式 `AgentExecutionContext`、祖先环路和委托边校验。具名 background 在 durable run/inbox 完成前明确拒绝;旧 general background 的 `TaskNotification` 兼容路径仍由独立受监督消费者直接投递,不提供新设计的可靠收件箱语义。自动标题属于非关键派生工作Turn 持久化完成后由 `TaskSupervisor` 调度Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。 当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`,不能另建字符串 notification 通道重复投递。具名子 Agent 基础已接入:候选运行代从受信任配置目录严格加载不可变 `AgentCatalog`Definition 固定 Provider/Model内联或 `llm_profile`、工具/Skill allowlist、委托边和执行限制,工具集完全由定义文件决定`delegate` 使用 `foreground/background` 两个 canonical 生命周期词,批量并发与生命周期正交。具名 foreground 支持批量并发、显式 `AgentExecutionContext`、祖先环路和委托边校验Root 对具名 Agent 的 background单任务或批量批量并发、每个 run 独立 completion 事件)走 durable run/inbox + continuation 投递,空闲时完成即返回。内置 general-purpose 定义随二进制释放WebUI「子 Agent」页可增删改与启停定义。旧匿名 general 兼容路径已移除。自动标题属于非关键派生工作Turn 持久化完成后由 `TaskSupervisor` 调度Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
每个 session 最多有一个 active plan但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 不获得 `todo``delegate`。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。 每个 session 最多有一个 active plan但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 的工具集由其定义文件决定,能否继续委托由其 `delegates` 白名单决定。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。
## 6. 持久化 ## 6. 持久化
@ -219,7 +219,7 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
- 5 秒 busy timeout。 - 5 秒 busy timeout。
- schema version 迁移。 - schema version 迁移。
持久化范围包括 sessions、messages、session turn usage、memories、task plans/items、scheduled jobs、job runs 和 background tasks。消息保存可展示 `reasoning_content`、Provider 私有回放状态、`turn_id`、iteration 和 completion status私有 Provider 状态不进入 WebSocket/Channel且只允许回放给同一 Provider。成功 Turn 的 Provider usage 与消息批次在同一事务中写入 `session_turn_usage`,以 `turn_id` 幂等累计会话输入、输出、缓存输入和请求数;升级前的历史没有可归属 usage统计起点必须显式呈现。成功持久化一个 Turn 后,交互 Channel 收到只包含公开字段的 `CommittedTurnDelta`,其中 `history_revision` 是本批次最高 durable sequence客户端按 revision 幂等合并,正常完成不重新加载整段历史,断线重连和失败/取消仍使用 `SessionHistory` 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要不保存正文、reasoning 或签名 payload。修改 schema 时应: 持久化范围包括 sessions、messages、session turn usage、memories、task plans/items、scheduled jobs、job runs、agent run/inbox/session state。消息保存可展示 `reasoning_content`、Provider 私有回放状态、`turn_id`、iteration 和 completion status私有 Provider 状态不进入 WebSocket/Channel且只允许回放给同一 Provider。成功 Turn 的 Provider usage 与消息批次在同一事务中写入 `session_turn_usage`,以 `turn_id` 幂等累计会话输入、输出、缓存输入和请求数;升级前的历史没有可归属 usage统计起点必须显式呈现。成功持久化一个 Turn 后,交互 Channel 收到只包含公开字段的 `CommittedTurnDelta`,其中 `history_revision` 是本批次最高 durable sequence客户端按 revision 幂等合并,正常完成不重新加载整段历史,断线重连和失败/取消仍使用 `SessionHistory` 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要不保存正文、reasoning 或签名 payload。修改 schema 时应:
1. 更新集中式 schema/迁移逻辑。 1. 更新集中式 schema/迁移逻辑。
2. 保留已有数据库的升级路径。 2. 保留已有数据库的升级路径。
@ -236,7 +236,7 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
## 7. 后台任务与生命周期 ## 7. 后台任务与生命周期
`TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。inbound/control routers、inbound lanes、outbound dispatcher、scheduler、session workers、Turn delivery、outbound lanes、后台任务通知消费者、自动标题和子 Agent 后台任务都应通过它注册。 `TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。inbound/control routers、inbound lanes、outbound dispatcher、scheduler、session workers、Turn delivery、outbound lanes、自动标题和子 Agent 后台任务都应通过它注册。
两种注册方式: 两种注册方式:

View File

@ -73,7 +73,7 @@ PicoBot 已经具备一版子 Agent 能力:根交互 Agent 通过 `delegate`
| Queue | 输入属于后续 Turn不改变当前 Turn 的模型上下文 | | Queue | 输入属于后续 Turn不改变当前 Turn 的模型上下文 |
| Steer | 输入尝试进入当前 Turn并在最近安全边界注入失败时可靠退化为 queue | | Steer | 输入尝试进入当前 Turn并在最近安全边界注入失败时可靠退化为 queue |
| Agent Signal | Background Agent 在运行中主动发出的非终态重要事件 | | Agent Signal | Background Agent 在运行中主动发出的非终态重要事件 |
| Agent Completion | Agent Run 进入 completed/failed/timed_out/cancelled/interrupted 时由运行时自动产生的终态事实background 按 group policy 投影为 run/group inbox event | | Agent Completion | Agent Run 进入 completed/failed/timed_out/cancelled/interrupted 时由运行时自动产生的终态事实background 逐 run 投影为 inbox completion event |
| Agent Inbox | 持久化的主 Agent 内部收件箱,是 Background 结果与信号的权威来源 | | Agent Inbox | 持久化的主 Agent 内部收件箱,是 Background 结果与信号的权威来源 |
| Turn Mailbox | 当前 Turn 接受 steer 输入的有界内存邮箱,保留来源、顺序和 durable event ID | | Turn Mailbox | 当前 Turn 接受 steer 输入的有界内存邮箱,保留来源、顺序和 durable event ID |
@ -216,7 +216,7 @@ flowchart LR
Sub[Sub Agent] --> DT Sub[Sub Agent] --> DT
DT --> C[AgentCoordinator] DT --> C[AgentCoordinator]
C --> AC[AgentCatalog] C --> AC[AgentCatalog]
C --> DP[DelegationPolicy] C --> RI[runtime-injected tool marker]
C --> PF[ProviderFactory] C --> PF[ProviderFactory]
C --> TR[Filtered ToolRegistry] C --> TR[Filtered ToolRegistry]
C --> AR[AgentRunner] C --> AR[AgentRunner]
@ -239,11 +239,11 @@ flowchart LR
替代当前承担过多职责的 `SubAgentManager`,负责: 替代当前承担过多职责的 `SubAgentManager`,负责:
- 解析 caller/target 和授权委托边。 - 解析 caller/target 和授权委托边。
- 创建 run/group ID、父子关系和预算。 - 创建 run ID、父子关系和预算。
- 持久化接纳状态后启动 AgentRunner。 - 持久化接纳状态后启动 AgentRunner。
- 管理 foreground await、background spawn、取消和超时。 - 管理 foreground await、background spawn、取消和超时。
- 控制全局、session、Agent 与任务树的 run admission quota以及 Provider/普通工具步骤的 execution permit两类配额不共用生命周期。 - 控制全局、session、Agent 与任务树的 run admission quota以及 Provider/普通工具步骤的 execution permit两类配额不共用生命周期。
- 生成自动 completion terminal outcome按 group policy 物化 inbox event。 - 生成自动 completion terminal outcome逐 run 物化 inbox event。
- 向 WorkManager 条件提交计划子项结果。 - 向 WorkManager 条件提交计划子项结果。
### 6.3 AgentRunner ### 6.3 AgentRunner
@ -259,7 +259,7 @@ flowchart LR
### 6.4 AgentEventSink / AgentResultRouter ### 6.4 AgentEventSink / AgentResultRouter
`AgentEventSink` 负责持久化 signal 和按 policy 生成的 run/group completion event`AgentResultRouter` 负责把 pending inbox event 送到原 root session。Router 的内存 wakeup 是加速器SQLite inbox 才是权威来源。 `AgentEventSink` 负责持久化 signal 和 run completion event(每个 run 终态独立生成)`AgentResultRouter` 负责把 pending inbox event 送到原 root session。Router 的内存 wakeup 是加速器SQLite inbox 才是权威来源。
### 6.5 ProviderFactory ### 6.5 ProviderFactory
@ -290,30 +290,24 @@ Root 的 allowed targets 来自 `root_delegates`;子 Agent 来自自身 Markdo
### 7.2 工具权限 ### 7.2 工具权限
调用参数不再提供 `allowed_tools` 扩权。有效工具集为: 工具可用性完全由具名 Agent 定义文件决定:有效工具集为
```text ```text
AgentDefinition.tools AgentDefinition.tools ∩ 当前运行代已注册工具
∩ 当前运行代已注册工具
∩ 系统可委托工具策略
``` ```
Tool 增加安全元数据 不再有工具侧的「可派发」标志。Tool trait 只保留一个运行时注入标记
```rust ```rust
enum DelegationPolicy { /// 该工具由运行上下文按需注入delegate 目标、信号契约、skill allowlist
RootOnly, /// 不能直接写进 Definition 的 `tools` 列表。普通工具默认 false。
Delegatable, fn runtime_injected(&self) -> bool { false }
RuntimeInjected,
}
``` ```
- `reload_config``todo`、管理配置和任意外部发送默认 `RootOnly` - `delegate``emit_signal``get_skill``agent_task` 标记 `runtime_injected=true`,由 Coordinator 根据 `delegates`/`signal`/`skills` 字段和运行上下文注入,不能仅靠 Markdown 的 `tools` 声明;`get_skill` 是唯一例外——把它写进 `tools` 表示启用 scoped skill 包装器。
- 普通只读工具在明确审查后标记 `Delegatable` - 其余任何已注册工具(含 `bash``send_message``todo` 等)都可由管理员在定义文件的 `tools` 列表显式授权,这是知情的选择。
- `delegate``emit_signal` 等由 Coordinator 根据运行上下文注入,标记 `RuntimeInjected`,不能仅靠 Markdown 获得。
- 新工具默认 `RootOnly`,避免未来工具无意暴露。
目标 Agent 可以拥有调用方没有的专业工具,因为委托边本身就是管理员授权调用该能力;模型不能在单次调用中越过 Definition 扩权 `allowed_tools` 调用参数只能收窄 Definition 的工具集,不能扩权;模型不能在单次调用中越过 Definition。
## 8. AgentExecutionContext ## 8. AgentExecutionContext
@ -324,7 +318,6 @@ pub struct AgentExecutionContext {
pub root_session_id: String, pub root_session_id: String,
pub root_turn_id: Option<String>, pub root_turn_id: Option<String>,
pub run_id: String, pub run_id: String,
pub group_id: Option<String>,
pub parent_run_id: Option<String>, pub parent_run_id: Option<String>,
pub caller_agent_id: String, pub caller_agent_id: String,
pub current_agent_id: String, pub current_agent_id: String,
@ -387,7 +380,6 @@ agent_task → get / list / cancel / get_result
```json ```json
{ {
"group_id": "group-123",
"runs": [ "runs": [
{"run_id": "run-a", "agent": "researcher", "status": "queued"}, {"run_id": "run-a", "agent": "researcher", "status": "queued"},
{"run_id": "run-b", "agent": "coder", "status": "queued"}, {"run_id": "run-b", "agent": "coder", "status": "queued"},
@ -411,11 +403,10 @@ agent_task → get / list / cancel / get_result
} }
``` ```
- `signal`:运行中主动事件的投递方式。 - `signal`:运行中主动事件的投递方式(当前由 Definition 的 `signal:` 契约 `delivery` 字段决定 queue/steer
- `completion`:正常终态结果的投递方式,默认 queue。 - `completion` / `failure`:投递契约为未来扩展;当前 completion 与 failure 恒为 queue尚未开放可配置 steer。
- `failure`:失败、超时、异常中断的投递方式,默认 queue可显式 steer。
Foreground 请求直接把 completion 作为 tool result 返回,因此不接受 completion delivery。第一阶段仅允许 Root 创建 background run子 Agent 之间可以 foreground 委托。待持久化 task tree 与 root inbox 稳定后,再允许子 Agent 创建最终归属于 root session 的 background run Foreground 请求直接把 completion 作为 tool result 返回,因此不接受 completion delivery。仅允许 Root 创建 background run单任务或 `tasks` 批量,批量并发执行、每个 run 独立 completion 事件);子 Agent 发起的 background 尚未开放
### 9.5 Foreground 返回 ### 9.5 Foreground 返回
@ -436,7 +427,7 @@ Foreground 请求直接把 completion 作为 tool result 返回,因此不接
### 9.6 幂等与接纳 ### 9.6 幂等与接纳
Background delegate 只有在 run/group 记录持久化成功、运行代 admission 成功、completion inbox 容量已经预留且执行任务已经被 TaskSupervisor 接纳后才返回成功。可选 `idempotency_key``(root_session_id, caller_scope_id, key)` 范围唯一,用于 Provider 重试时避免重复创建任务Root 的 `caller_scope_id` 固定为非空字面量 `ROOT`,数据库使用仅覆盖非空 key 的 partial unique index避免 SQLite `NULL` 破坏去重。 Background delegate 只有在 run 记录持久化成功、运行代 admission 成功、completion inbox 容量已经预留且执行任务已经被 TaskSupervisor 接纳后才返回成功。可选 `idempotency_key``(root_session_id, caller_scope_id, key)` 范围唯一,用于 Provider 重试时避免重复创建任务Root 的 `caller_scope_id` 固定为非空字面量 `ROOT`,数据库使用仅覆盖非空 key 的 partial unique index避免 SQLite `NULL` 破坏去重。
## 10. Prompt 与上下文隔离 ## 10. Prompt 与上下文隔离
@ -506,7 +497,7 @@ stateDiagram-v2
| Signal | 子 Agent 主动调用 `emit_signal` | 否 | 重要中间状态、监控告警 | | Signal | 子 Agent 主动调用 `emit_signal` | 否 | 重要中间状态、监控告警 |
| Completion outcome | AgentCoordinator 自动生成 | 是 | completed/failed/timed_out/cancelled/interrupted | | Completion outcome | AgentCoordinator 自动生成 | 是 | completed/failed/timed_out/cancelled/interrupted |
最终结果不能依赖模型记得调用工具。即使 Provider 异常、超时或任务被取消Coordinator 也必须持久化 run 的终态 outcome。Foreground 将其返回为 tool resultbackground `each` 将每个 outcome 物化为 run completion inbox event`all` 只在 group 终态时物化一个 group completion inbox event 最终结果不能依赖模型记得调用工具。即使 Provider 异常、超时或任务被取消Coordinator 也必须持久化 run 的终态 outcome。Foreground 将其返回为 tool resultbackground 的每个 run 终态都物化为一个独立的 run completion inbox event无 all/each 策略,批量也只是逐 run 生成)
### 12.2 EmitSignalTool ### 12.2 EmitSignalTool
@ -550,7 +541,7 @@ Coordinator 强制执行:
### 12.3 Completion 去重 ### 12.3 Completion 去重
run completion payload 包含本 run 已发出的 signal IDsgroup completion 则按 run 分组携带这些 IDs。若最终总结重复某个信号主 Agent可以识别并避免再次报告。正常 completion 可以配置 queue关键 failure 可以配置 steer。禁止完全静默丢弃失败,`silent` 若未来开放也只能用于正常 completion。 run completion payload 包含本 run 已发出的 signal IDs,主 Agent 可以识别并避免再次报告。completion/failure 投递当前恒为 queue可配置 steer 为未来扩展)。禁止完全静默丢弃失败,`silent` 若未来开放也只能用于正常 completion。
## 13. SendMessage、EmitSignal 与附件职责 ## 13. SendMessage、EmitSignal 与附件职责
@ -564,7 +555,7 @@ attach_artifact 工具产物 → 当前 Turn → DeliveryCoordinator当前回
### 13.1 send_message ### 13.1 send_message
只负责用户明确授权的跨 Channel/跨会话外部消息,具有真实外部副作用。默认 `RootOnly`目标和文件参数继续受 Channel/file transfer 限制`origin` 不再由模型自由填写,改由 ToolExecutionContext 生成,避免来源伪造。 只负责用户明确授权的跨 Channel/跨会话外部消息,具有真实外部副作用。目标和文件参数继续受 Channel/file transfer 限制`origin` 不再由模型自由填写,改由 ToolExecutionContext 生成,避免来源伪造。是否对子 Agent 开放由管理员在定义文件的 `tools` 里显式决定。
### 13.2 emit_signal ### 13.2 emit_signal
@ -576,7 +567,7 @@ attach_artifact 工具产物 → 当前 Turn → DeliveryCoordinator当前回
### 13.4 自动 completion ### 13.4 自动 completion
Completion 不是工具。AgentRunner 的终结路径统一返回 terminal outcomeCoordinator 保存结果并按 foreground/background 与 group policy 创建相应投递 event避免模型遗漏或重复。 Completion 不是工具。AgentRunner 的终结路径统一返回 terminal outcomeCoordinator 保存结果并按 foreground/background 创建相应投递 event避免模型遗漏或重复。
## 14. 持久化模型 ## 14. 持久化模型
@ -586,7 +577,6 @@ Completion 不是工具。AgentRunner 的终结路径统一返回 terminal outco
agent_runs agent_runs
---------- ----------
id TEXT PRIMARY KEY id TEXT PRIMARY KEY
group_id TEXT
root_session_id TEXT NOT NULL root_session_id TEXT NOT NULL
root_turn_id TEXT root_turn_id TEXT
parent_run_id TEXT parent_run_id TEXT
@ -634,34 +624,15 @@ WHERE idempotency_key IS NOT NULL;
不保存 API key、Authorization header、Provider 私有 reasoning state 或完整 connection URL。`cost` 是 nullable projectionProvider profile 未配置价格时必须为 `NULL`usage token 不受影响。 不保存 API key、Authorization header、Provider 私有 reasoning state 或完整 connection URL。`cost` 是 nullable projectionProvider profile 未配置价格时必须为 `NULL`usage token 不受影响。
### 14.2 agent_run_groups ### 14.2 agent_run_groups已删除schema v8
```text 批量委托不再建组头:单/批量请求的 `idempotency_key` 都绑定各自的 run 行,批量只是多个独立 run 的集合,使用 `(root_session_id, caller_scope_id, idempotency_key)` partial unique index避免批量 children 互相冲突。
agent_run_groups
----------------
id
root_session_id
caller_run_id
caller_scope_id
idempotency_key
mode
completion_policy all | each
expected_runs
terminal_runs
completion_slot_reserved
deadline_at
status
created_at
finished_at
```
批量请求的 `idempotency_key` 绑定 group其 child run 的 key 为 `NULL`。单任务请求没有 group 时key 绑定 run。两者分别使用 `(root_session_id, caller_scope_id, idempotency_key)` partial unique index避免批量 children 互相冲突 批量 background 的每个 run 终态都立即创建独立 completion event不等待 sibling主 Agent 空闲时收到即处理(完成即返回),忙碌时由公平调度合并或等待。不存在 all/each 策略。
批量 background 默认 `completion_policy=all`:单个 run 终态只更新 group 计数,全部 run 终态或 group deadline 到达后创建唯一 `group_completion` eventdeadline 到达时先把未终态 child 条件更新为 `timed_out``completion_policy=each` 则在每个 run 终态时立即创建独立 completion event不等待 siblingRouter 可通过 300500ms debounce 把已经到达的多个 event 合并为一次 continuation但不能用 debounce 改变 deadline 或确认语义 接纳 background 时在每个 run 的 `completion_slot_reserved` 记一个 slot。Storage 用同一写事务统计该 session 的 `pending/leased/admitted` 事件和有效 reservation避免并发接纳越过上限`consumed/dead_letter` 受 TTL 清理但不占 pending 配额。容量不足在创建 run 前拒绝signal 只能使用未预留容量。预留在 completion 事务落库或接纳回滚时释放。
接纳 background run/group 时按 policy 在 session inbox 配额中预留 completion slot`each` 在每个 run 的 `completion_slot_reserved` 记一个,`all` 在 group 字段记一个。Storage 用同一写事务统计该 session 的 `pending/leased/admitted` 事件和有效 reservation避免并发接纳越过上限`consumed/dead_letter` 受 TTL 清理但不占 pending 配额。容量不足在创建 run 前拒绝signal 只能使用未预留容量。预留在 completion 事务落库或接纳回滚时释放。 容量判断不能在每次接纳时通过无锁 `COUNT(*)` 推断。新增每 root session 一行的 `agent_session_state`,在同一 SQLite 写事务中以条件 `UPDATE` 维护 `pending_event_count``reserved_completion_slots` 和单调 `revision`。background 接纳先增加 reservationsignal 只有在 `pending + reserved < limit` 时增加 pendingcompletion 将 reservation 原子转换为 pendingconsume/dead-letter 减少 pending。启动恢复会以事件与 run 事实重算计数,发现差异时修复并记录告警。
容量判断不能在每次接纳时通过无锁 `COUNT(*)` 推断。新增每 root session 一行的 `agent_session_state`,在同一 SQLite 写事务中以条件 `UPDATE` 维护 `pending_event_count``reserved_completion_slots` 和单调 `revision`。background 接纳先增加 reservationsignal 只有在 `pending + reserved < limit` 时增加 pendingcompletion 将 reservation 原子转换为 pendingconsume/dead-letter 减少 pending。启动恢复会以事件与 run/group 事实重算计数,发现差异时修复并记录告警。
### 14.3 agent_inbox_events ### 14.3 agent_inbox_events
@ -670,11 +641,8 @@ agent_inbox_events
------------------ ------------------
id TEXT PRIMARY KEY id TEXT PRIMARY KEY
root_session_id TEXT NOT NULL root_session_id TEXT NOT NULL
scope_kind run | group run_id TEXT NOT NULL
scope_id TEXT NOT NULL event_type signal | completion
run_id TEXT
group_id TEXT
event_type signal | completion | group_completion
event_key TEXT NOT NULL event_key TEXT NOT NULL
delivery queue | steer delivery queue | steer
requires_continuation BOOLEAN NOT NULL DEFAULT TRUE requires_continuation BOOLEAN NOT NULL DEFAULT TRUE
@ -693,16 +661,12 @@ dead_lettered_at INTEGER
fallback_notified_at INTEGER fallback_notified_at INTEGER
revision INTEGER NOT NULL revision INTEGER NOT NULL
UNIQUE(scope_kind, scope_id, event_type, event_key) UNIQUE(run_id, event_type, event_key)
CHECK(
(scope_kind = 'run' AND run_id IS NOT NULL AND group_id IS NULL AND scope_id = run_id) OR
(scope_kind = 'group' AND group_id IS NOT NULL AND run_id IS NULL AND scope_id = group_id)
)
``` ```
完整结果保存在 `agent_runs.result`inbox payload 默认只放有界摘要、元数据和 result reference避免复制大文本。 完整结果保存在 `agent_runs.result`inbox payload 默认只放有界摘要、元数据和 result reference避免复制大文本。
event key 始终非空:无 dedupe key 的 signal 用 `signal:<event_uuid>`,有 dedupe key 的 signal 加冷却窗口 IDrun completion 固定为 `completion:terminal-v1`group completion 固定为 `group-completion:terminal-v1`。由同一次 `/stop` 产生、无需主 Agent再次解释的 cancelled completion 使用 `requires_continuation=false`,在终态事务中直接记为 consumed但仍保留事件审计和客户端投影。 event key 始终非空:无 dedupe key 的 signal 用 `signal:<event_uuid>`,有 dedupe key 的 signal 加冷却窗口 IDrun completion 固定为 `completion:<run_id>`。由同一次 `/stop` 产生、无需主 Agent再次解释的 cancelled completion 使用 `requires_continuation=false`,在终态事务中直接记为 consumed但仍保留事件审计和客户端投影。
### 14.4 原子事务 ### 14.4 原子事务
@ -710,16 +674,13 @@ Agent completion 必须在一个 Storage 事务中:
```text ```text
UPDATE agent_runs terminal state/result/usage UPDATE agent_runs terminal state/result/usage
UPDATE agent_run_groups terminal count/status
CONSUME reserved completion capacity CONSUME reserved completion capacity
INSERT run completion OR group completion ... ON CONFLICT DO NOTHING INSERT run completion ... ON CONFLICT DO NOTHING
UPDATE bound task item by execution_id UPDATE bound task item by execution_id
COMMIT COMMIT
``` ```
事务失败时不能对外宣称任务完成。内存 wakeup 只有在 commit 成功后发送。 事务失败时不能对外宣称任务完成。内存 wakeup 只有在 commit 成功后发送。每个 run 的终端事务独立生成自己的 completion 事件;迟到终态只更新自己的 run 行,不影响其它 sibling。
`completion_policy=all` 只有把 group 从 non-terminal 条件更新为 terminal 的事务赢家可以插入 group completion其他 sibling 的迟到终态只完成自己的 run 条件更新,不能重复生成 event。
### 14.5 continuation 消息与投递绑定 ### 14.5 continuation 消息与投递绑定
@ -758,7 +719,6 @@ pub enum TurnInputSource {
User, User,
AgentSignal { run_id: String, agent_id: String }, AgentSignal { run_id: String, agent_id: String },
AgentCompletion { run_id: String, agent_id: String }, AgentCompletion { run_id: String, agent_id: String },
AgentGroupCompletion { group_id: String },
} }
pub enum InputDelivery { pub enum InputDelivery {
@ -859,7 +819,7 @@ Steer event 在没有活动 Turn 时按 queue 处理。用户输入通常优先
```rust ```rust
enum AgentTaskSource { enum AgentTaskSource {
UserInput, UserInput,
BackgroundAgentResults { event_ids: Vec<String>, group_id: Option<String> }, BackgroundAgentResults { event_ids: Vec<String> },
ScheduledTask, ScheduledTask,
} }
``` ```
@ -1074,7 +1034,7 @@ WebUI 管理面展示:
### 20.2 Chat 表现 ### 20.2 Chat 表现
- Foreground delegate 继续作为当前 Turn 的可折叠工具块。 - Foreground delegate 继续作为当前 Turn 的可折叠工具块。
- Background delegate 启动后显示 run/group ID不假装任务已完成。 - Background delegate 启动后显示 run ID不假装任务已完成。
- AgentSignal 显示为独立运行时信号卡片,不显示成用户气泡。 - AgentSignal 显示为独立运行时信号卡片,不显示成用户气泡。
- queue completion 在主 Agent内部 continuation 后只显示主 Agent汇总回复。 - queue completion 在主 Agent内部 continuation 后只显示主 Agent汇总回复。
- steer 信号可以在当前 Turn 工具状态中显示“已接纳”,最终历史由 Turn commit 校准。 - steer 信号可以在当前 Turn 工具状态中显示“已接纳”,最终历史由 Turn commit 校准。
@ -1120,34 +1080,21 @@ WsOutbound::AgentEventUpdated { session_id, revision, event }
所有重试必须有次数、退避、deadline 和分类;永久错误立即终态化,不能无界重试。默认最多 8 次,退避为 `1s/5s/30s/2m/10m` 后封顶 10 分钟,并同时受 inbox event TTL 限制。 所有重试必须有次数、退避、deadline 和分类;永久错误立即终态化,不能无界重试。默认最多 8 次,退避为 `1s/5s/30s/2m/10m` 后封顶 10 分钟,并同时受 inbox event TTL 限制。
dead-letter 记录最终原因和时间,并通过 OutboundDispatcher 最多发送一次有界 system fallback只包含 run/group ID、终态和查询提示`fallback_notified_at` 保证幂等。fallback 渠道失败时SQLite run/event 记录和管理 UI 是最终诊断出口,不能把 dead-letter 伪装成已交付。 dead-letter 记录最终原因和时间,并通过 OutboundDispatcher 最多发送一次有界 system fallback只包含 run ID、终态和查询提示`fallback_notified_at` 保证幂等。fallback 渠道失败时SQLite run/event 记录和管理 UI 是最终诊断出口,不能把 dead-letter 伪装成已交付。
## 22. 兼容迁移 ## 22. 兼容迁移
### 22.1 Delegate 参数 ### 22.1 Delegate 参数
旧模式映射: 旧模式映射已在迁移完成后移除:`inline`/`parallel` 别名与 legacy general 委托均不再解析,只保留 canonical `foreground`/`background` 生命周期词与具名 `target`
```text
inline → foreground
parallel → foreground + tasks[]
background → background
```
过渡期只解析代码中确实存在的旧值并在 tool result/日志中给出弃用提示;`async` 从未是有效值,不新增该别名。新 system prompt 只描述 canonical 值 `foreground/background`
### 22.2 allowed_tools ### 22.2 allowed_tools
`allowed_tools` 首先变成只能收紧 Definition.tools 的兼容字段,不能扩权;随后从 schema 删除。没有 target 的旧委托映射到内置 `general` Agent Definition `allowed_tools` 只能收紧 Definition.tools不能扩权。没有 `target` 的委托不再支持(旧匿名 general 已移除);内置 `general-purpose` Agent 定义随二进制释放到 `~/.picobot/agents/`,开箱即用。
### 22.3 background_tasks ### 22.3 background_tasks
新增 `agent_runs` 后: `background_tasks` 表已在 schema v7 中删除(`DROP TABLE`),旧 adapter 与 direct notification 路径一并移除;`/api/tasks` 只读取 `agent_runs`。无历史兼容需求。
- 新任务只写新表。
- 管理 API 在过渡期 union 读取旧 `background_tasks` 与新 `agent_runs`
- 旧终态记录按原 TTL 清理,不强制迁移正文。
- 旧 pending/running 记录在升级启动时按 interrupted/cancelled 规则收敛。
### 22.4 版本与文档 ### 22.4 版本与文档
@ -1162,8 +1109,8 @@ background → background
- Delegate schema 使用 target + foreground/background canonical modes。 - Delegate schema 使用 target + foreground/background canonical modes。
- 批量 foreground 并发执行并聚合。 - 批量 foreground 并发执行并聚合。
- 显式 AgentExecutionContext 和委托图授权。 - 显式 AgentExecutionContext 和委托图授权。
- 明确 skills/memory 不继承、具名 Agent browser scope 隔离和 legacy general scope 兼容 - 明确 skills/memory 不继承、具名 Agent browser scope 隔离。
- 保持旧 background 通知路径作为兼容,但不开放嵌套 background - 不开放嵌套 background子 Agent 发起的 background
### Phase 2AAgentLoop 结构化取消 ### Phase 2AAgentLoop 结构化取消
@ -1173,7 +1120,7 @@ background → background
### Phase 2B统一 Agent Run 持久化 ### Phase 2B统一 Agent Run 持久化
- 新增 `agent_runs``agent_run_groups`Storage transaction API。 - 新增 `agent_runs`、Storage transaction API。
- 拆分 `delegate``agent_task` - 拆分 `delegate``agent_task`
- Foreground 结果也持久化,修复截断结果不可查询。 - Foreground 结果也持久化,修复截断结果不可查询。
- 实现预算、run admission quota 与 step execution permit 释放。 - 实现预算、run admission quota 与 step execution permit 释放。
@ -1216,7 +1163,7 @@ src/agent/
└── sub_agent.rs 迁移兼容层,最终缩减或删除 └── sub_agent.rs 迁移兼容层,最终缩减或删除
src/tools/ src/tools/
├── delegate.rs create run/group only ├── delegate.rs create run only
├── agent_task.rs get/list/cancel/get_result ├── agent_task.rs get/list/cancel/get_result
├── emit_signal.rs constrained internal signal ├── emit_signal.rs constrained internal signal
├── sleep.rs wake-aware wait ├── sleep.rs wake-aware wait
@ -1260,7 +1207,7 @@ src/storage/
- 不同 Agent 使用不同 provider/model profile。 - 不同 Agent 使用不同 provider/model profile。
- Provider storage/observer 正确注入。 - Provider storage/observer 正确注入。
- RootOnly 工具不能通过 Markdown 或兼容 allowed_tools 获得 - 工具集完全由定义文件的 `tools` 列表决定runtime-injected 工具不能通过 Markdown 声明
- runtime-injected delegate/emit_signal 只在上下文允许时存在。 - runtime-injected delegate/emit_signal 只在上下文允许时存在。
- 并行 run 的 browser/resource scope 隔离。 - 并行 run 的 browser/resource scope 隔离。
- persistent browser profile 可显式共享;具名 Agent不继承 transient parent scope。 - persistent browser profile 可显式共享;具名 Agent不继承 transient parent scope。
@ -1280,7 +1227,7 @@ src/storage/
- user mpsc 满不影响 durable wakewake revision 丢失后扫描可恢复。 - user mpsc 满不影响 durable wakewake revision 丢失后扫描可恢复。
- 用户持续输入时 burst/age 公平上限仍调度 continuation。 - 用户持续输入时 burst/age 公平上限仍调度 continuation。
- completion capacity 在 background 接纳时预留signal 不能抢占。 - completion capacity 在 background 接纳时预留signal 不能抢占。
- `each` 逐 run 提前投递;`all` 只触发一次 group 汇总 Turn。 - 每个 run 独立 completion逐 run 投递;无 group 汇总 Turn。
- retries/TTL 耗尽进入 dead-lettersystem fallback 最多发送一次。 - retries/TTL 耗尽进入 dead-lettersystem fallback 最多发送一次。
### 25.5 Signal ### 25.5 Signal
@ -1332,7 +1279,7 @@ src/storage/
3. Foreground/Background 只描述委托方等待行为;并发是独立调度维度。 3. Foreground/Background 只描述委托方等待行为;并发是独立调度维度。
4. Queue 输入永不泄漏正文到当前 TurnSteer 只在安全边界注入。 4. Queue 输入永不泄漏正文到当前 TurnSteer 只在安全边界注入。
5. Signal 先持久化后唤醒;内存通知不是事实来源。 5. Signal 先持久化后唤醒;内存通知不是事实来源。
6. Signal 是非终态事件run Completion 由运行时自动生成且恰好对应一个 run 终态`all` policy 的 Group Completion 恰好对应一个 group 终态 6. Signal 是非终态事件run Completion 由运行时自动生成且恰好对应一个 run 终态(批量背景也逐 run 生成,无 group completion
7. SendMessage 是外部输出EmitSignal 是内部输入,不能用一个公开万能工具混合权限。 7. SendMessage 是外部输出EmitSignal 是内部输入,不能用一个公开万能工具混合权限。
8. Durable Agent event 在 `/stop`、Turn 失败或 Gateway 崩溃时不能静默丢失。 8. Durable Agent event 在 `/stop`、Turn 失败或 Gateway 崩溃时不能静默丢失。
9. Agent Definition 和 Provider 绑定 runtime generation运行中不热切换。 9. Agent Definition 和 Provider 绑定 runtime generation运行中不热切换。
@ -1351,7 +1298,7 @@ src/storage/
```text ```text
Markdown Agent Definition Markdown Agent Definition
AgentCatalog + DelegationPolicy AgentCatalog + runtime-injected 工具标记
AgentCoordinator AgentCoordinator
├─ foreground并发执行、父等待、tool result 返回 ├─ foreground并发执行、父等待、tool result 返回

File diff suppressed because one or more lines are too long

View File

@ -129,7 +129,7 @@ Provider stream、可取消等待和工具批次外层都观察 tokenAgentRun
事件进入 dead-letter 后: 事件进入 dead-letter 后:
1. 保存最终原因和 `dead_lettered_at`,在任务树/API 中持续可见。 1. 保存最终原因和 `dead_lettered_at`,在任务树/API 中持续可见。
2. 通过 OutboundDispatcher 最多发送一次有界 system fallback内容只包含 run/group ID、终态和查询提示不复制大结果。 2. 通过 OutboundDispatcher 最多发送一次有界 system fallback内容只包含 run ID、终态和查询提示不复制大结果。
3. 用 `fallback_notified_at` 保证 fallback 幂等;渠道也失败时仍以 SQLite 记录和管理 UI 为最终可诊断出口。 3. 用 `fallback_notified_at` 保证 fallback 幂等;渠道也失败时仍以 SQLite 记录和管理 UI 为最终可诊断出口。
### B3 — `completion_policy=each` ### B3 — `completion_policy=each`
@ -197,7 +197,7 @@ Provider stream、可取消等待和工具批次外层都观察 tokenAgentRun
|-------|------------------| |-------|------------------|
| 1 | 除原内容外,明确 browser 兼容 scope、skills/memory 规则和 sub-run sleep 行为 | | 1 | 除原内容外,明确 browser 兼容 scope、skills/memory 规则和 sub-run sleep 行为 |
| 2A | CancellationToken 贯穿 AgentLoop先以现有 root Turn/sleep/Provider tests 锁定取消语义 | | 2A | CancellationToken 贯穿 AgentLoop先以现有 root Turn/sleep/Provider tests 锁定取消语义 |
| 2B | run/group 持久化、step execution gate、foreground child cancellation 和结果查询 | | 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 | | 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 卡片呈现 | | 4 | typed TurnMailbox、emit_signal、steer admission以及同一 `AgentEventUpdated` 的 Signal 卡片呈现 |
| 5 | root Turn wake-aware sleepsub-run 保持 timer/cancellation-only | | 5 | root Turn wake-aware sleepsub-run 保持 timer/cancellation-only |

View File

@ -13,8 +13,8 @@ PicoBot 是一个基于 Rust 的个人 AI 助手运行时,包含本地 Gateway
| 文件 | 内容 | | 文件 | 内容 |
|------|------| |------|------|
| `references/config.md` | 配置字段详解providers、models、agents、gateway、client、channels、memory、mcp、browser | | `references/config.md` | 配置字段详解providers、models、agents、agent_orchestration、gateway、client、channels、memory、mcp、browser |
| `references/db-schema.md` | 数据库表结构与运行约束sessions、messages、memories、scheduled_jobs、job_runs、llm_calls、background_tasks | | `references/db-schema.md` | 数据库表结构与运行约束sessions、messages、memories、task plans/items、scheduled_jobs、job_runs、llm_calls、agent run/inbox/state |
| `references/architecture.md` | 核心架构消息并发、会话系统、持久化、生命周期、上下文压缩、记忆、MCP、子 Agent | | `references/architecture.md` | 核心架构消息并发、会话系统、持久化、生命周期、上下文压缩、记忆、MCP、子 Agent |
| `references/faq.md` | 常见问题模型切换、渠道添加、Skill 安装、历史查询、定时任务、MCP 等 | | `references/faq.md` | 常见问题模型切换、渠道添加、Skill 安装、历史查询、定时任务、MCP 等 |
| `references/commands.md` | 常用命令编译、启动网关、Docker/WebUI 设备配对、启动客户端、运行测试 | | `references/commands.md` | 常用命令编译、启动网关、Docker/WebUI 设备配对、启动客户端、运行测试 |

View File

@ -50,7 +50,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
- 每个活动 Turn 独占一个 TurnSink平台 message ID 和 reaction 清理状态只存在于 sink 内 - 每个活动 Turn 独占一个 TurnSink平台 message ID 和 reaction 清理状态只存在于 sink 内
- Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext` - Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext`
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry - MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry
- 具名子 Agent 从运行代不可变 `AgentCatalog` 加载Definition 固定 Provider profile、工具/Skill allowlist、委托边与限制新工具默认 RootOnly。支持单个/批量 foreground批量并发、按请求顺序返回和显式父子授权Root 对具名 Agent 的 background 单任务走 durable run/inbox + continuation 投递(结果不再直接通知 Channel。禁用编排时旧 general background 仍通过 MessageBus 直接通知原会话 - 具名子 Agent 从运行代不可变 `AgentCatalog` 加载Definition 固定 Provider/Model、工具/Skill allowlist、委托边与限制工具集完全由定义文件的 `tools` 列表决定(管理员显式授权),`delegate`/`emit_signal`/`get_skill`/`agent_task` 为运行时注入不可静态声明。支持单个/批量 foreground 和显式父子授权Root 对具名 Agent 的 background单任务或批量走 durable run/inbox + continuation 投递,每个 run 独立完成、空闲时完成即返回。内置 general-purpose 定义随二进制释放到 `~/.picobot/agents/`WebUI「子 Agent」页可增删改与启停定义
- 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划 - 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
- WebUI 聊天复用 `/ws``cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入 - WebUI 聊天复用 `/ws``cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
- WebUI 使用 Svelte 5 + ViteBits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物 - WebUI 使用 Svelte 5 + ViteBits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
@ -301,7 +301,7 @@ signal:
- `id``[a-z][a-z0-9_-]{0,63}`,文件名必须与 id 一致;`root`/`main`/`default`/`general` 为保留名。重复 ID、大小写折叠冲突、越界 symlink 或引用错误(未知 Provider profile、未注册/不可委托工具、未知 skill 或 delegate 目标)会拒绝整个候选运行代,绝不静默裁剪。 - `id``[a-z][a-z0-9_-]{0,63}`,文件名必须与 id 一致;`root`/`main`/`default`/`general` 为保留名。重复 ID、大小写折叠冲突、越界 symlink 或引用错误(未知 Provider profile、未注册/不可委托工具、未知 skill 或 delegate 目标)会拒绝整个候选运行代,绝不静默裁剪。
- `llm_profile`:引用 `config.json``agents` keyDefinition 绑定 Provider 与模型,运行中不热切换。 - `llm_profile`:引用 `config.json``agents` keyDefinition 绑定 Provider 与模型,运行中不热切换。
- `tools`/`skills`固定 allowlist。`skills` 声明要求工具集含 `get_skill`,且只注入该 allowlist。新工具默认 RootOnly当前可委托工具为 `file_read``file_search``content_search``web_fetch``calculator`、普通 `browser` 动作和 `sleep`。 - `tools`/`skills``tools` 直接指定该 Agent 可用的全部普通工具;`skills` 声明要求工具集含 `get_skill`,且只注入该 allowlist。运行时注入工具`delegate`/`emit_signal`/`agent_task`)不能写进 `tools`。
- `delegates`:出边白名单,运行时还校验 ancestry 重复、`max_tree_depth` 与树级 `max_runs_per_tree` 预算。 - `delegates`:出边白名单,运行时还校验 ancestry 重复、`max_tree_depth` 与树级 `max_runs_per_tree` 预算。
- `signal`:可选信号契约。带该块的 run 才获得 `emit_signal` 工具fail-closed`delivery: steer` 使信号在活动 Turn 的安全边界注入主 Agent`queue` 走 continuation。 - `signal`:可选信号契约。带该块的 run 才获得 `emit_signal` 工具fail-closed`delivery: steer` 使信号在活动 Turn 的安全边界注入主 Agent`queue` 走 continuation。
- 角色正文(`---` 之后)即 `role_prompt`,与 frontmatter 一起做 SHA-256 `definition_hash` 快照。 - 角色正文(`---` 之后)即 `role_prompt`,与 frontmatter 一起做 SHA-256 `definition_hash` 快照。
@ -315,7 +315,7 @@ signal:
- 后台 run 内可调用 `emit_signal`key/severity/summary/details/dedupe_key总数、速率、burst、severity allowlist、载荷大小/深度与冷却窗去重均由契约强制steer 信号经两阶段 admissionclaim → mailbox 预留 → admit(turn_id) → 激活)注入当前 Turn`/stop` 时按 token 条件释放回 pending绝不静默丢弃。 - 后台 run 内可调用 `emit_signal`key/severity/summary/details/dedupe_key总数、速率、burst、severity allowlist、载荷大小/深度与冷却窗去重均由契约强制steer 信号经两阶段 admissionclaim → mailbox 预留 → admit(turn_id) → 激活)注入当前 Turn`/stop` 时按 token 条件释放回 pending绝不静默丢弃。
- 每个 run 的完成事件 payload 携带该 run 已发出的 signal IDs主 Agent 可识别重复报告。 - 每个 run 的完成事件 payload 携带该 run 已发出的 signal IDs主 Agent 可识别重复报告。
未启用编排或省略 target 时使用旧 general 兼容路径(结果带迁移提示)。其工具也只能取旧默认集合与 Delegatable 策略的交集;旧后台任务写入 `background_tasks` 表,完成后通过原 channel/chat 直接通知,默认 24 小时后清理,不具备 durable inbox 语义,等待一个版本观察后随旧适配器移除 旧匿名 general 兼容路径已移除:委托必须指定具名 `target`
后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册Gateway 关停时先收到取消信号,再在总宽限期内清理。 后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册Gateway 关停时先收到取消信号,再在总宽限期内清理。

View File

@ -72,7 +72,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `max_user_turn_burst_before_inbox` | 4 | 用户 Turn 公平调度阈值 | | `max_user_turn_burst_before_inbox` | 4 | 用户 Turn 公平调度阈值 |
| `max_inbox_wait_secs` | 30 | inbox 最大等待阈值 | | `max_inbox_wait_secs` | 30 | inbox 最大等待阈值 |
已实现:具名 foreground 与 backgroundRoot 单任务)、不同 `llm_profile`、固定工具/Skill allowlist、批量并发、父子委托边校验、durable inbox continuation、`emit_signal`queue/steer、run quota 与 step gate。未开放background 批量、子 Agent 发起的 background、`idempotency_key` 工具入口。未启用编排时旧 general background 兼容路径保持可用(带迁移提示,等待一个版本观察后移除) 已实现:具名 foreground 与 backgroundRoot 单任务或批量)、内联 `provider`/`model``llm_profile`、工具集由定义文件 `tools` 决定、批量并发、父子委托边校验、durable inbox continuation(空闲时完成即返回)`emit_signal`queue/steer、run quota 与 step gate、内置 general-purpose 定义与 WebUI「子 Agent」管理页。未开放:子 Agent 发起的 background、`idempotency_key` 工具入口。旧匿名 general 兼容路径已移除,委托必须指定具名 `target`
## gateway 字段 ## gateway 字段
@ -84,7 +84,6 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 | | `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 |
| `session_db_path` | string | - | SQLite 数据库路径,默认在 workspace 下 | | `session_db_path` | string | - | SQLite 数据库路径,默认在 workspace 下 |
| `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 | | `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 |
| `max_concurrent_background_tasks` | int | 10 | delegate 后台子任务最大并发数 |
| `scheduler` | object | - | 调度器配置 | | `scheduler` | object | - | 调度器配置 |
### gateway.scheduler 字段 ### gateway.scheduler 字段

View File

@ -2,7 +2,7 @@
数据库为 SQLite默认位于 workspace 下的 `picobot.db` 数据库为 SQLite默认位于 workspace 下的 `picobot.db`
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout连接池最多 8 个连接。当前 `PRAGMA user_version=6`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。 连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout连接池最多 8 个连接。当前 `PRAGMA user_version=8`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
## sessions 表 ## sessions 表
@ -55,55 +55,13 @@
`(session_id, seq)` 有唯一索引,防止并发写入重复序号。删除 session 会通过外键级联删除 messages。索引 `(session_id, client_visibility, seq)` 支撑按可见性分层查询。 `(session_id, seq)` 有唯一索引,防止并发写入重复序号。删除 session 会通过外键级联删除 messages。索引 `(session_id, client_visibility, seq)` 支撑按可见性分层查询。
## background_tasks 表legacy 兼容,只读过渡 ## agent_runs 表schema v8Agent 编排
旧 general无 targetdelegate 后台子任务表,由 legacy 适配器写入、`/api/tasks` 只读展示,等待一个版本观察后随旧适配器一起移除。具名 Agent 的后台运行不再写此表。`session_id` 不使用数据库外键,因为 session 使用软删除,关联关系由应用层维护。 每次具名委托foreground 与 background 一致)先落库再执行;`execution_id` 条件更新保证迟到结果丢弃。批量委托只是多个 run 的集合不再存在组头schema v7 的 `agent_run_groups` 表已删除)。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | TEXT PK | 后台任务 ID |
| `session_id` | TEXT | 所属会话 |
| `channel` | TEXT | 回传渠道 |
| `chat_id` | TEXT | 回传目标对话 |
| `prompt` | TEXT | 子任务提示 |
| `allowed_tools` | TEXT | 允许工具 JSON |
| `status` | TEXT | pending / running / completed / failed / cancelled |
| `result` | TEXT | 执行结果 |
| `error` | TEXT | 错误信息 |
| `tool_calls_count` | INTEGER | 工具调用次数 |
| `iterations` | INTEGER | Agent 迭代次数 |
| `started_at` | INTEGER | 开始时间 |
| `finished_at` | INTEGER | 结束时间 |
| `created_at` | INTEGER | 创建时间 |
## agent_run_groups 表schema v6Agent 编排)
批量委托的组头。单任务委托不建组;`completion_policy` 决定 background 完成事件形态(当前 background 批量未开放,组仅用于批量 foreground
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | TEXT PK | 组 ID |
| `root_session_id` | TEXT | 根会话(软删除,无级联外键,由应用层收敛) |
| `caller_run_id` | TEXT | 发起方 run IDNULL 表示 Root 发起) |
| `caller_scope_id` | TEXT | 幂等作用域Root 固定字面量 `"ROOT"` |
| `idempotency_key` | TEXT | 幂等键(当前工具未开放,预留) |
| `mode` | TEXT | foreground / background |
| `completion_policy` | TEXT | all / each |
| `expected_runs` / `terminal_runs` / `abnormal_runs` | INTEGER | 组内 run 计数 |
| `completion_slot_reserved` | INTEGER | 是否预留 background completion 槽 |
| `completion_delivery` / `failure_delivery` | TEXT | 组完成/失败投递 lanequeue/steer |
| `status` | TEXT | queued / running / completed / partial / failed / timed_out / cancelled / interrupted |
| `deadline_at` / `runtime_generation` / `revision` | INTEGER | 截止、运行代、客户端投影修订号 |
| `created_at` / `updated_at` / `finished_at` | INTEGER | 时间线 |
## agent_runs 表schema v6Agent 编排)
每次具名委托foreground 与 background 一致)先落库再执行;`execution_id` 条件更新保证迟到结果丢弃。
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| `id` | TEXT PK | run ID | | `id` | TEXT PK | run ID |
| `group_id` | TEXT FK | 所属组RESTRICT |
| `root_session_id` | TEXT | 根会话 | | `root_session_id` | TEXT | 根会话 |
| `parent_run_id` | TEXT FK | 父 runRESTRICTNULL 表示 Root 直接委托 | | `parent_run_id` | TEXT FK | 父 runRESTRICTNULL 表示 Root 直接委托 |
| `caller_agent_id` / `caller_scope_id` | TEXT | 调用方身份Root 的 caller_scope_id 固定 `"ROOT"` | | `caller_agent_id` / `caller_scope_id` | TEXT | 调用方身份Root 的 caller_scope_id 固定 `"ROOT"` |
@ -116,7 +74,6 @@
| `task` / `context_json` | TEXT | 任务与调用方上下文 | | `task` / `context_json` | TEXT | 任务与调用方上下文 |
| `budget_json` | TEXT | 树级剩余预算 | | `budget_json` | TEXT | 树级剩余预算 |
| `signal_contract_json` / `signal_delivery` | TEXT | Definition 信号契约快照与投递 lanequeue/steer | | `signal_contract_json` / `signal_delivery` | TEXT | Definition 信号契约快照与投递 lanequeue/steer |
| `completion_delivery` / `failure_delivery` | TEXT | 完成/失败投递 lane当前单任务 background 恒为 queue保留给批量 |
| `status` | TEXT | queued / running / waiting_children / completed / failed / timed_out / cancelled / interrupted | | `status` | TEXT | queued / running / waiting_children / completed / failed / timed_out / cancelled / interrupted |
| `result` / `error` | TEXT | 终态完整结果/错误get_result 与 tool 结果同源) | | `result` / `error` | TEXT | 终态完整结果/错误get_result 与 tool 结果同源) |
| `prompt_tokens` / `completion_tokens` / `cost` | INTEGER/REAL | Provider usage | | `prompt_tokens` / `completion_tokens` / `cost` | INTEGER/REAL | Provider usage |
@ -128,7 +85,7 @@
索引:`execution_id` 唯一、`(root_session_id, caller_scope_id, idempotency_key)` 部分唯一、`(root_session_id, created_at DESC)``(parent_run_id, created_at)``(runtime_generation, status, deadline_at)`(恢复扫描)。 索引:`execution_id` 唯一、`(root_session_id, caller_scope_id, idempotency_key)` 部分唯一、`(root_session_id, created_at DESC)``(parent_run_id, created_at)``(runtime_generation, status, deadline_at)`(恢复扫描)。
## agent_session_state 表schema v6Agent 编排) ## agent_session_state 表schema v8Agent 编排)
每根会话一行inbox 容量与客户端 revision 的权威计数: 每根会话一行inbox 容量与客户端 revision 的权威计数:
@ -142,16 +99,16 @@
容量判断在同一写事务内做条件 `UPDATE``pending + reserved + 新增 <= 上限`),杜绝并发 `COUNT(*)` 漂移。 容量判断在同一写事务内做条件 `UPDATE``pending + reserved + 新增 <= 上限`),杜绝并发 `COUNT(*)` 漂移。
## agent_inbox_events 表schema v6Agent 编排) ## agent_inbox_events 表schema v8Agent 编排)
background 完成/信号投递的唯一事实源:`pending → leased → admitted → consumed`,失败按 token 释放回 pending超限进 dead-letter崩溃靠 lease 过期恢复。 background 完成/信号投递的唯一事实源:`pending → leased → admitted → consumed`,失败按 token 释放回 pending超限进 dead-letter崩溃靠 lease 过期恢复。
| 字段 | 说明 | | 字段 | 说明 |
|------|------| |------|------|
| `id` | TEXT PK | | `id` | TEXT PK |
| `root_session_id` / `scope_kind` / `scope_id` | 归属run 或 groupCHECK 互斥) | | `root_session_id` | TEXT | 根会话 |
| `run_id` / `group_id` | TEXT FKRESTRICT | | `run_id` | TEXT FK NOT NULLRESTRICT |
| `event_type` | signal / completion / group_completion | | `event_type` | signal / completion |
| `event_key` | 去重键signal 含冷却窗口 id | | `event_key` | 去重键signal 含冷却窗口 id |
| `delivery` | queue / steer | | `delivery` | queue / steer |
| `requires_continuation` | 是否反向启动 continuation Turncancel 产物为 false | | `requires_continuation` | 是否反向启动 continuation Turncancel 产物为 false |
@ -164,7 +121,7 @@ background 完成/信号投递的唯一事实源:`pending → leased → admit
| `updated_at` | 最后更新时间 | | `updated_at` | 最后更新时间 |
| `created_at` / `consumed_at` / `superseded_at` / `dead_lettered_at` / `fallback_notified_at` / `fallback_suppressed_reason` | 状态时间线 | | `created_at` / `consumed_at` / `superseded_at` / `dead_lettered_at` / `fallback_notified_at` / `fallback_suppressed_reason` | 状态时间线 |
`(scope_kind, scope_id, event_type, event_key)` 唯一signal 冷却窗去重)。消费/死信会同步递减 `agent_session_state.pending_event_count` `(run_id, event_type, event_key)` 唯一signal 冷却窗去重)。消费/死信会同步递减 `agent_session_state.pending_event_count`
## task_plans / task_items 表 ## task_plans / task_items 表

View File

@ -137,19 +137,15 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
| 参数 | 必填 | 说明 | | 参数 | 必填 | 说明 |
|------|------|------| |------|------|------|
| `action` | 否 | 默认 `run`;迁移期仍支持 `check_task`, `cancel_task`, `list_tasks` |
| `target` | 具名 Agent 必填 | `root_delegates` 或当前 Agent Definition 允许的目标 ID | | `target` | 具名 Agent 必填 | `root_delegates` 或当前 Agent Definition 允许的目标 ID |
| `task` | 单任务必填 | 明确、独立、可验收的子任务;旧 `prompt` 仅兼容解析 | | `task` | 单任务必填 | 明确、独立、可验收的子任务 |
| `context` | 否 | 子 Agent 所需的显式事实;不会继承完整主会话历史 | | `context` | 否 | 子 Agent 所需的显式事实;不会继承完整主会话历史 |
| `mode` | 否 | `foreground`(默认)或 `background`;批量并发不是第三种 mode | | `mode` | 否 | `foreground`(默认)或 `background`;批量并发不是第三种 mode |
| `tasks` | 批量必填 | 子任务数组foreground 并发执行、结果保持请求顺序 | | `tasks` | 批量必填 | 子任务数组foreground 并发执行、结果保持请求顺序 |
| `allowed_tools` | 否 | 迁移字段,只能收窄具名 Definition 或旧 general 默认集,不能扩权 | | `allowed_tools` | 否 | 只能收窄具名 Definition 的工具集,不能扩权 |
| `max_iterations` | 否 | 旧 general 兼容限制;具名 Agent使用 Definition limits |
| `timeout_secs` | 否 | 旧 general 兼容限制;具名 Agent使用 Definition limits |
| `task_id` | 查询/取消必填 | 后台任务 ID |
| `plan_item_id` | 否 | 绑定当前计划子项;批量数组中的每项可分别绑定 | | `plan_item_id` | 否 | 绑定当前计划子项;批量数组中的每项可分别绑定 |
`inline` 映射到 foreground`parallel` 映射到 foreground + `tasks[]`,但不再出现在 tool schema。具名 background 单任务(`target` + `mode=background`)经 durable run/inbox 接纳:先落 `agent_runs` 并预留 inbox completion slot完成后由主 Agent 的 continuation Turn 处理结果,不再直接发 Channel 通知;background 批量、子 Agent 发起的 background 以及 general background 的新 durable 路径尚未开放general 仍走旧 direct notification 兼容路径)。后台运行可以在任务中调用 `emit_signal` 发送结构化内部信号(队列或 steer 投递Steer 信号会在当前 Turn 的安全边界注入主 Agent`agent_task.cancel` 会把该 run 未消费的普通信号标记 superseded。 子 Agent 的工具集完全由其定义文件(`~/.picobot/agents/*.md`)的 `tools` 列表决定,可直接内联 `provider`/`model` 指定模型。具名 background`target` + `mode=background`,单任务或 `tasks[]` 批量)经 durable run/inbox 接纳:每个 run 先落 `agent_runs` 并预留 inbox completion slot完成后由主 Agent 的 continuation Turn 处理结果,不再直接发 Channel 通知;批量并发执行、每个 run 独立返回(无用户输入积压时完成即返回)。子 Agent 发起的 background 尚未开放。后台运行可以在任务中调用 `emit_signal` 发送结构化内部信号(队列或 steer 投递Steer 信号会在当前 Turn 的安全边界注入主 Agent`agent_task.cancel` 会把该 run 未消费的普通信号标记 superseded。
## agent_task — 具名 Agent Run 查询与控制 ## agent_task — 具名 Agent Run 查询与控制

View File

@ -49,7 +49,7 @@
"agent_orchestration": { "agent_orchestration": {
"enabled": false, "enabled": false,
"definitions_dir": "agents", "definitions_dir": "agents",
"root_delegates": [], "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,

View File

@ -12,7 +12,10 @@ mod embedded {
/// Install built-in Agent definitions into `<config_dir>/agents/`. Files /// Install built-in Agent definitions into `<config_dir>/agents/`. Files
/// that already exist (user-modified or user-created) are left untouched. /// that already exist (user-modified or user-created) are left untouched.
pub fn install_builtin_agents(config_dir: &Path, profiles: &std::collections::HashMap<String, LLMProviderConfig>) { pub fn install_builtin_agents(
config_dir: &Path,
profiles: &std::collections::HashMap<String, LLMProviderConfig>,
) {
let agents_dir = config_dir.join("agents"); let agents_dir = config_dir.join("agents");
if let Err(error) = std::fs::create_dir_all(&agents_dir) { if let Err(error) = std::fs::create_dir_all(&agents_dir) {
tracing::warn!(dir = %agents_dir.display(), error = %error, "Failed to create agents directory"); tracing::warn!(dir = %agents_dir.display(), error = %error, "Failed to create agents directory");

View File

@ -2,9 +2,11 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use crate::config::{AgentOrchestrationConfig, LLMProviderConfig, expand_path}; use crate::config::{
AgentOrchestrationConfig, LLMProviderConfig, ModelConfig, ProviderConfig, expand_path,
};
use crate::skills::SkillsLoader; use crate::skills::SkillsLoader;
use crate::tools::{DelegationPolicy, ToolRegistry}; use crate::tools::ToolRegistry;
use super::definition::{AgentDefinition, AgentDefinitionError, parse_definition}; use super::definition::{AgentDefinition, AgentDefinitionError, parse_definition};
@ -18,6 +20,10 @@ pub enum AgentCatalogError {
Directory(String), Directory(String),
#[error("Agent '{agent}' references unknown Provider profile '{profile}'")] #[error("Agent '{agent}' references unknown Provider profile '{profile}'")]
UnknownProfile { agent: String, profile: String }, UnknownProfile { agent: String, profile: String },
#[error("Agent '{agent}' references unknown provider '{provider}'")]
UnknownProvider { agent: String, provider: String },
#[error("Agent '{agent}' references unknown model '{model}'")]
UnknownModel { agent: String, model: String },
#[error("Agent '{agent}' references invalid tool '{tool}': {reason}")] #[error("Agent '{agent}' references invalid tool '{tool}': {reason}")]
InvalidTool { InvalidTool {
agent: String, agent: String,
@ -52,10 +58,14 @@ impl AgentCatalog {
} }
} }
#[allow(clippy::too_many_arguments)]
pub fn load( pub fn load(
config: &AgentOrchestrationConfig, config: &AgentOrchestrationConfig,
config_dir: &Path, config_dir: &Path,
provider_profiles: &HashMap<String, LLMProviderConfig>, provider_profiles: &HashMap<String, LLMProviderConfig>,
providers: &HashMap<String, ProviderConfig>,
models: &HashMap<String, ModelConfig>,
workspace_dir: &Path,
tools: &ToolRegistry, tools: &ToolRegistry,
skills_loader: &SkillsLoader, skills_loader: &SkillsLoader,
runtime_generation: u64, runtime_generation: u64,
@ -93,15 +103,18 @@ 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 yaml = read_profile_name(&path)?; let spec = read_provider_spec(&path)?;
let provider = provider_profiles.get(&yaml).cloned().ok_or_else(|| { // Disabled definitions stay on disk for the management UI but
AgentCatalogError::UnknownProfile { // never enter the active catalog.
agent: path.display().to_string(), if !spec.enabled {
profile: yaml.clone(), disabled_ids.insert(spec.id);
continue;
} }
})?; let provider =
resolve_provider(&spec, provider_profiles, providers, models, workspace_dir)?;
let definition = Arc::new(parse_definition(&path, Arc::new(provider))?); let definition = Arc::new(parse_definition(&path, Arc::new(provider))?);
if definitions.contains_key(&definition.id) { if definitions.contains_key(&definition.id) {
return Err(AgentCatalogError::Config(format!( return Err(AgentCatalogError::Config(format!(
@ -148,7 +161,7 @@ impl AgentCatalog {
)); ));
} }
for target in &root_delegates { for target in &root_delegates {
if !definitions.contains_key(target) { if !definitions.contains_key(target) && !disabled_ids.contains(target) {
return Err(AgentCatalogError::UnknownDelegate { return Err(AgentCatalogError::UnknownDelegate {
agent: "ROOT".to_string(), agent: "ROOT".to_string(),
target: target.clone(), target: target.clone(),
@ -187,7 +200,7 @@ impl AgentCatalog {
} }
pub fn root_can_delegate(&self, target: &str) -> bool { pub fn root_can_delegate(&self, target: &str) -> bool {
self.root_delegates.contains(target) self.root_delegates.contains(target) && self.definitions.contains_key(target)
} }
pub fn can_delegate(&self, caller: &str, target: &str) -> bool { pub fn can_delegate(&self, caller: &str, target: &str) -> bool {
@ -229,7 +242,20 @@ fn definition_paths(directory: &Path) -> Result<Vec<PathBuf>, AgentCatalogError>
Ok(paths) Ok(paths)
} }
fn read_profile_name(path: &Path) -> Result<String, AgentCatalogError> { /// Provider/model/enabled fields read from a definition's frontmatter before
/// the full definition is parsed, so the catalog can resolve the provider
/// config and skip disabled definitions in one pass.
struct ProviderSpec {
id: String,
llm_profile: Option<String>,
provider: Option<String>,
model: Option<String>,
token_limit: Option<usize>,
max_tool_iterations: Option<usize>,
enabled: bool,
}
fn read_provider_spec(path: &Path) -> Result<ProviderSpec, AgentCatalogError> {
let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io { let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io {
path: path.display().to_string(), path: path.display().to_string(),
source, source,
@ -267,11 +293,78 @@ fn read_profile_name(path: &Path) -> Result<String, AgentCatalogError> {
)) ))
})?; })?;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct ProfileOnly { struct Spec {
llm_profile: String, id: String,
llm_profile: Option<String>,
provider: Option<String>,
model: Option<String>,
token_limit: Option<usize>,
max_tool_iterations: Option<usize>,
#[serde(default = "crate::agent::definition::default_true")]
enabled: bool,
} }
let parsed: ProfileOnly = serde_yaml::from_str(yaml).map_err(AgentDefinitionError::Yaml)?; let parsed: Spec = serde_yaml::from_str(yaml).map_err(AgentDefinitionError::Yaml)?;
Ok(parsed.llm_profile) Ok(ProviderSpec {
id: parsed.id,
llm_profile: parsed.llm_profile,
provider: parsed.provider,
model: parsed.model,
token_limit: parsed.token_limit,
max_tool_iterations: parsed.max_tool_iterations,
enabled: parsed.enabled,
})
}
fn resolve_provider(
spec: &ProviderSpec,
provider_profiles: &HashMap<String, LLMProviderConfig>,
providers: &HashMap<String, ProviderConfig>,
models: &HashMap<String, ModelConfig>,
workspace_dir: &Path,
) -> Result<LLMProviderConfig, AgentCatalogError> {
let inline = spec.provider.is_some() || spec.model.is_some();
if inline {
let provider_name = spec.provider.as_deref().unwrap_or_default();
let model_name = spec.model.as_deref().unwrap_or_default();
let provider =
providers
.get(provider_name)
.ok_or_else(|| AgentCatalogError::UnknownProvider {
agent: spec.id.clone(),
provider: provider_name.to_string(),
})?;
let model = models
.get(model_name)
.ok_or_else(|| AgentCatalogError::UnknownModel {
agent: spec.id.clone(),
model: model_name.to_string(),
})?;
return Ok(LLMProviderConfig {
provider_type: provider.provider_type.clone(),
name: provider_name.to_string(),
base_url: provider.base_url.clone(),
api_key: provider.api_key.clone(),
extra_headers: provider.extra_headers.clone(),
model_id: model.model_id.clone(),
temperature: model.temperature,
max_tokens: model.max_tokens,
model_extra: model.extra.clone(),
max_tool_iterations: spec.max_tool_iterations.unwrap_or(99),
token_limit: spec.token_limit.unwrap_or(128_000),
workspace_dir: workspace_dir.to_path_buf(),
input_types: model.input_type.clone(),
price_input_per_million: None,
price_output_per_million: None,
});
}
let profile = spec.llm_profile.as_deref().unwrap_or_default();
provider_profiles
.get(profile)
.cloned()
.ok_or_else(|| AgentCatalogError::UnknownProfile {
agent: spec.id.clone(),
profile: profile.to_string(),
})
} }
fn validate_definition_tools( fn validate_definition_tools(
@ -286,14 +379,17 @@ fn validate_definition_tools(
tool: name.clone(), tool: name.clone(),
reason: "tool is not registered in the prepared runtime".to_string(), reason: "tool is not registered in the prepared runtime".to_string(),
})?; })?;
let allowed = tool.delegation_policy() == DelegationPolicy::Delegatable // Which tools a named Agent receives is decided by its definition
|| (name == "get_skill" // file alone. Runtime-injected tools (delegate/emit_signal/
&& tool.delegation_policy() == DelegationPolicy::RuntimeInjected); // get_skill/agent_task) are assembled from dedicated fields
if !allowed { // (delegates/signal/skills) and must never appear in `tools`;
// `get_skill` is the one exception: listing it turns on the scoped
// skill wrapper, which is injected at resolve time.
if tool.runtime_injected() && name != "get_skill" {
return Err(AgentCatalogError::InvalidTool { return Err(AgentCatalogError::InvalidTool {
agent: definition.id.clone(), agent: definition.id.clone(),
tool: name.clone(), tool: name.clone(),
reason: format!("policy is {:?}", tool.delegation_policy()), reason: "runtime-injected tool cannot be declared in a definition".to_string(),
}); });
} }
} }
@ -303,7 +399,7 @@ fn validate_definition_tools(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::tools::{CalculatorTool, FileWriteTool}; use crate::tools::{CalculatorTool, GetSkillTool};
fn provider() -> LLMProviderConfig { fn provider() -> LLMProviderConfig {
LLMProviderConfig { LLMProviderConfig {
@ -380,8 +476,18 @@ mod tests {
); );
let profiles = HashMap::from([("research".to_string(), provider())]); let profiles = HashMap::from([("research".to_string(), provider())]);
let catalog = let catalog = AgentCatalog::load(
AgentCatalog::load(&config(), root.path(), &profiles, &tools, &loader, 7).unwrap(); &config(),
root.path(),
&profiles,
&HashMap::new(),
&HashMap::new(),
root.path(),
&tools,
&loader,
7,
)
.unwrap();
assert!(catalog.root_can_delegate("researcher")); assert!(catalog.root_can_delegate("researcher"));
assert!(catalog.can_delegate("researcher", "reviewer")); assert!(catalog.can_delegate("researcher", "reviewer"));
@ -393,22 +499,61 @@ mod tests {
} }
#[test] #[test]
fn catalog_rejects_root_only_tool() { fn catalog_accepts_any_ordinary_tool_but_rejects_runtime_injected() {
// Ordinary tools (including side-effecting ones like file_write) are
// now accepted purely by the definition file.
let root = tempfile::tempdir().unwrap(); 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"], &[]);
let tools = ToolRegistry::new(); let tools = ToolRegistry::new();
tools.register(FileWriteTool::new()); tools.register(crate::tools::FileWriteTool::new());
let loader = SkillsLoader::new_for_testing( let loader = SkillsLoader::new_for_testing(
root.path().join("skills"), root.path().join("skills"),
root.path().join("external-skills"), root.path().join("external-skills"),
); );
let profiles = HashMap::from([("research".to_string(), provider())]); let profiles = HashMap::from([("research".to_string(), provider())]);
AgentCatalog::load(
&config(),
root.path(),
&profiles,
&HashMap::new(),
&HashMap::new(),
root.path(),
&tools,
&loader,
1,
)
.unwrap();
let error = // Runtime-injected tools (e.g. delegate) must not be declared in a
AgentCatalog::load(&config(), root.path(), &profiles, &tools, &loader, 1).unwrap_err(); // definition's `tools` list; get_skill remains the one exception.
let root2 = tempfile::tempdir().unwrap();
assert!(matches!(error, AgentCatalogError::InvalidTool { .. })); std::fs::create_dir(root2.path().join("agents")).unwrap();
write_agent(root2.path(), "researcher", &["get_skill"], &[]);
let tools2 = ToolRegistry::new();
tools2.register(GetSkillTool::new(Arc::new(
crate::skills::SkillsLoader::new_for_testing(
root2.path().join("skills"),
root2.path().join("external-skills"),
),
)));
let loader2 = SkillsLoader::new_for_testing(
root2.path().join("skills"),
root2.path().join("external-skills"),
);
let profiles2 = HashMap::from([("research".to_string(), provider())]);
AgentCatalog::load(
&config(),
root2.path(),
&profiles2,
&HashMap::new(),
&HashMap::new(),
root2.path(),
&tools2,
&loader2,
1,
)
.unwrap();
} }
#[cfg(unix)] #[cfg(unix)]
@ -434,7 +579,18 @@ mod tests {
let profiles = HashMap::from([("research".to_string(), provider())]); let profiles = HashMap::from([("research".to_string(), provider())]);
assert!( assert!(
AgentCatalog::load(&config(), root.path(), &profiles, &tools, &loader, 1,).is_err() AgentCatalog::load(
&config(),
root.path(),
&profiles,
&HashMap::new(),
&HashMap::new(),
root.path(),
&tools,
&loader,
1
)
.is_err()
); );
} }
} }

View File

@ -14,8 +14,8 @@ use crate::agent::sub_agent::{
use crate::storage::Storage; use crate::storage::Storage;
use crate::storage::agent_inbox::AgentEventType; use crate::storage::agent_inbox::AgentEventType;
use crate::storage::agent_run::{ use crate::storage::agent_run::{
AcceptAgentRequest, AcceptedAgentRuns, AgentCompletionPolicy, AgentRunMode, AgentRunRecord, AcceptAgentRequest, AcceptedAgentRuns, AgentRunMode, AgentRunRecord, AgentRunStatus,
AgentRunStatus, AgentTerminalOutcome, NewAgentGroup, NewAgentRun, AgentTerminalOutcome, NewAgentRun,
}; };
use crate::tools::ToolExecutionContext; use crate::tools::ToolExecutionContext;
use crate::tools::emit_signal::{SignalAccepted, SignalAcceptedStatus, SignalInput}; use crate::tools::emit_signal::{SignalAccepted, SignalAcceptedStatus, SignalInput};
@ -26,6 +26,12 @@ use crate::tools::emit_signal::{SignalAccepted, SignalAcceptedStatus, SignalInpu
/// runs reserve an inbox completion slot at admission; their completion event /// runs reserve an inbox completion slot at admission; their completion event
/// is materialized by the terminal commit and delivered through the Session /// is materialized by the terminal commit and delivered through the Session
/// continuation lane instead of a direct channel notification. /// continuation lane instead of a direct channel notification.
/// Admission result for a background batch: the run ids actually spawned.
#[derive(Debug, Clone)]
pub struct BackgroundAdmission {
pub run_ids: Vec<String>,
}
pub struct AgentCoordinator { pub struct AgentCoordinator {
storage: Arc<Storage>, storage: Arc<Storage>,
manager: Arc<SubAgentManager>, manager: Arc<SubAgentManager>,
@ -85,23 +91,34 @@ impl AgentCoordinator {
/// Admit a named background run for the root caller and spawn its runner. /// Admit a named background run for the root caller and spawn its runner.
/// Completion is guaranteed by the reserved inbox slot; the returned ID /// Completion is guaranteed by the reserved inbox slot; the returned ID
/// is only valid when every durable step succeeded. /// is only valid when every durable step succeeded.
/// Admit one or more named background runs and spawn their runners.
/// Returns immediately: run quota is acquired inside each runner (queuing
/// time counts toward the run timeout), and completion capacity is
/// reserved up front so no completion can ever be lost.
pub async fn delegate_background( pub async fn delegate_background(
self: &Arc<Self>, self: &Arc<Self>,
caller: &ToolExecutionContext, caller: &ToolExecutionContext,
config: SubAgentConfig, configs: Vec<SubAgentConfig>,
) -> Result<String, CoordinatorError> { ) -> Result<BackgroundAdmission, CoordinatorError> {
if caller.agent.is_some() { if caller.agent.is_some() {
return Err(CoordinatorError::Rejected( return Err(CoordinatorError::Rejected(
"nested background runs are not available yet; only the root Agent may delegate background work".to_string(), "nested background runs are not available yet; only the root Agent may delegate background work".to_string(),
)); ));
} }
if config.target.is_none() { if configs.is_empty() {
return Err(CoordinatorError::Rejected( return Err(CoordinatorError::Rejected(
"legacy general Agent is not persisted; named background targets only".to_string(), "background delegation requires at least one task".to_string(),
)); ));
} }
let run_id = Uuid::new_v4().to_string(); // Hard cap: a batch larger than the run quota would never execute
let resolved = self.manager.resolve_agent(&config, caller, &run_id)?; // concurrently, so reject it up front.
if configs.len() > self.execution_gate.max_concurrent_runs() {
return Err(CoordinatorError::Rejected(format!(
"background batch of {} runs exceeds max_concurrent_runs ({})",
configs.len(),
self.execution_gate.max_concurrent_runs()
)));
}
let root_session_id = caller let root_session_id = caller
.session_id .session_id
.clone() .clone()
@ -116,28 +133,28 @@ impl AgentCoordinator {
})?; })?;
let now = chrono::Utc::now().timestamp_millis(); let now = chrono::Utc::now().timestamp_millis();
// 0. Run quota + admission guard before any durable write; any // Resolve every target before any durable write so a bad request
// failure here releases everything without touching SQLite. The // fails closed without leaving orphan rows.
// permit stays with the runner until the terminal commit. let mut run_ids = Vec::with_capacity(configs.len());
let run_permit = self let mut resolved = Vec::with_capacity(configs.len());
.execution_gate for config in &configs {
.acquire_run(&root_session_id, &caller.cancellation) if config.target.is_none() {
.await return Err(CoordinatorError::Rejected(
.map_err(|error| CoordinatorError::Rejected(error.to_string()))?; "named background targets only".to_string(),
let activity = self.admission.try_enter().ok_or_else(|| { ));
CoordinatorError::Rejected( }
"gateway is draining for configuration reload and cannot accept background tasks" let run_id = Uuid::new_v4().to_string();
.to_string(), resolved.push(self.manager.resolve_agent(config, caller, &run_id)?);
) run_ids.push(run_id);
})?; }
// 1. Reserve the completion slot; failure means the inbox is full and // 1. Reserve one completion slot per run; failure rejects the whole
// nothing is admitted. // batch so nothing is admitted under capacity.
if self if self
.storage .storage
.reserve_completion_slots( .reserve_completion_slots(
&root_session_id, &root_session_id,
1, configs.len() as i64,
self.max_pending_inbox_events_per_session, self.max_pending_inbox_events_per_session,
now, now,
) )
@ -145,28 +162,31 @@ impl AgentCoordinator {
.is_none() .is_none()
{ {
return Err(CoordinatorError::Rejected( return Err(CoordinatorError::Rejected(
"inbox capacity exceeded; cannot accept another background run".to_string(), "inbox capacity exceeded; cannot accept the background batch".to_string(),
)); ));
} }
// 2. Persist the queued run atomically with the reservation. // 2. Persist the queued runs atomically with the reservation.
let accept = NewAgentRun { let mut runs = Vec::with_capacity(configs.len());
id: run_id.clone(), for (index, config) in configs.iter().enumerate() {
let resolution = &resolved[index];
runs.push(NewAgentRun {
id: run_ids[index].clone(),
root_session_id: root_session_id.clone(), root_session_id: root_session_id.clone(),
root_turn_id: caller.turn_id.clone(), root_turn_id: caller.turn_id.clone(),
parent_run_id: None, parent_run_id: None,
caller_agent_id: "ROOT".to_string(), caller_agent_id: "ROOT".to_string(),
caller_scope_id: "ROOT".to_string(), caller_scope_id: "ROOT".to_string(),
idempotency_key: None, idempotency_key: None,
agent_id: resolved.agent_id.clone().unwrap_or_default(), agent_id: resolution.agent_id.clone().unwrap_or_default(),
definition_hash: resolved.definition_hash.clone().unwrap_or_default(), definition_hash: resolution.definition_hash.clone().unwrap_or_default(),
provider_profile: resolved.llm_profile.clone().unwrap_or_default(), provider_profile: resolution.llm_profile.clone().unwrap_or_default(),
provider_name: resolved.provider_config.name.clone(), provider_name: resolution.provider_config.name.clone(),
model_id: resolved.provider_config.model_id.clone(), model_id: resolution.provider_config.model_id.clone(),
mode: AgentRunMode::Background, mode: AgentRunMode::Background,
depth: 1, depth: 1,
plan_item_id: config.plan_item_id.clone(), plan_item_id: config.plan_item_id.clone(),
execution_id: run_id.clone(), execution_id: run_ids[index].clone(),
task: config.prompt.clone(), task: config.prompt.clone(),
context_json: config.context.clone(), context_json: config.context.clone(),
budget_json: serde_json::json!({ budget_json: serde_json::json!({
@ -174,31 +194,28 @@ impl AgentCoordinator {
"remaining_depth": self.manager.catalog().max_tree_depth(), "remaining_depth": self.manager.catalog().max_tree_depth(),
}) })
.to_string(), .to_string(),
signal_contract_json: resolved signal_contract_json: resolution
.signal_contract .signal_contract
.as_ref() .as_ref()
.map(|contract| serde_json::to_string(contract).unwrap_or_default()), .map(|contract| serde_json::to_string(contract).unwrap_or_default()),
signal_delivery: resolved signal_delivery: resolution
.signal_contract .signal_contract
.as_ref() .as_ref()
.map(|contract| contract.delivery.as_str().to_string()), .map(|contract| contract.delivery.as_str().to_string()),
deadline_at: now + (resolved.timeout_secs * 1000) as i64, deadline_at: now + (resolution.timeout_secs * 1000) as i64,
runtime_generation: self.runtime_generation, runtime_generation: self.runtime_generation,
completion_slot_reserved: true, completion_slot_reserved: true,
}; });
}
match self match self
.storage .storage
.accept_agent_runs(AcceptAgentRequest { .accept_agent_runs(AcceptAgentRequest { runs, now })
group: None,
runs: vec![accept],
now,
})
.await? .await?
{ {
AcceptedAgentRuns::Accepted { .. } => {} AcceptedAgentRuns::Accepted { .. } => {}
AcceptedAgentRuns::Existing { .. } => { AcceptedAgentRuns::Existing { .. } => {
self.storage self.storage
.release_completion_slots(&root_session_id, 1, now) .release_completion_slots(&root_session_id, configs.len() as i64, now)
.await?; .await?;
return Err(CoordinatorError::Rejected( return Err(CoordinatorError::Rejected(
"background admission conflicted with an existing run id".to_string(), "background admission conflicted with an existing run id".to_string(),
@ -206,39 +223,47 @@ impl AgentCoordinator {
} }
} }
// 3. Register the cancellation token and spawn the runner. The // 3. Spawn one runner per run. Each runner acquires its own run
// activity guard and run quota are held for the whole run, not // quota permit and admission guard; delegate returns immediately.
// released when `delegate_background` returns. let coordinator = self.clone();
let mut spawned_ids = Vec::with_capacity(configs.len());
for (index, config) in configs.iter().enumerate() {
let run_id = run_ids[index].clone();
let token = CancellationToken::new(); let token = CancellationToken::new();
self.active_tokens.insert(run_id.clone(), token.clone()); self.active_tokens.insert(run_id.clone(), token.clone());
let coordinator = self.clone();
let config = config.clone(); let config = config.clone();
let resolution = resolved[index].clone();
let spawned = self let spawned = self
.task_supervisor .task_supervisor
.spawn_graceful(format!("agent-run:{run_id}"), { .spawn_graceful(format!("agent-run:{run_id}"), {
let coordinator = coordinator.clone();
let run_id = run_id.clone(); let run_id = run_id.clone();
async move { async move {
coordinator coordinator
.run_background_runner( .run_background_runner(&run_id, &config, resolution, token)
&run_id, &config, resolved, token, run_permit, activity,
)
.await; .await;
} }
}); });
if !spawned { if !spawned {
// Compensation: undo the durable admission before returning. // Compensation: the rejected closure was dropped by the
// The rejected closure was dropped by the supervisor, which // supervisor. Cancel the run and release its slot.
// released the run quota permit and activity guard.
self.active_tokens.remove(&run_id); self.active_tokens.remove(&run_id);
let _ = self let _ = self
.storage .storage
.cancel_agent_run_with_completion(&run_id, "gateway shutdown", true, now) .cancel_agent_run_with_completion(&run_id, "gateway shutdown", true, now)
.await; .await;
continue;
}
spawned_ids.push(run_id);
}
if spawned_ids.is_empty() {
return Err(CoordinatorError::Rejected( return Err(CoordinatorError::Rejected(
"gateway is shutting down and cannot accept background tasks".to_string(), "gateway is shutting down and cannot accept background tasks".to_string(),
)); ));
} }
Ok(run_id) Ok(BackgroundAdmission {
run_ids: spawned_ids,
})
} }
async fn run_background_runner( async fn run_background_runner(
@ -247,11 +272,46 @@ impl AgentCoordinator {
config: &SubAgentConfig, config: &SubAgentConfig,
resolved: ResolvedAgentRun, resolved: ResolvedAgentRun,
token: CancellationToken, token: CancellationToken,
_run_permit: crate::agent::gate::RunPermit,
_activity: crate::gateway::reload::ActivityGuard,
) { ) {
let now = chrono::Utc::now().timestamp_millis(); let now = chrono::Utc::now().timestamp_millis();
let execution_id = run_id.to_string(); let execution_id = run_id.to_string();
let root_session_id = resolved
.tool_context
.agent
.as_ref()
.map(|agent| agent.root_session_id.clone())
.unwrap_or_default();
// Run quota + admission guard, acquired inside the runner so
// `delegate_background` returns immediately. Queuing time counts
// toward the run timeout and cancellation aborts the wait.
let run_permit = match self
.execution_gate
.acquire_run(&root_session_id, &token)
.await
{
Ok(permit) => permit,
Err(_) => {
let _ = self
.storage
.cancel_agent_run_with_completion(run_id, "cancelled before start", true, now)
.await;
self.active_tokens.remove(run_id);
return;
}
};
let Some(activity) = self.admission.try_enter() else {
drop(run_permit);
let _ = self
.storage
.cancel_agent_run_with_completion(run_id, "gateway shutdown", true, now)
.await;
self.active_tokens.remove(run_id);
return;
};
let _run_permit = run_permit;
let _activity = activity;
if !self if !self
.storage .storage
.mark_agent_run_running(run_id, &execution_id, now) .mark_agent_run_running(run_id, &execution_id, now)
@ -402,8 +462,8 @@ impl AgentCoordinator {
} }
/// Startup/activation recovery: interrupt runs of older generations, /// Startup/activation recovery: interrupt runs of older generations,
/// expire stale leases, converge group counters and reconcile the /// expire stale leases and reconcile the per-session capacity rows.
/// per-session capacity rows. Safe to call once per activation. /// Safe to call once per activation.
pub async fn recover_on_activation( pub async fn recover_on_activation(
&self, &self,
) -> Result<crate::storage::agent_inbox::RecoveryReport, CoordinatorError> { ) -> Result<crate::storage::agent_inbox::RecoveryReport, CoordinatorError> {
@ -479,23 +539,6 @@ impl AgentCoordinator {
.or_else(|| caller.agent.as_ref().map(|agent| agent.run_id.clone())) .or_else(|| caller.agent.as_ref().map(|agent| agent.run_id.clone()))
.unwrap_or_else(|| "root".to_string()); .unwrap_or_else(|| "root".to_string());
let group = (configs.len() > 1).then(|| NewAgentGroup {
id: Uuid::new_v4().to_string(),
root_session_id: root_session_id.clone(),
caller_run_id: caller.agent.as_ref().map(|agent| agent.run_id.clone()),
caller_scope_id: caller_scope_id.clone(),
idempotency_key: None,
mode: AgentRunMode::Foreground,
completion_policy: AgentCompletionPolicy::All,
deadline_at: now
+ resolved
.iter()
.map(|run| (run.timeout_secs * 1000) as i64)
.max()
.unwrap_or(0),
runtime_generation: self.runtime_generation,
});
let mut runs = Vec::with_capacity(configs.len()); let mut runs = Vec::with_capacity(configs.len());
for (index, config) in configs.iter().enumerate() { for (index, config) in configs.iter().enumerate() {
let resolution = &resolved[index]; let resolution = &resolved[index];
@ -547,7 +590,7 @@ impl AgentCoordinator {
match self match self
.storage .storage
.accept_agent_runs(AcceptAgentRequest { group, runs, now }) .accept_agent_runs(AcceptAgentRequest { runs, now })
.await? .await?
{ {
AcceptedAgentRuns::Accepted { .. } => {} AcceptedAgentRuns::Accepted { .. } => {}
@ -1113,7 +1156,18 @@ mod tests {
root_delegates: vec!["researcher".to_string()], root_delegates: vec!["researcher".to_string()],
..Default::default() ..Default::default()
}; };
AgentCatalog::load(&config, root, &profiles, &tools, &loader, 1).unwrap() AgentCatalog::load(
&config,
root,
&profiles,
&HashMap::new(),
&HashMap::new(),
root,
&tools,
&loader,
1,
)
.unwrap()
} }
async fn coordinator() -> (Arc<AgentCoordinator>, tempfile::TempDir) { async fn coordinator() -> (Arc<AgentCoordinator>, tempfile::TempDir) {
@ -1122,6 +1176,13 @@ mod tests {
async fn coordinator_with_inbox_limit( async fn coordinator_with_inbox_limit(
max_pending: usize, max_pending: usize,
) -> (Arc<AgentCoordinator>, tempfile::TempDir) {
coordinator_with_inbox_and_run_limit(max_pending, None).await
}
async fn coordinator_with_inbox_and_run_limit(
max_pending: usize,
max_concurrent_runs: Option<usize>,
) -> (Arc<AgentCoordinator>, tempfile::TempDir) { ) -> (Arc<AgentCoordinator>, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let storage = Arc::new(Storage::new(&dir.path().join("coord.db")).await.unwrap()); let storage = Arc::new(Storage::new(&dir.path().join("coord.db")).await.unwrap());
@ -1143,6 +1204,17 @@ mod tests {
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 {
Some(limit) => {
let config = crate::config::AgentOrchestrationConfig {
enabled: true,
max_concurrent_runs: limit,
..Default::default()
};
crate::agent::gate::ExecutionGate::new(&config)
}
None => crate::agent::gate::ExecutionGate::unbounded(),
};
( (
AgentCoordinator::new( AgentCoordinator::new(
storage, storage,
@ -1150,7 +1222,7 @@ mod tests {
work_manager, work_manager,
notifier, notifier,
Arc::new(crate::agent::AgentProjectionHub::new()), Arc::new(crate::agent::AgentProjectionHub::new()),
crate::agent::gate::ExecutionGate::unbounded(), gate,
crate::gateway::reload::RuntimeAdmission::open(), crate::gateway::reload::RuntimeAdmission::open(),
supervisor, supervisor,
1, 1,
@ -1223,18 +1295,7 @@ mod tests {
.await .await
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert!(first.group_id.is_some()); assert_ne!(first.id, second.id);
assert_eq!(first.group_id, second.group_id);
let group = coordinator
.storage
.get_agent_run_group(first.group_id.as_ref().unwrap())
.await
.unwrap()
.unwrap();
assert_eq!(group.expected_runs, 2);
assert_eq!(group.terminal_runs, 2);
assert!(group.status.is_terminal());
} }
#[tokio::test] #[tokio::test]
@ -1280,23 +1341,21 @@ mod tests {
let (coordinator, _dir) = coordinator().await; let (coordinator, _dir) = coordinator().await;
let caller = ToolExecutionContext::for_session("cli:test:dialog"); let caller = ToolExecutionContext::for_session("cli:test:dialog");
let run_id = coordinator let admission = coordinator
.delegate_background(&caller, foreground_config("researcher")) .delegate_background(&caller, vec![foreground_config("researcher")])
.await .await
.unwrap(); .unwrap();
assert_eq!(admission.run_ids.len(), 1);
let run_id = &admission.run_ids[0];
let run = coordinator let run = coordinator.get_run(&caller, run_id).await.unwrap().unwrap();
.get_run(&caller, &run_id)
.await
.unwrap()
.unwrap();
assert_eq!(run.mode, AgentRunMode::Background); assert_eq!(run.mode, AgentRunMode::Background);
assert!(run.completion_slot_reserved); assert!(run.completion_slot_reserved);
// A second background run while the inbox is at capacity must be // A second background run while the inbox is at capacity must be
// rejected, not admitted silently. // rejected, not admitted silently.
let error = coordinator let error = coordinator
.delegate_background(&caller, foreground_config("researcher")) .delegate_background(&caller, vec![foreground_config("researcher")])
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(error, CoordinatorError::Rejected(_))); assert!(matches!(error, CoordinatorError::Rejected(_)));
@ -1306,11 +1365,7 @@ mod tests {
// reservation into a durable completion event. // reservation into a durable completion event.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop { loop {
let run = coordinator let run = coordinator.get_run(&caller, run_id).await.unwrap().unwrap();
.get_run(&caller, &run_id)
.await
.unwrap()
.unwrap();
if run.status.is_terminal() { if run.status.is_terminal() {
break; break;
} }
@ -1342,6 +1397,44 @@ mod tests {
assert_eq!(state, (1, 0)); assert_eq!(state, (1, 0));
} }
#[tokio::test]
async fn background_batch_admits_all_runs_and_creates_a_group() {
let (coordinator, _dir) = coordinator_with_inbox_limit(8).await;
let caller = ToolExecutionContext::for_session("cli:test:dialog");
let configs = vec![
foreground_config("researcher"),
foreground_config("researcher"),
foreground_config("researcher"),
];
let admission = coordinator
.delegate_background(&caller, configs)
.await
.unwrap();
assert_eq!(admission.run_ids.len(), 3);
let runs = coordinator.list_runs(&caller, None, 10).await.unwrap();
assert_eq!(runs.len(), 3);
assert!(runs.iter().all(|run| run.completion_slot_reserved));
}
#[tokio::test]
async fn background_batch_rejects_when_exceeding_run_quota() {
let (coordinator, _dir) = coordinator_with_inbox_and_run_limit(16, Some(2)).await;
let caller = ToolExecutionContext::for_session("cli:test:dialog");
let error = coordinator
.delegate_background(
&caller,
vec![
foreground_config("researcher"),
foreground_config("researcher"),
foreground_config("researcher"),
],
)
.await
.unwrap_err();
assert!(matches!(error, CoordinatorError::Rejected(_)));
}
async fn storage_accept_run_with_delivery( async fn storage_accept_run_with_delivery(
storage: &Arc<Storage>, storage: &Arc<Storage>,
run_id: &str, run_id: &str,
@ -1382,7 +1475,6 @@ mod tests {
}; };
let _ = storage let _ = storage
.accept_agent_runs(AcceptAgentRequest { .accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![run], runs: vec![run],
now, now,
}) })
@ -1418,7 +1510,6 @@ mod tests {
root_turn_id: None, root_turn_id: None,
run_id: run_id.to_string(), run_id: run_id.to_string(),
execution_id: run_id.to_string(), execution_id: run_id.to_string(),
group_id: None,
parent_run_id: None, parent_run_id: None,
caller_agent_id: "ROOT".to_string(), caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(), current_agent_id: "researcher".to_string(),
@ -1483,7 +1574,6 @@ mod tests {
root_turn_id: None, root_turn_id: None,
run_id: run_id.to_string(), run_id: run_id.to_string(),
execution_id: run_id.to_string(), execution_id: run_id.to_string(),
group_id: None,
parent_run_id: None, parent_run_id: None,
caller_agent_id: "ROOT".to_string(), caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(), current_agent_id: "researcher".to_string(),
@ -1573,7 +1663,6 @@ mod tests {
root_turn_id: None, root_turn_id: None,
run_id: run_id.to_string(), run_id: run_id.to_string(),
execution_id: run_id.to_string(), execution_id: run_id.to_string(),
group_id: None,
parent_run_id: None, parent_run_id: None,
caller_agent_id: "ROOT".to_string(), caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(), current_agent_id: "researcher".to_string(),
@ -1660,7 +1749,6 @@ mod tests {
root_turn_id: None, root_turn_id: None,
run_id: "run-sig-3".to_string(), run_id: "run-sig-3".to_string(),
execution_id: "run-sig-3".to_string(), execution_id: "run-sig-3".to_string(),
group_id: None,
parent_run_id: None, parent_run_id: None,
caller_agent_id: "ROOT".to_string(), caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(), current_agent_id: "researcher".to_string(),
@ -1711,7 +1799,6 @@ mod tests {
root_turn_id: None, root_turn_id: None,
run_id: run_id.to_string(), run_id: run_id.to_string(),
execution_id: run_id.to_string(), execution_id: run_id.to_string(),
group_id: None,
parent_run_id: None, parent_run_id: None,
caller_agent_id: "ROOT".to_string(), caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(), current_agent_id: "researcher".to_string(),

View File

@ -203,28 +203,51 @@ impl AgentLimits {
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct AgentFrontmatter { pub struct AgentFrontmatter {
id: String, pub id: String,
description: String, pub description: String,
llm_profile: String, /// Either this (a key in `config.json`'s `agents` map) or the inline
/// `provider` + `model` pair must be present.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm_profile: Option<String>,
/// Inline provider/model selection; the preferred way to author an Agent
/// from the WebUI. Overrides `llm_profile` when both are present.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_limit: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tool_iterations: Option<usize>,
/// Disabled definitions stay on disk but never load into the catalog.
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delegates: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<String>,
#[serde(default)] #[serde(default)]
tools: Vec<String>, pub limits: AgentLimits,
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
delegates: Vec<String>, pub signal: Option<SignalContract>,
#[serde(default)] }
skills: Vec<String>,
#[serde(default)] pub fn default_true() -> bool {
limits: AgentLimits, true
#[serde(default)]
signal: Option<SignalContract>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AgentDefinition { pub struct AgentDefinition {
pub id: String, pub id: String,
pub description: String, pub description: String,
pub llm_profile: String, pub llm_profile: Option<String>,
pub provider: Option<String>,
pub model: Option<String>,
pub provider_config: Arc<LLMProviderConfig>, pub provider_config: Arc<LLMProviderConfig>,
pub enabled: bool,
pub tools: Vec<String>, pub tools: Vec<String>,
pub delegates: Vec<String>, pub delegates: Vec<String>,
pub skills: Vec<String>, pub skills: Vec<String>,
@ -253,6 +276,68 @@ pub(crate) fn parse_definition(
path: &Path, path: &Path,
provider_config: Arc<LLMProviderConfig>, provider_config: Arc<LLMProviderConfig>,
) -> Result<AgentDefinition, AgentDefinitionError> { ) -> Result<AgentDefinition, AgentDefinitionError> {
let (frontmatter, role_prompt) = read_frontmatter(path)?;
let canonical_frontmatter = serde_json::to_vec(&frontmatter)
.map_err(|error| AgentDefinitionError::Invalid(error.to_string()))?;
let mut hasher = Sha256::new();
hasher.update(canonical_frontmatter);
hasher.update(b"\n---\n");
hasher.update(role_prompt.as_bytes());
let definition_hash = hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
Ok(AgentDefinition {
id: frontmatter.id,
description: frontmatter.description.trim().to_string(),
llm_profile: frontmatter.llm_profile,
provider: frontmatter.provider,
model: frontmatter.model,
provider_config,
enabled: frontmatter.enabled,
tools: frontmatter.tools,
delegates: frontmatter.delegates,
skills: frontmatter.skills,
limits: frontmatter.limits,
signal_contract: frontmatter.signal,
role_prompt,
definition_hash,
source_path: path.to_path_buf(),
})
}
/// Provider-agnostic view of a definition file, used by the management UI.
/// It carries the frontmatter plus the role body but no resolved
/// `provider_config`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDefinitionInfo {
#[serde(flatten)]
pub frontmatter: AgentFrontmatter,
pub role_prompt: String,
}
/// Read and validate a definition file without resolving its provider
/// config. Used by the management API to list/validate definitions; the
/// catalog performs the full provider/tool/delegate resolution on load.
pub fn parse_definition_info(path: &Path) -> Result<AgentDefinitionInfo, AgentDefinitionError> {
let (frontmatter, role_prompt) = read_frontmatter(path)?;
Ok(AgentDefinitionInfo {
frontmatter,
role_prompt,
})
}
/// Serialize a definition back to the Markdown file format.
pub fn serialize_definition(info: &AgentDefinitionInfo) -> String {
let yaml = serde_yaml::to_string(&info.frontmatter).unwrap_or_default();
format!("---\n{yaml}---\n{}\n", info.role_prompt.trim_end())
}
/// Read + validate the frontmatter and role body of a definition file,
/// shared by `parse_definition` and `parse_definition_info`.
fn read_frontmatter(path: &Path) -> Result<(AgentFrontmatter, String), AgentDefinitionError> {
let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io { let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io {
path: path.display().to_string(), path: path.display().to_string(),
source, source,
@ -320,9 +405,20 @@ pub(crate) fn parse_definition(
frontmatter.id frontmatter.id
))); )));
} }
if frontmatter.llm_profile.trim().is_empty() { let has_profile = frontmatter
.llm_profile
.as_deref()
.is_some_and(|p| !p.trim().is_empty());
let has_inline = frontmatter.provider.is_some() || frontmatter.model.is_some();
if !has_profile && !has_inline {
return Err(AgentDefinitionError::Invalid(format!( return Err(AgentDefinitionError::Invalid(format!(
"Agent '{}' has an empty llm_profile", "Agent '{}' must declare either llm_profile or provider+model",
frontmatter.id
)));
}
if frontmatter.provider.is_some() != frontmatter.model.is_some() {
return Err(AgentDefinitionError::Invalid(format!(
"Agent '{}' must declare provider and model together",
frontmatter.id frontmatter.id
))); )));
} }
@ -333,7 +429,6 @@ pub(crate) fn parse_definition(
reject_duplicates("tools", &frontmatter.tools)?; reject_duplicates("tools", &frontmatter.tools)?;
reject_duplicates("delegates", &frontmatter.delegates)?; reject_duplicates("delegates", &frontmatter.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()) {
return Err(AgentDefinitionError::Invalid(format!( return Err(AgentDefinitionError::Invalid(format!(
@ -342,33 +437,7 @@ pub(crate) fn parse_definition(
path.display() path.display()
))); )));
} }
Ok((frontmatter, role_prompt))
let canonical_frontmatter = serde_json::to_vec(&frontmatter)
.map_err(|error| AgentDefinitionError::Invalid(error.to_string()))?;
let mut hasher = Sha256::new();
hasher.update(canonical_frontmatter);
hasher.update(b"\n---\n");
hasher.update(role_prompt.as_bytes());
let definition_hash = hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
Ok(AgentDefinition {
id: frontmatter.id,
description: frontmatter.description.trim().to_string(),
llm_profile: frontmatter.llm_profile,
provider_config,
tools: frontmatter.tools,
delegates: frontmatter.delegates,
skills: frontmatter.skills,
limits: frontmatter.limits,
signal_contract: frontmatter.signal,
role_prompt,
definition_hash,
source_path: path.to_path_buf(),
})
} }
pub fn validate_agent_id(id: &str) -> Result<(), AgentDefinitionError> { pub fn validate_agent_id(id: &str) -> Result<(), AgentDefinitionError> {

View File

@ -67,6 +67,7 @@ impl KeyedSemaphores {
/// provider/tool step permits have independent lifecycles; acquisition order /// provider/tool step permits have independent lifecycles; acquisition order
/// is always global -> session and release order is reversed. /// is always global -> session and release order is reversed.
pub struct ExecutionGate { pub struct ExecutionGate {
max_concurrent_runs: usize,
run_global: Arc<Semaphore>, run_global: Arc<Semaphore>,
run_session: Arc<KeyedSemaphores>, run_session: Arc<KeyedSemaphores>,
provider_global: Arc<Semaphore>, provider_global: Arc<Semaphore>,
@ -91,6 +92,7 @@ impl std::fmt::Debug for ExecutionGate {
impl ExecutionGate { impl ExecutionGate {
pub fn new(config: &crate::config::AgentOrchestrationConfig) -> Arc<Self> { pub fn new(config: &crate::config::AgentOrchestrationConfig) -> Arc<Self> {
Arc::new(Self { Arc::new(Self {
max_concurrent_runs: config.max_concurrent_runs,
run_global: Arc::new(Semaphore::new(config.max_concurrent_runs)), run_global: Arc::new(Semaphore::new(config.max_concurrent_runs)),
run_session: Arc::new(KeyedSemaphores::new(config.max_concurrent_runs_per_session)), run_session: Arc::new(KeyedSemaphores::new(config.max_concurrent_runs_per_session)),
provider_global: Arc::new(Semaphore::new(config.max_concurrent_provider_steps)), provider_global: Arc::new(Semaphore::new(config.max_concurrent_provider_steps)),
@ -108,6 +110,7 @@ impl ExecutionGate {
/// route through it so the code path stays uniform. /// route through it so the code path stays uniform.
pub fn unbounded() -> Arc<Self> { pub fn unbounded() -> Arc<Self> {
Arc::new(Self { Arc::new(Self {
max_concurrent_runs: usize::MAX,
run_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)), run_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
run_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)), run_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)),
provider_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)), provider_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
@ -117,6 +120,12 @@ impl ExecutionGate {
}) })
} }
/// Global run quota ceiling; background batches larger than this are
/// rejected up front instead of queueing indefinitely.
pub fn max_concurrent_runs(&self) -> usize {
self.max_concurrent_runs
}
pub async fn acquire_run( pub async fn acquire_run(
self: &Arc<Self>, self: &Arc<Self>,
session_id: &str, session_id: &str,

View File

@ -25,7 +25,6 @@ pub struct AgentExecutionContext {
/// Execution attempt identifier owning conditional state transitions in /// Execution attempt identifier owning conditional state transitions in
/// Storage. Equal to `run_id` for the first attempt. /// Storage. Equal to `run_id` for the first attempt.
pub execution_id: String, pub execution_id: String,
pub group_id: Option<String>,
pub parent_run_id: Option<String>, pub parent_run_id: Option<String>,
pub caller_agent_id: String, pub caller_agent_id: String,
pub current_agent_id: String, pub current_agent_id: String,
@ -62,7 +61,6 @@ impl AgentExecutionContext {
root_turn_id: parent.root_turn_id.clone(), root_turn_id: parent.root_turn_id.clone(),
run_id: run_id.clone(), run_id: run_id.clone(),
execution_id: run_id, execution_id: run_id,
group_id: None,
parent_run_id: Some(parent.run_id.clone()), parent_run_id: Some(parent.run_id.clone()),
caller_agent_id: parent.current_agent_id.clone(), caller_agent_id: parent.current_agent_id.clone(),
current_agent_id: target, current_agent_id: target,
@ -110,7 +108,6 @@ mod tests {
root_turn_id: None, root_turn_id: None,
run_id: "run-root".to_string(), run_id: "run-root".to_string(),
execution_id: "run-root".to_string(), execution_id: "run-root".to_string(),
group_id: None,
parent_run_id: None, parent_run_id: None,
caller_agent_id: "ROOT".to_string(), caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(), current_agent_id: "researcher".to_string(),

View File

@ -38,7 +38,6 @@ pub enum TurnInputSource {
User, User,
AgentSignal { run_id: String, agent_id: String }, AgentSignal { run_id: String, agent_id: String },
AgentCompletion { run_id: String, agent_id: String }, AgentCompletion { run_id: String, agent_id: String },
AgentGroupCompletion { group_id: String },
} }
impl TurnInputSource { impl TurnInputSource {
@ -61,11 +60,6 @@ impl From<&TurnInputSource> for WakeupSource {
agent_id: agent_id.clone(), agent_id: agent_id.clone(),
} }
} }
TurnInputSource::AgentGroupCompletion { group_id } => {
WakeupSource::AgentGroupCompletion {
group_id: group_id.clone(),
}
}
} }
} }
} }
@ -78,7 +72,6 @@ pub enum WakeupSource {
UserQueue, UserQueue,
AgentSignal { run_id: String, agent_id: String }, AgentSignal { run_id: String, agent_id: String },
AgentCompletion { run_id: String, agent_id: String }, AgentCompletion { run_id: String, agent_id: String },
AgentGroupCompletion { group_id: String },
AgentQueue, AgentQueue,
} }
@ -215,7 +208,6 @@ impl TurnInput {
task_id: self.durable_event_id.clone(), task_id: self.durable_event_id.clone(),
from_run_id: Some(run_id.clone()), from_run_id: Some(run_id.clone()),
from_agent_id: Some(agent_id.clone()), from_agent_id: Some(agent_id.clone()),
group_id: None,
}; };
(crate::bus::ClientVisibility::Hidden, Some(source)) (crate::bus::ClientVisibility::Hidden, Some(source))
} }
@ -229,21 +221,6 @@ impl TurnInput {
task_id: self.durable_event_id.clone(), task_id: self.durable_event_id.clone(),
from_run_id: Some(run_id.clone()), from_run_id: Some(run_id.clone()),
from_agent_id: Some(agent_id.clone()), from_agent_id: Some(agent_id.clone()),
group_id: None,
};
(crate::bus::ClientVisibility::Hidden, Some(source))
}
TurnInputSource::AgentGroupCompletion { group_id } => {
let source = MessageSource {
kind: crate::bus::SourceKind::AgentGroupCompletion,
from_channel: None,
from_session: None,
from_user_id: None,
system_name: None,
task_id: self.durable_event_id.clone(),
from_run_id: None,
from_agent_id: None,
group_id: Some(group_id.clone()),
}; };
(crate::bus::ClientVisibility::Hidden, Some(source)) (crate::bus::ClientVisibility::Hidden, Some(source))
} }

View File

@ -1,7 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use crate::agent::AgentError; use crate::agent::AgentError;
use crate::agent::AgentLoop; use crate::agent::AgentLoop;
use crate::agent::system_prompt::build_sub_agent_system_prompt; use crate::agent::system_prompt::build_sub_agent_system_prompt;
@ -163,7 +162,6 @@ impl SubAgentManager {
self self
} }
pub(crate) fn resolve_agent( pub(crate) fn resolve_agent(
&self, &self,
config: &SubAgentConfig, config: &SubAgentConfig,
@ -258,7 +256,6 @@ impl SubAgentManager {
root_turn_id: caller.turn_id.clone(), root_turn_id: caller.turn_id.clone(),
run_id: task_id.to_string(), run_id: task_id.to_string(),
execution_id: task_id.to_string(), execution_id: task_id.to_string(),
group_id: None,
parent_run_id: None, parent_run_id: None,
caller_agent_id: "ROOT".to_string(), caller_agent_id: "ROOT".to_string(),
current_agent_id: target.to_string(), current_agent_id: target.to_string(),
@ -349,7 +346,7 @@ impl SubAgentManager {
skills_prompt, skills_prompt,
agent_id: Some(target.to_string()), agent_id: Some(target.to_string()),
definition_hash: Some(definition.definition_hash.clone()), definition_hash: Some(definition.definition_hash.clone()),
llm_profile: Some(definition.llm_profile.clone()), llm_profile: definition.llm_profile.clone(),
signal_contract: definition.signal_contract.clone(), signal_contract: definition.signal_contract.clone(),
tool_context: ToolExecutionContext::for_session(format!("agent-run:{task_id}")) tool_context: ToolExecutionContext::for_session(format!("agent-run:{task_id}"))
.with_turn_id( .with_turn_id(
@ -530,7 +527,6 @@ fn terminal_status_from_error(error: AgentError) -> TaskStatus {
} }
} }
fn format_duration(seconds: u64) -> String { fn format_duration(seconds: u64) -> String {
if seconds < 60 { if seconds < 60 {
format!("{}s", seconds) format!("{}s", seconds)
@ -602,20 +598,18 @@ mod tests {
} }
#[test] #[test]
fn reload_tool_is_never_delegated_to_sub_agents() { fn runtime_injected_tools_are_marked_but_ordinary_tools_are_not() {
let tool = crate::tools::ReloadConfigTool::new( let reload = crate::tools::ReloadConfigTool::new(
crate::gateway::reload::ReloadHandle::unavailable(), crate::gateway::reload::ReloadHandle::unavailable(),
); );
assert_eq!( assert!(!crate::tools::Tool::runtime_injected(&reload));
crate::tools::Tool::delegation_policy(&tool),
crate::tools::DelegationPolicy::RootOnly
);
} }
#[test] #[test]
fn resolve_agent_rejects_missing_target() { fn resolve_agent_rejects_missing_target() {
let manager = manager(); let manager = manager();
let error = match manager.resolve_agent(&config(None), &ToolExecutionContext::default(), "t-1") { let error =
match manager.resolve_agent(&config(None), &ToolExecutionContext::default(), "t-1") {
Ok(_) => panic!("expected rejection"), Ok(_) => panic!("expected rejection"),
Err(error) => error, Err(error) => error,
}; };
@ -625,10 +619,16 @@ mod tests {
#[test] #[test]
fn resolve_agent_rejects_unknown_target_without_catalog() { fn resolve_agent_rejects_unknown_target_without_catalog() {
let manager = manager(); let manager = manager();
let error = match manager.resolve_agent(&config(Some("ghost")), &ToolExecutionContext::default(), "t-2") { let error = match manager.resolve_agent(
&config(Some("ghost")),
&ToolExecutionContext::default(),
"t-2",
) {
Ok(_) => panic!("expected rejection"), Ok(_) => panic!("expected rejection"),
Err(error) => error, Err(error) => error,
}; };
assert!(matches!(error, SubAgentError::Other(message) if message.contains("orchestration"))); assert!(
matches!(error, SubAgentError::Other(message) if message.contains("orchestration"))
);
} }
} }

View File

@ -347,10 +347,10 @@ impl PromptSection for DelegationSection {
fn build(&self, _ctx: &PromptContext<'_>) -> String { fn build(&self, _ctx: &PromptContext<'_>) -> String {
"## 子 Agent 委托原则\n\n\ "## 子 Agent 委托原则\n\n\
- \n\ - \n\
- Agent \n\ - Agent agents/*.md 的 tools 列表)决定,不要重复说明它已有哪些工具。\n\
- delegate Agent\n\ - Agent delegates \n\
- prompt \n\ - prompt \n\
- background" - background tasks run "
.to_string() .to_string()
} }
} }

View File

@ -218,9 +218,6 @@ pub enum SourceKind {
/// A durable background run completion outcome. /// A durable background run completion outcome.
#[serde(rename = "agent_result")] #[serde(rename = "agent_result")]
AgentCompletion, AgentCompletion,
/// A durable background group completion outcome.
#[serde(rename = "agent_group_result")]
AgentGroupCompletion,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -237,9 +234,6 @@ pub struct MessageSource {
/// Agent definition id for `agent_signal`/`agent_result` sources. /// Agent definition id for `agent_signal`/`agent_result` sources.
#[serde(default)] #[serde(default)]
pub from_agent_id: Option<String>, pub from_agent_id: Option<String>,
/// Durable group identity for `agent_group_result` sources.
#[serde(default)]
pub group_id: Option<String>,
} }
impl ChatMessage { impl ChatMessage {

View File

@ -315,8 +315,6 @@ pub struct GatewayConfig {
pub cleanup_interval_minutes: Option<u64>, pub cleanup_interval_minutes: Option<u64>,
#[serde(default, rename = "session_db_path")] #[serde(default, rename = "session_db_path")]
pub session_db_path: Option<String>, pub session_db_path: Option<String>,
#[serde(default, rename = "max_concurrent_background_tasks")]
pub max_concurrent_background_tasks: usize,
#[serde(default)] #[serde(default)]
pub scheduler: Option<SchedulerConfig>, pub scheduler: Option<SchedulerConfig>,
#[serde(default)] #[serde(default)]
@ -332,7 +330,6 @@ impl Default for GatewayConfig {
session_ttl_hours: None, session_ttl_hours: None,
cleanup_interval_minutes: None, cleanup_interval_minutes: None,
session_db_path: None, session_db_path: None,
max_concurrent_background_tasks: 10,
scheduler: None, scheduler: None,
file_transfer: FileTransferConfig::default(), file_transfer: FileTransferConfig::default(),
} }

View File

@ -857,6 +857,259 @@ pub async fn get_tools(State(state): State<Arc<GatewayState>>) -> Result<Json<Va
Ok(Json(json!({ "tools": tools }))) Ok(Json(json!({ "tools": tools })))
} }
/// List Agent definition files (enabled and disabled) from the resolved
/// definitions directory.
pub async fn list_agents(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let mut agents = Vec::new();
let entries = match std::fs::read_dir(&state.agents_dir) {
Ok(entries) => entries,
Err(_error) => {
return Ok(Json(json!({ "agents": [] })));
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
match crate::agent::definition::parse_definition_info(&path) {
Ok(info) => {
let fm = &info.frontmatter;
agents.push(json!({
"id": fm.id,
"description": fm.description,
"enabled": fm.enabled,
"llm_profile": fm.llm_profile,
"provider": fm.provider,
"model": fm.model,
"token_limit": fm.token_limit,
"max_tool_iterations": fm.max_tool_iterations,
"tools": fm.tools,
"delegates": fm.delegates,
"skills": fm.skills,
"limits": fm.limits,
"signal": fm.signal,
"role_prompt": info.role_prompt,
}));
}
Err(error) => {
tracing::warn!(path = %path.display(), error = %error, "Failed to parse Agent definition");
}
}
}
agents.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
Ok(Json(json!({ "agents": agents })))
}
/// Create or update a definition file under the definitions directory.
pub async fn put_agent(
State(state): State<Arc<GatewayState>>,
Json(body): Json<Value>,
) -> Result<Json<Value>, ApiError> {
let info = agent_info_from_json(&body)?;
let fm = &info.frontmatter;
// Validate referenced provider/model/tools/skills so a broken file is
// rejected at the API boundary instead of breaking the next reload.
if let Some(provider) = fm.provider.as_deref()
&& !state.config.providers.contains_key(provider)
{
return Err(ApiError::bad_request(format!(
"unknown provider '{provider}'"
)));
}
if let Some(model) = fm.model.as_deref()
&& !state.config.models.contains_key(model)
{
return Err(ApiError::bad_request(format!("unknown model '{model}'")));
}
let registry = state.session_manager.tools();
for tool in &fm.tools {
let Some(registered) = registry.get(tool) else {
return Err(ApiError::bad_request(format!("unknown tool '{tool}'")));
};
if registered.runtime_injected() && tool != "get_skill" {
return Err(ApiError::bad_request(format!(
"tool '{tool}' is runtime-injected and cannot be declared"
)));
}
}
let loaded_skills: std::collections::HashSet<String> = state
.session_manager
.skills_loader()
.list_skills()
.into_iter()
.map(|(name, _)| name)
.collect();
for skill in &fm.skills {
if !loaded_skills.contains(skill) {
return Err(ApiError::bad_request(format!("unknown skill '{skill}'")));
}
}
if !fm.skills.is_empty() && !fm.tools.iter().any(|t| t == "get_skill") {
return Err(ApiError::bad_request(
"skills require the get_skill tool in the definition",
));
}
let content = crate::agent::definition::serialize_definition(&info);
tokio::fs::create_dir_all(&state.agents_dir)
.await
.map_err(ApiError::internal)?;
let path = state.agents_dir.join(format!("{}.md", fm.id));
tokio::fs::write(&path, content)
.await
.map_err(ApiError::internal)?;
Ok(Json(json!({ "id": fm.id, "saved": true })))
}
/// Delete a definition file.
pub async fn delete_agent(
State(state): State<Arc<GatewayState>>,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
crate::agent::definition::validate_agent_id(&id)
.map_err(|error| ApiError::bad_request(error.to_string()))?;
let path = state.agents_dir.join(format!("{id}.md"));
match tokio::fs::remove_file(&path).await {
Ok(()) => Ok(Json(json!({ "deleted": id }))),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Err(ApiError::not_found(format!("agent {id} not found")))
}
Err(error) => Err(ApiError::internal(error)),
}
}
/// Available providers/models/tools/skills for the editor UI.
pub async fn get_agent_options(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<Value>, ApiError> {
let providers: Vec<String> = state.config.providers.keys().cloned().collect();
let models: Vec<Value> = state
.config
.models
.iter()
.map(|(name, model)| json!({ "name": name, "model_id": model.model_id }))
.collect();
let registry = state.session_manager.tools();
let mut tools: Vec<Value> = registry
.iter()
.into_iter()
// get_skill is the one runtime-injected tool that may be declared in
// a definition's `tools` list (it turns on the scoped skill wrapper),
// so it must be offered in the editor.
.filter(|(name, tool)| {
(!tool.runtime_injected() || name == "get_skill") && !name.contains("__")
})
.map(|(name, tool)| json!({ "name": name, "description": tool.description() }))
.collect();
tools.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
let skills: Vec<String> = state
.session_manager
.skills_loader()
.list_skills()
.into_iter()
.map(|(name, _)| name)
.collect();
Ok(Json(json!({
"providers": providers,
"models": models,
"tools": tools,
"skills": skills,
})))
}
fn agent_info_from_json(
body: &Value,
) -> Result<crate::agent::definition::AgentDefinitionInfo, ApiError> {
use crate::agent::definition::{AgentDefinitionInfo, AgentFrontmatter, AgentLimits};
let id = body
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| ApiError::bad_request("missing required field: id"))?
.to_string();
crate::agent::definition::validate_agent_id(&id)
.map_err(|error| ApiError::bad_request(error.to_string()))?;
let description = body
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let role_prompt = body
.get("role_prompt")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if role_prompt.trim().is_empty() {
return Err(ApiError::bad_request("role_prompt must not be empty"));
}
let provider = body
.get("provider")
.and_then(Value::as_str)
.map(str::to_string);
let model = body
.get("model")
.and_then(Value::as_str)
.map(str::to_string);
let llm_profile = body
.get("llm_profile")
.and_then(Value::as_str)
.map(str::to_string);
if provider.is_none() && model.is_none() && llm_profile.as_deref().is_none_or(str::is_empty) {
return Err(ApiError::bad_request(
"either provider+model or llm_profile is required",
));
}
if provider.is_some() != model.is_some() {
return Err(ApiError::bad_request(
"provider and model must be set together",
));
}
let info = AgentDefinitionInfo {
frontmatter: AgentFrontmatter {
id,
description,
llm_profile: llm_profile.filter(|v| !v.is_empty()),
provider,
model,
token_limit: body
.get("token_limit")
.and_then(Value::as_u64)
.map(|v| v as usize),
max_tool_iterations: body
.get("max_tool_iterations")
.and_then(Value::as_u64)
.map(|v| v as usize),
enabled: body.get("enabled").and_then(Value::as_bool).unwrap_or(true),
tools: string_array(body, "tools"),
delegates: string_array(body, "delegates"),
skills: string_array(body, "skills"),
limits: body
.get("limits")
.and_then(|v| serde_json::from_value::<AgentLimits>(v.clone()).ok())
.unwrap_or_default(),
signal: body
.get("signal")
.and_then(|v| serde_json::from_value(v.clone()).ok()),
},
role_prompt,
};
Ok(info)
}
fn string_array(body: &Value, key: &str) -> Vec<String> {
body.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
pub async fn get_skills(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> { pub async fn get_skills(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let loader = state.session_manager.skills_loader(); let loader = state.session_manager.skills_loader();
let skills: Vec<Value> = loader let skills: Vec<Value> = loader
@ -879,28 +1132,19 @@ pub async fn get_tasks(
Query(query): Query<LimitQuery>, Query(query): Query<LimitQuery>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let limit = query.limit.unwrap_or(100).clamp(1, 500); let limit = query.limit.unwrap_or(100).clamp(1, 500);
// Unified projection: new durable agent_runs plus the legacy
// background_tasks records (read-only). Merged by created_at.
let runs = state let runs = state
.storage .storage
.list_all_agent_runs(None, limit as i64) .list_all_agent_runs(None, limit as i64)
.await .await
.map_err(ApiError::internal)?; .map_err(ApiError::internal)?;
let legacy = state let tasks: Vec<Value> = runs
.storage .into_iter()
.list_recent_background_tasks(limit) .map(|run| {
.await json!({
.map_err(ApiError::internal)?;
let mut tasks: Vec<Value> = Vec::with_capacity(runs.len() + legacy.len());
for run in runs {
tasks.push(json!({
"source": "agent_run", "source": "agent_run",
"id": run.id, "id": run.id,
"group_id": run.group_id,
"parent_run_id": run.parent_run_id, "parent_run_id": run.parent_run_id,
"session_id": run.root_session_id, "session_id": run.root_session_id,
"channel": null,
"chat_id": null,
"agent_id": run.agent_id, "agent_id": run.agent_id,
"mode": run.mode.as_str(), "mode": run.mode.as_str(),
"depth": run.depth, "depth": run.depth,
@ -913,33 +1157,9 @@ pub async fn get_tasks(
"started_at": run.started_at, "started_at": run.started_at,
"finished_at": run.finished_at, "finished_at": run.finished_at,
"created_at": run.created_at, "created_at": run.created_at,
})); })
} })
for task in legacy { .collect();
tasks.push(json!({
"source": "legacy_background_task",
"id": task.id,
"session_id": task.session_id,
"channel": task.channel,
"chat_id": task.chat_id,
"prompt": task.prompt,
"status": task.status,
"result": task.result,
"error": task.error,
"tool_calls_count": task.tool_calls_count,
"iterations": task.iterations,
"started_at": task.started_at,
"finished_at": task.finished_at,
"created_at": task.created_at,
}));
}
tasks.sort_by(|left, right| {
right
.get("created_at")
.and_then(Value::as_i64)
.cmp(&left.get("created_at").and_then(Value::as_i64))
});
tasks.truncate(limit);
Ok(Json(json!({ "tasks": tasks }))) Ok(Json(json!({ "tasks": tasks })))
} }

View File

@ -53,6 +53,8 @@ pub struct GatewayState {
pub(crate) reload: reload::ReloadHandle, pub(crate) reload: reload::ReloadHandle,
pub(crate) admission: reload::RuntimeAdmission, pub(crate) admission: reload::RuntimeAdmission,
pub agent_catalog: Arc<crate::agent::AgentCatalog>, pub agent_catalog: Arc<crate::agent::AgentCatalog>,
/// Directory holding Agent definition files (resolved definitions_dir).
pub agents_dir: std::path::PathBuf,
} }
impl GatewayState { impl GatewayState {
@ -190,11 +192,26 @@ impl GatewayState {
.unwrap_or_else(|| std::path::Path::new(".")) .unwrap_or_else(|| std::path::Path::new("."))
.to_path_buf(); .to_path_buf();
// Resolve the Agent definitions directory exactly like the catalog
// does (relative paths stay inside the trusted config dir).
let agents_dir = {
let configured =
crate::config::expand_path(&config.agent_orchestration.definitions_dir);
if configured.is_absolute() {
configured
} else {
config_dir.join(configured)
}
};
// Create SessionManager with bus injection // Create SessionManager with bus injection
let session_manager = SessionManager::new( let session_manager = SessionManager::new(
provider_config.clone(), provider_config.clone(),
AgentCatalogPreparation { AgentCatalogPreparation {
provider_profiles, provider_profiles,
providers: config.providers.clone(),
models: config.models.clone(),
workspace_dir: crate::config::expand_path(&config.workspace_dir),
config: config.agent_orchestration.clone(), config: config.agent_orchestration.clone(),
config_dir, config_dir,
runtime_generation, runtime_generation,
@ -283,6 +300,7 @@ impl GatewayState {
reload, reload,
admission, admission,
agent_catalog, agent_catalog,
agents_dir,
}) })
} }
@ -299,9 +317,8 @@ impl GatewayState {
/// Start the message processing loops /// Start the message processing loops
pub async fn start_message_processing(&self) { pub async fn start_message_processing(&self) {
// Recover durable Agent state for this runtime generation: interrupt // Recover durable Agent state for this runtime generation: interrupt
// runs of older generations, expire stale inbox leases, converge // runs of older generations, expire stale inbox leases and reconcile
// group counters and reconcile capacity rows. Runs never recover // capacity rows. Runs never recover while the generation is still a candidate.
// while the generation is still a candidate.
if let Some(coordinator) = self.session_manager.agent_coordinator() { if let Some(coordinator) = self.session_manager.agent_coordinator() {
match coordinator.recover_on_activation().await { match coordinator.recover_on_activation().await {
Ok(report) => { Ok(report) => {
@ -312,7 +329,6 @@ impl GatewayState {
leases_expired = report.leases_expired, leases_expired = report.leases_expired,
dead_lettered = report.dead_lettered, dead_lettered = report.dead_lettered,
sessions_reconciled = report.sessions_reconciled, sessions_reconciled = report.sessions_reconciled,
groups_converged = report.groups_converged,
"Agent state recovered on activation" "Agent state recovered on activation"
); );
} }
@ -673,6 +689,12 @@ fn build_router(state: Arc<GatewayState>) -> Router {
.route("/api/skills", routing::get(http::get_skills)) .route("/api/skills", routing::get(http::get_skills))
.route("/api/jobs", routing::get(http::get_jobs)) .route("/api/jobs", routing::get(http::get_jobs))
.route("/api/jobs/{id}/runs", routing::get(http::get_job_runs)) .route("/api/jobs/{id}/runs", routing::get(http::get_job_runs))
.route(
"/api/agents",
routing::get(http::list_agents).post(http::put_agent),
)
.route("/api/agents/options", routing::get(http::get_agent_options))
.route("/api/agents/{id}", routing::delete(http::delete_agent))
.route("/api/agent-runs", routing::get(http::get_agent_runs)) .route("/api/agent-runs", routing::get(http::get_agent_runs))
.route( .route(
"/api/agent-runs/{id}", "/api/agent-runs/{id}",

View File

@ -44,8 +44,6 @@ pub struct MessageAttachment {
pub struct AgentRunView { pub struct AgentRunView {
pub id: String, pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub group_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_run_id: Option<String>, pub parent_run_id: Option<String>,
pub agent_id: String, pub agent_id: String,
pub provider_name: String, pub provider_name: String,
@ -80,8 +78,6 @@ pub struct AgentEventView {
pub id: String, pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>, pub run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_id: Option<String>,
pub event_type: String, pub event_type: String,
pub delivery: String, pub delivery: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@ -114,7 +110,6 @@ impl AgentRunView {
.map(|result| truncate(result, max_result_chars)); .map(|result| truncate(result, max_result_chars));
Self { Self {
id: record.id.clone(), id: record.id.clone(),
group_id: record.group_id.clone(),
parent_run_id: record.parent_run_id.clone(), parent_run_id: record.parent_run_id.clone(),
agent_id: record.agent_id.clone(), agent_id: record.agent_id.clone(),
provider_name: record.provider_name.clone(), provider_name: record.provider_name.clone(),
@ -142,7 +137,6 @@ impl AgentEventView {
Self { Self {
id: record.id.clone(), id: record.id.clone(),
run_id: record.run_id.clone(), run_id: record.run_id.clone(),
group_id: record.group_id.clone(),
event_type: record.event_type.as_str().to_string(), event_type: record.event_type.as_str().to_string(),
delivery: record.delivery.as_str().to_string(), delivery: record.delivery.as_str().to_string(),
severity: record.severity.clone(), severity: record.severity.clone(),
@ -614,7 +608,6 @@ mod tests {
fn agent_run_and_event_views_serialize_without_sensitive_fields() { fn agent_run_and_event_views_serialize_without_sensitive_fields() {
let run = crate::storage::agent_run::AgentRunRecord { let run = crate::storage::agent_run::AgentRunRecord {
id: "run-1".to_string(), id: "run-1".to_string(),
group_id: None,
root_session_id: "cli:test:d1".to_string(), root_session_id: "cli:test:d1".to_string(),
root_turn_id: None, root_turn_id: None,
parent_run_id: None, parent_run_id: None,
@ -635,8 +628,6 @@ mod tests {
budget_json: r#"{"remaining_runs":3}"#.to_string(), budget_json: r#"{"remaining_runs":3}"#.to_string(),
signal_contract_json: Some("secret contract".to_string()), signal_contract_json: Some("secret contract".to_string()),
signal_delivery: None, signal_delivery: None,
completion_delivery: None,
failure_delivery: None,
status: crate::storage::agent_run::AgentRunStatus::Completed, status: crate::storage::agent_run::AgentRunStatus::Completed,
result: Some("r".repeat(10_000)), result: Some("r".repeat(10_000)),
error: None, error: None,

View File

@ -141,7 +141,6 @@ impl SessionManager {
task_id: Some(job_id.to_string()), task_id: Some(job_id.to_string()),
from_run_id: None, from_run_id: None,
from_agent_id: None, from_agent_id: None,
group_id: None,
}, },
Vec::new(), Vec::new(),
) )
@ -176,7 +175,6 @@ mod tests {
task_id: None, task_id: None,
from_run_id: None, from_run_id: None,
from_agent_id: None, from_agent_id: None,
group_id: None,
}; };
let media = vec![MediaItem::new("/tmp/report.pdf", "file")]; let media = vec![MediaItem::new("/tmp/report.pdf", "file")];

View File

@ -752,17 +752,6 @@ fn steer_input_from_event(
content, content,
) )
} }
AgentEventType::GroupCompletion => {
let group_id = event.group_id.clone().unwrap_or_default();
let content = format!("[后台 Agent 任务组结果] group={group_id}");
(
TurnInputSource::AgentGroupCompletion {
group_id: group_id.clone(),
},
None,
content,
)
}
}; };
let input = TurnInput { let input = TurnInput {
id: format!("steer:{}", event.id), id: format!("steer:{}", event.id),
@ -1744,6 +1733,9 @@ pub struct SessionManagerServices {
pub struct AgentCatalogPreparation { pub struct AgentCatalogPreparation {
pub provider_profiles: HashMap<String, LLMProviderConfig>, pub provider_profiles: HashMap<String, LLMProviderConfig>,
pub providers: HashMap<String, crate::config::ProviderConfig>,
pub models: HashMap<String, crate::config::ModelConfig>,
pub workspace_dir: std::path::PathBuf,
pub config: crate::config::AgentOrchestrationConfig, pub config: crate::config::AgentOrchestrationConfig,
pub config_dir: std::path::PathBuf, pub config_dir: std::path::PathBuf,
pub runtime_generation: u64, pub runtime_generation: u64,
@ -1967,6 +1959,9 @@ impl SessionManager {
&catalog_preparation.config, &catalog_preparation.config,
&catalog_preparation.config_dir, &catalog_preparation.config_dir,
&catalog_preparation.provider_profiles, &catalog_preparation.provider_profiles,
&catalog_preparation.providers,
&catalog_preparation.models,
&catalog_preparation.workspace_dir,
&tools, &tools,
&skills_loader, &skills_loader,
catalog_preparation.runtime_generation, catalog_preparation.runtime_generation,
@ -2017,25 +2012,6 @@ impl SessionManager {
tools.register(delegate_tool); tools.register(delegate_tool);
tools.register(crate::tools::ReloadConfigTool::new(reload.clone())); tools.register(crate::tools::ReloadConfigTool::new(reload.clone()));
// Start periodic background task cleanup (every hour, TTL 24h)
let cleanup_storage = storage.clone();
task_supervisor.spawn("background-task-cleanup", async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
interval.tick().await; // skip immediate first tick
loop {
interval.tick().await;
match cleanup_storage.cleanup_old_tasks(86_400_000).await {
Ok(count) if count > 0 => {
tracing::info!(count, "Cleaned up old background tasks");
}
Err(e) => {
tracing::warn!(error = %e, "Failed to clean up old background tasks");
}
_ => {}
}
}
});
Ok(Self { Ok(Self {
inner: Arc::new(Mutex::new(SessionManagerInner { inner: Arc::new(Mutex::new(SessionManagerInner {
sessions: HashMap::new(), sessions: HashMap::new(),
@ -3156,7 +3132,6 @@ impl SessionManager {
task_id: task_id.map(|s| s.to_string()), task_id: task_id.map(|s| s.to_string()),
from_run_id: None, from_run_id: None,
from_agent_id: None, from_agent_id: None,
group_id: None,
}; };
let msg = ChatMessage::assistant_with_source(content, source); let msg = ChatMessage::assistant_with_source(content, source);
append_persisted_messages(&session, vec![msg]) append_persisted_messages(&session, vec![msg])
@ -3286,7 +3261,6 @@ impl SessionManager {
task_id: None, task_id: None,
from_run_id: None, from_run_id: None,
from_agent_id: None, from_agent_id: None,
group_id: None,
}; };
let mut message = let mut message =
guard.create_user_message_with_source(content, media_refs, source); guard.create_user_message_with_source(content, media_refs, source);
@ -3570,11 +3544,24 @@ fn spawn_agent_worker(
}; };
let mut consecutive_user_turns = 0usize; let mut consecutive_user_turns = 0usize;
'tasks: loop { 'tasks: loop {
// Fairness: a due inbox event must be processed before the // Drain user tasks first so we can tell whether the session
// next user Turn once the user burst budget is exhausted or // is idle. Admission sequence numbers are allocated while
// the oldest pending event has waited too long. The next // holding the Session lock; draining everything currently
// pending due time also arms a timer so a released event is // visible before selecting the smallest sequence keeps a
// re-claimed after its retry backoff without needing a wake. // terminal fallback from overtaking an earlier `/queue` task.
while let Ok(task) = task_rx.try_recv() {
local_tasks.push_back(task);
}
let has_user_backlog = !local_tasks.is_empty();
// Fairness: when user work is queued it runs first, but a due
// inbox event must still preempt it once the user burst
// budget is exhausted or the oldest pending event has waited
// too long. When idle (no user backlog) a due event is
// claimed immediately, so a background result is delivered as
// soon as its run finishes. The next pending due time also
// arms a timer so a released event is re-claimed after its
// retry backoff without needing a wake.
let mut next_due_at = None; let mut next_due_at = None;
let storage = { let storage = {
let guard = session.lock().await; let guard = session.lock().await;
@ -3595,8 +3582,11 @@ fn spawn_agent_worker(
) )
.await .await
.unwrap_or(None); .unwrap_or(None);
let force = consecutive_user_turns >= inbox_burst let age_exceeded = oldest_due
|| oldest_due.is_some_and(|created_at| now - created_at >= inbox_wait_ms); .is_some_and(|created_at| now - created_at >= inbox_wait_ms);
let burst_exceeded = consecutive_user_turns >= inbox_burst;
let force = oldest_due.is_some()
&& (!has_user_backlog || burst_exceeded || age_exceeded);
if force if force
&& let Ok(Some(lease)) = crate::storage::Storage::claim_inbox_batch( && let Ok(Some(lease)) = crate::storage::Storage::claim_inbox_batch(
&storage, &storage,
@ -3628,13 +3618,6 @@ fn spawn_agent_worker(
} }
} }
// Admission sequence numbers are allocated while holding the
// Session lock. Drain everything currently visible on the
// channel before selecting the smallest sequence, so a
// terminal fallback cannot overtake an earlier `/queue` task.
while let Ok(task) = task_rx.try_recv() {
local_tasks.push_back(task);
}
let task = if let Some(task) = pop_lowest_sequence(&mut local_tasks) { let task = if let Some(task) = pop_lowest_sequence(&mut local_tasks) {
task task
} else { } else {
@ -3688,7 +3671,6 @@ fn spawn_agent_worker(
task_id: None, task_id: None,
from_run_id: None, from_run_id: None,
from_agent_id: None, from_agent_id: None,
group_id: None,
}; };
let mut message = let mut message =
guard.create_user_message_with_source(&task.content, media_refs, source); guard.create_user_message_with_source(&task.content, media_refs, source);

View File

@ -8,7 +8,6 @@ use crate::bus::{ClientVisibility, TurnOrigin};
pub enum AgentEventType { pub enum AgentEventType {
Signal, Signal,
Completion, Completion,
GroupCompletion,
} }
impl AgentEventType { impl AgentEventType {
@ -16,7 +15,6 @@ impl AgentEventType {
match self { match self {
Self::Signal => "signal", Self::Signal => "signal",
Self::Completion => "completion", Self::Completion => "completion",
Self::GroupCompletion => "group_completion",
} }
} }
@ -24,7 +22,6 @@ impl AgentEventType {
match value { match value {
"signal" => Ok(Self::Signal), "signal" => Ok(Self::Signal),
"completion" => Ok(Self::Completion), "completion" => Ok(Self::Completion),
"group_completion" => Ok(Self::GroupCompletion),
other => Err(StorageError::Migration(format!( other => Err(StorageError::Migration(format!(
"corrupt agent event type '{other}'" "corrupt agent event type '{other}'"
))), ))),
@ -98,10 +95,7 @@ impl AgentEventStatus {
pub struct AgentInboxEventRecord { pub struct AgentInboxEventRecord {
pub id: String, pub id: String,
pub root_session_id: String, pub root_session_id: String,
pub scope_kind: String,
pub scope_id: String,
pub run_id: Option<String>, pub run_id: Option<String>,
pub group_id: Option<String>,
pub event_type: AgentEventType, pub event_type: AgentEventType,
pub event_key: String, pub event_key: String,
pub delivery: AgentEventDelivery, pub delivery: AgentEventDelivery,
@ -128,10 +122,7 @@ pub struct AgentInboxEventRecord {
pub struct NewInboxEvent { pub struct NewInboxEvent {
pub id: String, pub id: String,
pub root_session_id: String, pub root_session_id: String,
pub scope_kind: String,
pub scope_id: String,
pub run_id: Option<String>, pub run_id: Option<String>,
pub group_id: Option<String>,
pub event_type: AgentEventType, pub event_type: AgentEventType,
pub event_key: String, pub event_key: String,
pub delivery: AgentEventDelivery, pub delivery: AgentEventDelivery,
@ -163,13 +154,12 @@ pub struct RecoveryReport {
pub leases_expired: usize, pub leases_expired: usize,
pub dead_lettered: usize, pub dead_lettered: usize,
pub sessions_reconciled: usize, pub sessions_reconciled: usize,
pub groups_converged: usize,
} }
const EVENT_COLUMNS: &str = "id, root_session_id, scope_kind, scope_id, run_id, group_id, \ const EVENT_COLUMNS: &str = "id, root_session_id, run_id, event_type, event_key, delivery, \
event_type, event_key, delivery, requires_continuation, severity, payload_json, status, \ requires_continuation, severity, payload_json, status, attempt_count, lease_token, \
attempt_count, lease_token, lease_until, next_attempt_at, admitted_turn_id, last_error, \ lease_until, next_attempt_at, admitted_turn_id, last_error, revision, created_at, \
revision, created_at, consumed_at, superseded_at, dead_lettered_at, fallback_notified_at, \ consumed_at, superseded_at, dead_lettered_at, fallback_notified_at, \
fallback_suppressed_reason"; fallback_suppressed_reason";
fn event_record_from_row( fn event_record_from_row(
@ -178,10 +168,7 @@ fn event_record_from_row(
Ok(AgentInboxEventRecord { Ok(AgentInboxEventRecord {
id: row.get("id"), id: row.get("id"),
root_session_id: row.get("root_session_id"), root_session_id: row.get("root_session_id"),
scope_kind: row.get("scope_kind"),
scope_id: row.get("scope_id"),
run_id: row.get("run_id"), run_id: row.get("run_id"),
group_id: row.get("group_id"),
event_type: AgentEventType::parse(row.get::<&str, _>("event_type"))?, event_type: AgentEventType::parse(row.get::<&str, _>("event_type"))?,
event_key: row.get("event_key"), event_key: row.get("event_key"),
delivery: AgentEventDelivery::parse(row.get::<&str, _>("delivery"))?, delivery: AgentEventDelivery::parse(row.get::<&str, _>("delivery"))?,
@ -295,10 +282,9 @@ impl super::Storage {
ensure_agent_session_state_tx(&mut tx, &event.root_session_id, now).await?; ensure_agent_session_state_tx(&mut tx, &event.root_session_id, now).await?;
if let Some(existing_id) = sqlx::query_scalar::<_, String>( if let Some(existing_id) = sqlx::query_scalar::<_, String>(
"SELECT id FROM agent_inbox_events \ "SELECT id FROM agent_inbox_events \
WHERE scope_kind = ? AND scope_id = ? AND event_type = ? AND event_key = ?", WHERE run_id = ? AND event_type = ? AND event_key = ?",
) )
.bind(&event.scope_kind) .bind(&event.run_id)
.bind(&event.scope_id)
.bind(event.event_type.as_str()) .bind(event.event_type.as_str())
.bind(&event.event_key) .bind(&event.event_key)
.fetch_optional(&mut *tx) .fetch_optional(&mut *tx)
@ -864,8 +850,7 @@ impl super::Storage {
/// failure completion event (the reservation is converted). /// failure completion event (the reservation is converted).
/// 2. Expired leases return to `pending` with a backoff; attempts beyond /// 2. Expired leases return to `pending` with a backoff; attempts beyond
/// the maximum become `dead_letter`. /// the maximum become `dead_letter`.
/// 3. Group counters are recomputed from their runs and finalized. /// 3. Per-session capacity counters are reconciled with the rows.
/// 4. Per-session capacity counters are reconciled with the rows.
pub async fn recover_agent_state( pub async fn recover_agent_state(
&self, &self,
active_generation: i64, active_generation: i64,
@ -877,14 +862,14 @@ impl super::Storage {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
// 1. Interrupt runs of previous generations. // 1. Interrupt runs of previous generations.
let interrupted: Vec<(String, String, i64)> = sqlx::query_as( let interrupted: Vec<(String, String, i64, String, String)> = sqlx::query_as(
"SELECT id, root_session_id, completion_slot_reserved FROM agent_runs \ "SELECT id, root_session_id, completion_slot_reserved, agent_id, task FROM agent_runs \
WHERE runtime_generation != ? AND status IN ('queued', 'running', 'waiting_children')", WHERE runtime_generation != ? AND status IN ('queued', 'running', 'waiting_children')",
) )
.bind(active_generation) .bind(active_generation)
.fetch_all(&mut *tx) .fetch_all(&mut *tx)
.await?; .await?;
for (run_id, session_id, reserved) in &interrupted { for (run_id, session_id, reserved, agent_id, task) in &interrupted {
let updated = sqlx::query( let updated = sqlx::query(
"UPDATE agent_runs SET status = 'interrupted', error = ?, finished_at = ?, updated_at = ? \ "UPDATE agent_runs SET status = 'interrupted', error = ?, finished_at = ?, updated_at = ? \
WHERE id = ? AND status IN ('queued', 'running', 'waiting_children')", WHERE id = ? AND status IN ('queued', 'running', 'waiting_children')",
@ -906,20 +891,21 @@ impl super::Storage {
let event = NewInboxEvent { let event = NewInboxEvent {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
root_session_id: session_id.clone(), root_session_id: session_id.clone(),
scope_kind: "run".to_string(),
scope_id: run_id.clone(),
run_id: Some(run_id.clone()), run_id: Some(run_id.clone()),
group_id: None,
event_type: AgentEventType::Completion, event_type: AgentEventType::Completion,
event_key: format!("interrupted:{run_id}"), event_key: format!("interrupted:{run_id}"),
delivery: AgentEventDelivery::Queue, delivery: AgentEventDelivery::Queue,
requires_continuation: true, requires_continuation: true,
severity: Some("error".to_string()), severity: Some("error".to_string()),
payload_json: serde_json::json!({ payload_json: completion_payload(
"status": "interrupted", run_id,
"error": "interrupted by runtime generation handover", agent_id,
}) task,
.to_string(), None,
"interrupted",
Some("interrupted by runtime generation handover"),
&[],
),
}; };
insert_event_tx(&mut tx, &event, revision, now).await?; insert_event_tx(&mut tx, &event, revision, now).await?;
report.completion_events_generated += 1; report.completion_events_generated += 1;
@ -978,61 +964,10 @@ impl super::Storage {
} }
} }
// 3. Converge group counters from their runs. // 3. Reconcile per-session capacity counters.
let group_ids: Vec<String> = sqlx::query_scalar(
"SELECT id FROM agent_run_groups WHERE status IN ('queued', 'running')",
)
.fetch_all(&mut *tx)
.await?;
for group_id in &group_ids {
let (terminal, abnormal): (i64, i64) = sqlx::query_as(
"SELECT \
COUNT(*) FILTER (WHERE status IN ('completed','failed','timed_out','cancelled','interrupted')), \
COUNT(*) FILTER (WHERE status IN ('failed','timed_out','cancelled','interrupted')) \
FROM agent_runs WHERE group_id = ?",
)
.bind(group_id)
.fetch_one(&mut *tx)
.await?;
let expected: i64 =
sqlx::query_scalar("SELECT expected_runs FROM agent_run_groups WHERE id = ?")
.bind(group_id)
.fetch_one(&mut *tx)
.await?;
let status = if terminal >= expected && terminal > 0 {
if abnormal == 0 {
"completed"
} else if abnormal < expected {
"partial"
} else {
"failed"
}
} else {
"running"
};
sqlx::query(
"UPDATE agent_run_groups SET terminal_runs = ?, abnormal_runs = ?, status = ?, \
finished_at = CASE WHEN status IN ('completed','partial','failed','timed_out','cancelled','interrupted') THEN ? ELSE NULL END, \
updated_at = ? WHERE id = ?",
)
.bind(terminal)
.bind(abnormal)
.bind(status)
.bind(now)
.bind(now)
.bind(group_id)
.execute(&mut *tx)
.await?;
if status != "running" {
report.groups_converged += 1;
}
}
// 4. Reconcile per-session capacity counters.
let sessions: Vec<(String, i64, i64)> = sqlx::query_as( let sessions: Vec<(String, i64, i64)> = sqlx::query_as(
"SELECT root_session_id, \ "SELECT root_session_id, \
(SELECT COUNT(*) FROM agent_runs r WHERE r.root_session_id = s.root_session_id AND r.completion_slot_reserved = 1) \ (SELECT COUNT(*) FROM agent_runs r WHERE r.root_session_id = s.root_session_id AND r.completion_slot_reserved = 1), \
+ (SELECT COUNT(*) FROM agent_run_groups g WHERE g.root_session_id = s.root_session_id AND g.completion_slot_reserved = 1), \
(SELECT COUNT(*) FROM agent_inbox_events e WHERE e.root_session_id = s.root_session_id AND e.status IN ('pending','leased','admitted')) \ (SELECT COUNT(*) FROM agent_inbox_events e WHERE e.root_session_id = s.root_session_id AND e.status IN ('pending','leased','admitted')) \
FROM agent_session_state s", FROM agent_session_state s",
) )
@ -1116,17 +1051,14 @@ pub(crate) async fn insert_event_tx(
now: i64, now: i64,
) -> Result<(), StorageError> { ) -> Result<(), StorageError> {
sqlx::query( sqlx::query(
"INSERT INTO agent_inbox_events (id, root_session_id, scope_kind, scope_id, run_id, \ "INSERT INTO agent_inbox_events (id, root_session_id, run_id, event_type, event_key, \
group_id, event_type, event_key, delivery, requires_continuation, severity, \ delivery, requires_continuation, severity, payload_json, status, attempt_count, \
payload_json, status, attempt_count, revision, next_attempt_at, created_at, updated_at) \ revision, next_attempt_at, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)",
) )
.bind(&event.id) .bind(&event.id)
.bind(&event.root_session_id) .bind(&event.root_session_id)
.bind(&event.scope_kind)
.bind(&event.scope_id)
.bind(&event.run_id) .bind(&event.run_id)
.bind(&event.group_id)
.bind(event.event_type.as_str()) .bind(event.event_type.as_str())
.bind(&event.event_key) .bind(&event.event_key)
.bind(event.delivery.as_str()) .bind(event.delivery.as_str())
@ -1144,10 +1076,14 @@ pub(crate) async fn insert_event_tx(
/// Helper used by `commit_agent_terminal` to materialize a completion event /// Helper used by `commit_agent_terminal` to materialize a completion event
/// for a background run that reserved a slot. /// for a background run that reserved a slot.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn insert_completion_event_tx( pub(crate) async fn insert_completion_event_tx(
tx: &mut sqlx::SqliteConnection, tx: &mut sqlx::SqliteConnection,
run_id: &str, run_id: &str,
session_id: &str, session_id: &str,
agent_id: &str,
task: &str,
result: Option<&str>,
status: &str, status: &str,
error: Option<&str>, error: Option<&str>,
signal_ids: &[String], signal_ids: &[String],
@ -1161,10 +1097,7 @@ pub(crate) async fn insert_completion_event_tx(
let event = NewInboxEvent { let event = NewInboxEvent {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
root_session_id: session_id.to_string(), root_session_id: session_id.to_string(),
scope_kind: "run".to_string(),
scope_id: run_id.to_string(),
run_id: Some(run_id.to_string()), run_id: Some(run_id.to_string()),
group_id: None,
event_type: AgentEventType::Completion, event_type: AgentEventType::Completion,
event_key: format!("completion:{run_id}"), event_key: format!("completion:{run_id}"),
delivery: AgentEventDelivery::Queue, delivery: AgentEventDelivery::Queue,
@ -1174,28 +1107,115 @@ pub(crate) async fn insert_completion_event_tx(
} else { } else {
None None
}, },
payload_json: serde_json::json!({ payload_json: completion_payload(run_id, agent_id, task, result, status, error, signal_ids),
"status": status,
"error": error,
"signal_ids": signal_ids,
})
.to_string(),
}; };
insert_event_tx(tx, &event, revision, now).await insert_event_tx(tx, &event, revision, now).await
} }
/// Default trigger content for a continuation Turn. /// Bounded payload for a run completion event. Carries enough task/result
/// context for the main Agent to report which run finished and what it
/// produced, while the full result stays in `agent_runs.result`.
pub(crate) fn completion_payload(
run_id: &str,
agent_id: &str,
task: &str,
result: Option<&str>,
status: &str,
error: Option<&str>,
signal_ids: &[String],
) -> String {
serde_json::json!({
"run_id": run_id,
"agent_id": agent_id,
"task": truncate_utf8(task, 500),
"result": result.map(|r| truncate_utf8(r, 2_000)),
"status": status,
"error": error,
"signal_ids": signal_ids,
})
.to_string()
}
/// Truncate a string to at most `max` UTF-8 characters.
pub(crate) fn truncate_utf8(value: &str, max: usize) -> String {
if value.chars().count() <= max {
value.to_string()
} else {
let mut truncated: String = value.chars().take(max).collect();
truncated.push('…');
truncated
}
}
/// Default trigger content for a continuation Turn. Events are rendered as
/// readable summaries (task, agent, result) rather than raw JSON so the main
/// Agent knows exactly which run finished and what it produced.
pub fn build_continuation_trigger( pub fn build_continuation_trigger(
events: &[AgentInboxEventRecord], events: &[AgentInboxEventRecord],
now: i64, now: i64,
) -> crate::bus::ChatMessage { ) -> crate::bus::ChatMessage {
let mut content = String::from( let mut content = String::from(
"后台 Agent 任务已经完成。请结合以下结果继续当前对话,向用户呈现最相关的部分;\ "后台 Agent 任务已经完成,以下是完成结果。请结合这些结果继续当前对话,向用户呈现最相关的部分;\
", ",
); );
for event in events { for event in events {
content.push_str("\n\n- "); let payload: serde_json::Value =
content.push_str(&event.payload_json); serde_json::from_str(&event.payload_json).unwrap_or(serde_json::Value::Null);
let get = |key: &str| {
payload
.get(key)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string()
};
match event.event_type {
AgentEventType::Completion => {
let status = get("status");
let agent_id = get("agent_id");
let run_id = get("run_id");
let task = get("task");
let result = get("result");
let error = get("error");
content.push_str("\n\n## 后台任务完成");
if !agent_id.is_empty() {
content.push_str(&format!("Agent{agent_id}"));
}
content.push_str(&format!(",状态:{status}"));
if !run_id.is_empty() {
content.push_str(&format!("Run ID{run_id}"));
}
content.push('\n');
if !task.is_empty() {
content.push_str(&format!("- 任务:{task}\n"));
}
if !result.is_empty() {
content.push_str(&format!("- 结果:{result}\n"));
}
if !error.is_empty() {
content.push_str(&format!("- 错误:{error}\n"));
}
}
AgentEventType::Signal => {
let severity = get("severity");
let agent_id = get("agent_id");
let run_id = get("run_id");
let summary = get("summary");
content.push_str("\n\n## 后台信号");
if !severity.is_empty() {
content.push_str(&format!("(严重级别:{severity}"));
}
if !agent_id.is_empty() {
content.push_str(&format!("Agent{agent_id}"));
}
if !run_id.is_empty() {
content.push_str(&format!("Run ID{run_id}"));
}
content.push('\n');
if !summary.is_empty() {
content.push_str(&format!("- 摘要:{summary}\n"));
}
}
}
} }
let mut message = crate::bus::ChatMessage::user(content); let mut message = crate::bus::ChatMessage::user(content);
message.client_visibility = ClientVisibility::Hidden; message.client_visibility = ClientVisibility::Hidden;
@ -1234,10 +1254,7 @@ mod tests {
NewInboxEvent { NewInboxEvent {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
root_session_id: session.to_string(), root_session_id: session.to_string(),
scope_kind: "run".to_string(),
scope_id: run_id.to_string(),
run_id: Some(run_id.to_string()), run_id: Some(run_id.to_string()),
group_id: None,
event_type: AgentEventType::Completion, event_type: AgentEventType::Completion,
event_key: format!("completion:{run_id}"), event_key: format!("completion:{run_id}"),
delivery: AgentEventDelivery::Queue, delivery: AgentEventDelivery::Queue,
@ -1251,7 +1268,6 @@ mod tests {
use crate::storage::agent_run::{AcceptAgentRequest, AgentRunMode, NewAgentRun}; use crate::storage::agent_run::{AcceptAgentRequest, AgentRunMode, NewAgentRun};
storage storage
.accept_agent_runs(AcceptAgentRequest { .accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![NewAgentRun { runs: vec![NewAgentRun {
id: run_id.to_string(), id: run_id.to_string(),
root_session_id: session.to_string(), root_session_id: session.to_string(),
@ -1697,7 +1713,6 @@ mod tests {
}; };
storage storage
.accept_agent_runs(AcceptAgentRequest { .accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![run], runs: vec![run],
now: 10, now: 10,
}) })
@ -1760,10 +1775,7 @@ mod tests {
NewInboxEvent { NewInboxEvent {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
root_session_id: session.to_string(), root_session_id: session.to_string(),
scope_kind: "run".to_string(),
scope_id: run_id.to_string(),
run_id: Some(run_id.to_string()), run_id: Some(run_id.to_string()),
group_id: None,
event_type: AgentEventType::Signal, event_type: AgentEventType::Signal,
event_key: format!("signal:{dedupe_key}:{window}"), event_key: format!("signal:{dedupe_key}:{window}"),
delivery: AgentEventDelivery::Steer, delivery: AgentEventDelivery::Steer,
@ -2034,4 +2046,54 @@ mod tests {
assert_eq!(record.status, AgentEventStatus::Pending); assert_eq!(record.status, AgentEventStatus::Pending);
assert_eq!(record.next_attempt_at, Some(120)); assert_eq!(record.next_attempt_at, Some(120));
} }
#[test]
fn completion_trigger_renders_task_agent_and_result() {
let event = AgentInboxEventRecord {
id: "evt-1".to_string(),
root_session_id: "cli:test:d1".to_string(),
run_id: Some("run-1".to_string()),
event_type: AgentEventType::Completion,
event_key: "completion:run-1".to_string(),
delivery: AgentEventDelivery::Queue,
requires_continuation: true,
severity: None,
payload_json: completion_payload(
"run-1",
"researcher",
"在 bash 中执行 sleep 5 并返回随机数",
Some("随机数 19471"),
"completed",
None,
&[],
),
status: AgentEventStatus::Pending,
attempt_count: 0,
lease_token: None,
lease_until: None,
next_attempt_at: None,
admitted_turn_id: None,
last_error: None,
revision: 1,
created_at: 1,
consumed_at: None,
superseded_at: None,
dead_lettered_at: None,
fallback_notified_at: None,
fallback_suppressed_reason: None,
};
let trigger = build_continuation_trigger(&[event], 1);
assert_eq!(
trigger.client_visibility,
crate::bus::ClientVisibility::Hidden
);
assert!(trigger.content.contains("researcher"));
assert!(trigger.content.contains("run-1"));
assert!(trigger.content.contains("在 bash 中执行 sleep 5"));
assert!(trigger.content.contains("随机数 19471"));
assert!(
!trigger.content.contains("\"status\""),
"raw JSON must not be injected"
);
}
} }

View File

@ -2,48 +2,14 @@ use sqlx::{Row, SqliteConnection};
use super::StorageError; use super::StorageError;
/// Frozen schema v6 DDL for the Agent orchestration tables. Executed inside /// Frozen DDL for the Agent orchestration tables. Executed inside the single
/// the single migration transaction so table creation, column additions and /// migration transaction so table creation, column additions and `user_version`
/// `user_version` advance atomically. The inbox tables belong to Phase 3 /// advance atomically. The inbox table belongs to Phase 3 behavior but its
/// behavior but their shape is frozen together with the run tables. /// shape is frozen together with the run tables.
pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
r#"
CREATE TABLE IF NOT EXISTS agent_run_groups (
id TEXT PRIMARY KEY,
root_session_id TEXT NOT NULL,
caller_run_id TEXT,
caller_scope_id TEXT NOT NULL,
idempotency_key TEXT,
mode TEXT NOT NULL,
completion_policy TEXT NOT NULL,
expected_runs INTEGER NOT NULL,
terminal_runs INTEGER NOT NULL DEFAULT 0,
abnormal_runs INTEGER NOT NULL DEFAULT 0,
completion_slot_reserved INTEGER NOT NULL DEFAULT 0,
completion_delivery TEXT,
failure_delivery TEXT,
deadline_at INTEGER NOT NULL,
status TEXT NOT NULL,
runtime_generation INTEGER NOT NULL,
revision INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER,
CHECK (mode IN ('foreground', 'background')),
CHECK (completion_policy IN ('all', 'each')),
CHECK (status IN ('queued', 'running', 'completed', 'partial', 'failed',
'timed_out', 'cancelled', 'interrupted')),
CHECK (expected_runs > 0),
CHECK (terminal_runs >= 0 AND terminal_runs <= expected_runs),
CHECK (completion_slot_reserved IN (0, 1))
)
"#,
"CREATE INDEX IF NOT EXISTS idx_agent_groups_session_created ON agent_run_groups(root_session_id, created_at DESC)",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_groups_idempotency ON agent_run_groups(root_session_id, caller_scope_id, idempotency_key) WHERE idempotency_key IS NOT NULL",
r#" r#"
CREATE TABLE IF NOT EXISTS agent_runs ( CREATE TABLE IF NOT EXISTS agent_runs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
group_id TEXT,
root_session_id TEXT NOT NULL, root_session_id TEXT NOT NULL,
root_turn_id TEXT, root_turn_id TEXT,
parent_run_id TEXT, parent_run_id TEXT,
@ -64,8 +30,6 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
budget_json TEXT NOT NULL, budget_json TEXT NOT NULL,
signal_contract_json TEXT, signal_contract_json TEXT,
signal_delivery TEXT, signal_delivery TEXT,
completion_delivery TEXT,
failure_delivery TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
result TEXT, result TEXT,
error TEXT, error TEXT,
@ -88,7 +52,6 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
'failed', 'timed_out', 'cancelled', 'interrupted')), 'failed', 'timed_out', 'cancelled', 'interrupted')),
CHECK (depth >= 1), CHECK (depth >= 1),
CHECK (completion_slot_reserved IN (0, 1)), CHECK (completion_slot_reserved IN (0, 1)),
FOREIGN KEY (group_id) REFERENCES agent_run_groups(id) ON DELETE RESTRICT,
FOREIGN KEY (parent_run_id) REFERENCES agent_runs(id) ON DELETE RESTRICT FOREIGN KEY (parent_run_id) REFERENCES agent_runs(id) ON DELETE RESTRICT
) )
"#, "#,
@ -113,10 +76,7 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
CREATE TABLE IF NOT EXISTS agent_inbox_events ( CREATE TABLE IF NOT EXISTS agent_inbox_events (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
root_session_id TEXT NOT NULL, root_session_id TEXT NOT NULL,
scope_kind TEXT NOT NULL, run_id TEXT NOT NULL,
scope_id TEXT NOT NULL,
run_id TEXT,
group_id TEXT,
event_type TEXT NOT NULL, event_type TEXT NOT NULL,
event_key TEXT NOT NULL, event_key TEXT NOT NULL,
delivery TEXT NOT NULL, delivery TEXT NOT NULL,
@ -138,21 +98,13 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
fallback_notified_at INTEGER, fallback_notified_at INTEGER,
fallback_suppressed_reason TEXT, fallback_suppressed_reason TEXT,
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
CHECK (scope_kind IN ('run', 'group')), CHECK (event_type IN ('signal', 'completion')),
CHECK (event_type IN ('signal', 'completion', 'group_completion')),
CHECK (delivery IN ('queue', 'steer')), CHECK (delivery IN ('queue', 'steer')),
CHECK (requires_continuation IN (0, 1)), CHECK (requires_continuation IN (0, 1)),
CHECK (status IN ('pending', 'leased', 'admitted', 'consumed', CHECK (status IN ('pending', 'leased', 'admitted', 'consumed',
'superseded', 'dead_letter')), 'superseded', 'dead_letter')),
CHECK ( UNIQUE(run_id, event_type, event_key),
(scope_kind = 'run' AND run_id IS NOT NULL AND group_id IS NULL FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE RESTRICT
AND scope_id = run_id) OR
(scope_kind = 'group' AND group_id IS NOT NULL AND run_id IS NULL
AND scope_id = group_id)
),
UNIQUE(scope_kind, scope_id, event_type, event_key),
FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE RESTRICT,
FOREIGN KEY (group_id) REFERENCES agent_run_groups(id) ON DELETE RESTRICT
) )
"#, "#,
"CREATE INDEX IF NOT EXISTS idx_agent_inbox_claim ON agent_inbox_events(root_session_id, status, next_attempt_at, created_at)", "CREATE INDEX IF NOT EXISTS idx_agent_inbox_claim ON agent_inbox_events(root_session_id, status, next_attempt_at, created_at)",
@ -185,31 +137,6 @@ impl AgentRunMode {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentCompletionPolicy {
All,
Each,
}
impl AgentCompletionPolicy {
pub fn as_str(&self) -> &'static str {
match self {
Self::All => "all",
Self::Each => "each",
}
}
pub fn parse(value: &str) -> Result<Self, StorageError> {
match value {
"all" => Ok(Self::All),
"each" => Ok(Self::Each),
other => Err(StorageError::Migration(format!(
"corrupt agent completion policy '{other}'"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentRunStatus { pub enum AgentRunStatus {
Queued, Queued,
@ -257,81 +184,9 @@ impl AgentRunStatus {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentGroupStatus {
Queued,
Running,
Completed,
Partial,
Failed,
TimedOut,
Cancelled,
Interrupted,
}
impl AgentGroupStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Completed => "completed",
Self::Partial => "partial",
Self::Failed => "failed",
Self::TimedOut => "timed_out",
Self::Cancelled => "cancelled",
Self::Interrupted => "interrupted",
}
}
pub fn parse(value: &str) -> Result<Self, StorageError> {
match value {
"queued" => Ok(Self::Queued),
"running" => Ok(Self::Running),
"completed" => Ok(Self::Completed),
"partial" => Ok(Self::Partial),
"failed" => Ok(Self::Failed),
"timed_out" => Ok(Self::TimedOut),
"cancelled" => Ok(Self::Cancelled),
"interrupted" => Ok(Self::Interrupted),
other => Err(StorageError::Migration(format!(
"corrupt agent group status '{other}'"
))),
}
}
pub fn is_terminal(self) -> bool {
!matches!(self, Self::Queued | Self::Running)
}
}
#[derive(Debug, Clone)]
pub struct AgentRunGroupRecord {
pub id: String,
pub root_session_id: String,
pub caller_run_id: Option<String>,
pub caller_scope_id: String,
pub idempotency_key: Option<String>,
pub mode: AgentRunMode,
pub completion_policy: AgentCompletionPolicy,
pub expected_runs: i64,
pub terminal_runs: i64,
pub abnormal_runs: i64,
pub completion_slot_reserved: bool,
pub completion_delivery: Option<String>,
pub failure_delivery: Option<String>,
pub deadline_at: i64,
pub status: AgentGroupStatus,
pub runtime_generation: i64,
pub revision: i64,
pub created_at: i64,
pub updated_at: i64,
pub finished_at: Option<i64>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AgentRunRecord { pub struct AgentRunRecord {
pub id: String, pub id: String,
pub group_id: Option<String>,
pub root_session_id: String, pub root_session_id: String,
pub root_turn_id: Option<String>, pub root_turn_id: Option<String>,
pub parent_run_id: Option<String>, pub parent_run_id: Option<String>,
@ -352,8 +207,6 @@ pub struct AgentRunRecord {
pub budget_json: String, pub budget_json: String,
pub signal_contract_json: Option<String>, pub signal_contract_json: Option<String>,
pub signal_delivery: Option<String>, pub signal_delivery: Option<String>,
pub completion_delivery: Option<String>,
pub failure_delivery: Option<String>,
pub status: AgentRunStatus, pub status: AgentRunStatus,
pub result: Option<String>, pub result: Option<String>,
pub error: Option<String>, pub error: Option<String>,
@ -404,39 +257,19 @@ pub struct NewAgentRun {
pub completion_slot_reserved: bool, pub completion_slot_reserved: bool,
} }
/// Group header for batch admission. Single-task requests must not create a /// Batch admission request. Each run carries its own idempotency key; a
/// group; their idempotency key lives on the run row instead. /// single-task request is just a one-element batch.
#[derive(Debug, Clone)]
pub struct NewAgentGroup {
pub id: String,
pub root_session_id: String,
pub caller_run_id: Option<String>,
pub caller_scope_id: String,
pub idempotency_key: Option<String>,
pub mode: AgentRunMode,
pub completion_policy: AgentCompletionPolicy,
pub deadline_at: i64,
pub runtime_generation: i64,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AcceptAgentRequest { pub struct AcceptAgentRequest {
pub group: Option<NewAgentGroup>,
pub runs: Vec<NewAgentRun>, pub runs: Vec<NewAgentRun>,
pub now: i64, pub now: i64,
} }
#[derive(Debug)] #[derive(Debug)]
pub enum AcceptedAgentRuns { pub enum AcceptedAgentRuns {
Accepted { Accepted { runs: Vec<AgentRunRecord> },
group: Option<AgentRunGroupRecord>, /// Idempotent retry: the run already existed for this key.
runs: Vec<AgentRunRecord>, Existing { runs: Vec<AgentRunRecord> },
},
/// Idempotent retry: the group/run already existed for this key.
Existing {
group: Option<AgentRunGroupRecord>,
runs: Vec<AgentRunRecord>,
},
} }
/// Terminal outcome produced by a runner. The Coordinator persists it; the /// Terminal outcome produced by a runner. The Coordinator persists it; the
@ -486,36 +319,24 @@ impl AgentTerminalOutcome {
} }
} }
pub fn is_abnormal(&self) -> bool {
!matches!(self, Self::Completed { .. })
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TerminalCommit { pub struct TerminalCommit {
pub run: AgentRunRecord, pub run: AgentRunRecord,
pub group: Option<AgentRunGroupRecord>,
pub group_finished: bool,
} }
const RUN_COLUMNS: &str = "id, group_id, root_session_id, root_turn_id, parent_run_id, \ const RUN_COLUMNS: &str = "id, root_session_id, root_turn_id, parent_run_id, \
caller_agent_id, caller_scope_id, idempotency_key, agent_id, definition_hash, \ caller_agent_id, caller_scope_id, idempotency_key, agent_id, definition_hash, \
provider_profile, provider_name, model_id, mode, depth, plan_item_id, execution_id, \ provider_profile, provider_name, model_id, mode, depth, plan_item_id, execution_id, \
task, context_json, budget_json, signal_contract_json, signal_delivery, \ task, context_json, budget_json, signal_contract_json, signal_delivery, \
completion_delivery, failure_delivery, status, result, error, prompt_tokens, \ status, result, error, prompt_tokens, completion_tokens, cost, tool_calls_count, \
completion_tokens, cost, tool_calls_count, iterations, runtime_generation, attempt, \ iterations, runtime_generation, attempt, completion_slot_reserved, deadline_at, \
completion_slot_reserved, deadline_at, revision, started_at, finished_at, \ revision, started_at, finished_at, created_at, updated_at";
created_at, updated_at";
const GROUP_COLUMNS: &str = "id, root_session_id, caller_run_id, caller_scope_id, \
idempotency_key, mode, completion_policy, expected_runs, terminal_runs, \
abnormal_runs, completion_slot_reserved, completion_delivery, failure_delivery, \
deadline_at, status, runtime_generation, revision, created_at, updated_at, finished_at";
fn run_record_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<AgentRunRecord, StorageError> { fn run_record_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<AgentRunRecord, StorageError> {
Ok(AgentRunRecord { Ok(AgentRunRecord {
id: row.get("id"), id: row.get("id"),
group_id: row.get("group_id"),
root_session_id: row.get("root_session_id"), root_session_id: row.get("root_session_id"),
root_turn_id: row.get("root_turn_id"), root_turn_id: row.get("root_turn_id"),
parent_run_id: row.get("parent_run_id"), parent_run_id: row.get("parent_run_id"),
@ -536,8 +357,6 @@ fn run_record_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<AgentRunRecord,
budget_json: row.get("budget_json"), budget_json: row.get("budget_json"),
signal_contract_json: row.get("signal_contract_json"), signal_contract_json: row.get("signal_contract_json"),
signal_delivery: row.get("signal_delivery"), signal_delivery: row.get("signal_delivery"),
completion_delivery: row.get("completion_delivery"),
failure_delivery: row.get("failure_delivery"),
status: AgentRunStatus::parse(row.get::<&str, _>("status"))?, status: AgentRunStatus::parse(row.get::<&str, _>("status"))?,
result: row.get("result"), result: row.get("result"),
error: row.get("error"), error: row.get("error"),
@ -558,38 +377,11 @@ fn run_record_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<AgentRunRecord,
}) })
} }
fn group_record_from_row(
row: &sqlx::sqlite::SqliteRow,
) -> Result<AgentRunGroupRecord, StorageError> {
Ok(AgentRunGroupRecord {
id: row.get("id"),
root_session_id: row.get("root_session_id"),
caller_run_id: row.get("caller_run_id"),
caller_scope_id: row.get("caller_scope_id"),
idempotency_key: row.get("idempotency_key"),
mode: AgentRunMode::parse(row.get::<&str, _>("mode"))?,
completion_policy: AgentCompletionPolicy::parse(row.get::<&str, _>("completion_policy"))?,
expected_runs: row.get("expected_runs"),
terminal_runs: row.get("terminal_runs"),
abnormal_runs: row.get("abnormal_runs"),
completion_slot_reserved: row.get::<i64, _>("completion_slot_reserved") != 0,
completion_delivery: row.get("completion_delivery"),
failure_delivery: row.get("failure_delivery"),
deadline_at: row.get("deadline_at"),
status: AgentGroupStatus::parse(row.get::<&str, _>("status"))?,
runtime_generation: row.get("runtime_generation"),
revision: row.get("revision"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
finished_at: row.get("finished_at"),
})
}
impl super::Storage { impl super::Storage {
/// Admit a group (optional) and its runs in one transaction, claiming any /// Admit a batch of runs in one transaction, claiming any referenced
/// referenced plan items atomically. If any plan item was already taken /// plan items atomically. If any plan item was already taken the whole
/// the whole admission rolls back so a run can never diverge from the /// admission rolls back so a run can never diverge from the plan it
/// plan it claims to execute. /// claims to execute.
pub async fn accept_agent_runs( pub async fn accept_agent_runs(
&self, &self,
request: AcceptAgentRequest, request: AcceptAgentRequest,
@ -601,39 +393,9 @@ impl super::Storage {
} }
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
if let Some(group) = request.group.as_ref() {
let inserted = sqlx::query(
"INSERT INTO agent_run_groups (id, root_session_id, caller_run_id, \
caller_scope_id, idempotency_key, mode, completion_policy, \
expected_runs, terminal_runs, abnormal_runs, completion_slot_reserved, \
deadline_at, status, runtime_generation, revision, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, 'queued', ?, 0, ?, ?)",
)
.bind(&group.id)
.bind(&group.root_session_id)
.bind(&group.caller_run_id)
.bind(&group.caller_scope_id)
.bind(&group.idempotency_key)
.bind(group.mode.as_str())
.bind(group.completion_policy.as_str())
.bind(request.runs.len() as i64)
.bind(group.deadline_at)
.bind(group.runtime_generation)
.bind(request.now)
.bind(request.now)
.execute(&mut *tx)
.await?
.rows_affected()
== 1;
if !inserted {
drop(tx);
return self.existing_agent_admission(request).await;
}
}
for run in &request.runs { for run in &request.runs {
let inserted = sqlx::query( let inserted = sqlx::query(
"INSERT INTO agent_runs (id, group_id, root_session_id, root_turn_id, \ "INSERT INTO agent_runs (id, root_session_id, root_turn_id, \
parent_run_id, caller_agent_id, caller_scope_id, idempotency_key, \ parent_run_id, caller_agent_id, caller_scope_id, idempotency_key, \
agent_id, definition_hash, provider_profile, provider_name, model_id, \ agent_id, definition_hash, provider_profile, provider_name, model_id, \
mode, depth, plan_item_id, execution_id, task, context_json, budget_json, \ mode, depth, plan_item_id, execution_id, task, context_json, budget_json, \
@ -641,10 +403,9 @@ impl super::Storage {
status, runtime_generation, attempt, completion_slot_reserved, deadline_at, \ status, runtime_generation, attempt, completion_slot_reserved, deadline_at, \
revision, created_at, updated_at) \ revision, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
?, ?, 'queued', ?, 1, ?, ?, 0, ?, ?)", ?, 'queued', ?, 1, ?, ?, 0, ?, ?)",
) )
.bind(&run.id) .bind(&run.id)
.bind(request.group.as_ref().map(|group| group.id.clone()))
.bind(&run.root_session_id) .bind(&run.root_session_id)
.bind(&run.root_turn_id) .bind(&run.root_turn_id)
.bind(&run.parent_run_id) .bind(&run.parent_run_id)
@ -699,13 +460,7 @@ impl super::Storage {
StorageError::NotFound(format!("agent run {} vanished after admission", run.id)) StorageError::NotFound(format!("agent run {} vanished after admission", run.id))
})?); })?);
} }
let group = match request.group.as_ref() { Ok(AcceptedAgentRuns::Accepted { runs })
Some(group) => Some(self.get_agent_run_group(&group.id).await?.ok_or_else(|| {
StorageError::NotFound(format!("agent group {} vanished after admission", group.id))
})?),
None => None,
};
Ok(AcceptedAgentRuns::Accepted { group, runs })
} }
async fn existing_agent_admission( async fn existing_agent_admission(
@ -718,16 +473,12 @@ impl super::Storage {
runs.push(record); runs.push(record);
} }
} }
let group = match request.group.as_ref() { if runs.is_empty() {
Some(group) => self.get_agent_run_group(&group.id).await?,
None => None,
};
if runs.is_empty() && group.is_none() {
return Err(StorageError::Conflict( return Err(StorageError::Conflict(
"agent admission conflicted but no existing rows were found".to_string(), "agent admission conflicted but no existing rows were found".to_string(),
)); ));
} }
Ok(AcceptedAgentRuns::Existing { group, runs }) Ok(AcceptedAgentRuns::Existing { runs })
} }
pub async fn get_agent_run( pub async fn get_agent_run(
@ -746,22 +497,6 @@ impl super::Storage {
} }
} }
pub async fn get_agent_run_group(
&self,
group_id: &str,
) -> Result<Option<AgentRunGroupRecord>, StorageError> {
let row = sqlx::query(sqlx::AssertSqlSafe(format!(
"SELECT {GROUP_COLUMNS} FROM agent_run_groups WHERE id = ?"
)))
.bind(group_id)
.fetch_optional(&self.pool)
.await?;
match row {
Some(row) => Ok(Some(group_record_from_row(&row)?)),
None => Ok(None),
}
}
/// 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(
@ -834,18 +569,6 @@ impl super::Storage {
rows.iter().map(run_record_from_row).collect() rows.iter().map(run_record_from_row).collect()
} }
pub async fn list_agent_group_runs(
&self,
group_id: &str,
) -> Result<Vec<AgentRunRecord>, StorageError> {
let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
"SELECT {RUN_COLUMNS} FROM agent_runs WHERE group_id = ? ORDER BY created_at ASC, id ASC"
)))
.bind(group_id)
.fetch_all(&self.pool)
.await?;
rows.iter().map(run_record_from_row).collect()
}
/// Conditional `queued -> running` transition owned by this execution. /// Conditional `queued -> running` transition owned by this execution.
pub async fn mark_agent_run_running( pub async fn mark_agent_run_running(
@ -976,8 +699,9 @@ impl super::Storage {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
if reserved { if reserved {
let session: String = let (session, agent_id, task): (String, String, String) = sqlx::query_as(
sqlx::query_scalar("SELECT root_session_id FROM agent_runs WHERE id = ?") "SELECT root_session_id, agent_id, task FROM agent_runs WHERE id = ?",
)
.bind(run_id) .bind(run_id)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await?; .await?;
@ -994,33 +718,34 @@ impl super::Storage {
let event = super::agent_inbox::NewInboxEvent { let event = super::agent_inbox::NewInboxEvent {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
root_session_id: session, root_session_id: session,
scope_kind: "run".to_string(),
scope_id: run_id.to_string(),
run_id: Some(run_id.to_string()), run_id: Some(run_id.to_string()),
group_id: None,
event_type: super::agent_inbox::AgentEventType::Completion, event_type: super::agent_inbox::AgentEventType::Completion,
event_key: format!("completion:{run_id}"), event_key: format!("completion:{run_id}"),
delivery: super::agent_inbox::AgentEventDelivery::Queue, delivery: super::agent_inbox::AgentEventDelivery::Queue,
requires_continuation: !suppress_continuation, requires_continuation: !suppress_continuation,
severity: Some("warning".to_string()), severity: Some("warning".to_string()),
payload_json: serde_json::json!({ "status": "cancelled", "error": reason }) payload_json: super::agent_inbox::completion_payload(
.to_string(), run_id,
&agent_id,
&task,
None,
"cancelled",
Some(reason),
&[],
),
}; };
if suppress_continuation { if suppress_continuation {
// Directly consumed: pending count never grows. // Directly consumed: pending count never grows.
sqlx::query( sqlx::query(
"INSERT INTO agent_inbox_events (id, root_session_id, scope_kind, scope_id, \ "INSERT INTO agent_inbox_events (id, root_session_id, run_id, event_type, \
run_id, group_id, event_type, event_key, delivery, requires_continuation, \ event_key, delivery, requires_continuation, severity, payload_json, \
severity, payload_json, status, attempt_count, revision, next_attempt_at, \ status, attempt_count, revision, next_attempt_at, created_at, \
created_at, updated_at, consumed_at) \ updated_at, consumed_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'consumed', 0, ?, NULL, ?, ?, ?)", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'consumed', 0, ?, NULL, ?, ?, ?)",
) )
.bind(&event.id) .bind(&event.id)
.bind(&event.root_session_id) .bind(&event.root_session_id)
.bind(&event.scope_kind)
.bind(&event.scope_id)
.bind(&event.run_id) .bind(&event.run_id)
.bind(&event.group_id)
.bind(event.event_type.as_str()) .bind(event.event_type.as_str())
.bind(&event.event_key) .bind(&event.event_key)
.bind(event.delivery.as_str()) .bind(event.delivery.as_str())
@ -1135,50 +860,6 @@ impl super::Storage {
.await?; .await?;
let run = run_record_from_row(&run_row)?; let run = run_record_from_row(&run_row)?;
let mut group = None;
let mut group_finished = false;
if let Some(group_id) = run.group_id.as_deref() {
sqlx::query(
"UPDATE agent_run_groups SET terminal_runs = terminal_runs + 1, \
abnormal_runs = abnormal_runs + ?, updated_at = ? WHERE id = ?",
)
.bind(i64::from(outcome.is_abnormal()))
.bind(now)
.bind(group_id)
.execute(&mut *tx)
.await?;
let group_row = sqlx::query(sqlx::AssertSqlSafe(format!(
"SELECT {GROUP_COLUMNS} FROM agent_run_groups WHERE id = ?"
)))
.bind(group_id)
.fetch_one(&mut *tx)
.await?;
let mut record = group_record_from_row(&group_row)?;
if record.terminal_runs >= record.expected_runs && !record.status.is_terminal() {
let final_status = if record.abnormal_runs == 0 {
AgentGroupStatus::Completed
} else if record.abnormal_runs < record.expected_runs {
AgentGroupStatus::Partial
} else {
AgentGroupStatus::Failed
};
sqlx::query(
"UPDATE agent_run_groups SET status = ?, finished_at = ?, updated_at = ? \
WHERE id = ? AND status IN ('queued', 'running')",
)
.bind(final_status.as_str())
.bind(now)
.bind(now)
.bind(group_id)
.execute(&mut *tx)
.await?;
record.status = final_status;
record.finished_at = Some(now);
group_finished = true;
}
group = Some(record);
}
if let Some(item_id) = run.plan_item_id.as_deref() { if let Some(item_id) = run.plan_item_id.as_deref() {
finish_plan_item( finish_plan_item(
&mut tx, &mut tx,
@ -1196,29 +877,44 @@ impl super::Storage {
// reservation into a durable completion event in the same commit. // reservation into a durable completion event in the same commit.
// The event survives restarts, queue-full conditions and lost wakes. // The event survives restarts, queue-full conditions and lost wakes.
if run.completion_slot_reserved { if run.completion_slot_reserved {
let (status, error, signal_ids) = match outcome { let (status, error, signal_ids, result) = match outcome {
AgentTerminalOutcome::Completed { signal_ids, .. } => { AgentTerminalOutcome::Completed {
("completed", None, signal_ids.as_slice()) result, signal_ids, ..
} } => (
"completed",
None,
signal_ids.as_slice(),
Some(result.as_str()),
),
AgentTerminalOutcome::Failed { AgentTerminalOutcome::Failed {
error, signal_ids, .. error, signal_ids, ..
} => ("failed", Some(error.as_str()), signal_ids.as_slice()), } => ("failed", Some(error.as_str()), signal_ids.as_slice(), None),
AgentTerminalOutcome::TimedOut { signal_ids, .. } => ( AgentTerminalOutcome::TimedOut { signal_ids, .. } => (
"timed_out", "timed_out",
Some("deadline exceeded"), Some("deadline exceeded"),
signal_ids.as_slice(), signal_ids.as_slice(),
None,
),
AgentTerminalOutcome::Cancelled { reason, signal_ids } => (
"cancelled",
Some(reason.as_str()),
signal_ids.as_slice(),
None,
),
AgentTerminalOutcome::Interrupted { reason, signal_ids } => (
"interrupted",
Some(reason.as_str()),
signal_ids.as_slice(),
None,
), ),
AgentTerminalOutcome::Cancelled { reason, signal_ids } => {
("cancelled", Some(reason.as_str()), signal_ids.as_slice())
}
AgentTerminalOutcome::Interrupted { reason, signal_ids } => {
("interrupted", Some(reason.as_str()), signal_ids.as_slice())
}
}; };
super::agent_inbox::insert_completion_event_tx( super::agent_inbox::insert_completion_event_tx(
&mut tx, &mut tx,
&run.id, &run.id,
&run.root_session_id, &run.root_session_id,
&run.agent_id,
&run.task,
result,
status, status,
error, error,
signal_ids, signal_ids,
@ -1228,11 +924,7 @@ impl super::Storage {
} }
tx.commit().await?; tx.commit().await?;
Ok(Some(TerminalCommit { Ok(Some(TerminalCommit { run }))
run,
group,
group_finished,
}))
} }
} }
@ -1369,15 +1061,14 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn fresh_database_creates_schema_v6_agent_tables() { async fn fresh_database_creates_schema_v8_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, 6); assert_eq!(version, 8);
for table in [ for table in [
"agent_run_groups",
"agent_runs", "agent_runs",
"agent_session_state", "agent_session_state",
"agent_inbox_events", "agent_inbox_events",
@ -1398,7 +1089,6 @@ mod tests {
let (storage, _dir) = create_test_storage().await; let (storage, _dir) = create_test_storage().await;
let accepted = storage let accepted = storage
.accept_agent_runs(AcceptAgentRequest { .accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")], runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
now: 100, now: 100,
}) })
@ -1408,98 +1098,14 @@ mod tests {
let run = storage.get_agent_run("run-1").await.unwrap().unwrap(); let run = storage.get_agent_run("run-1").await.unwrap().unwrap();
assert_eq!(run.status, AgentRunStatus::Queued); assert_eq!(run.status, AgentRunStatus::Queued);
assert!(run.group_id.is_none());
assert_eq!(run.execution_id, "exec-1"); assert_eq!(run.execution_id, "exec-1");
} }
#[tokio::test]
async fn batch_admission_tracks_group_terminal_counters() {
let (storage, _dir) = create_test_storage().await;
let group = NewAgentGroup {
id: "group-1".to_string(),
root_session_id: "cli:test:d1".to_string(),
caller_run_id: None,
caller_scope_id: "turn-1".to_string(),
idempotency_key: Some("batch-key".to_string()),
mode: AgentRunMode::Foreground,
completion_policy: AgentCompletionPolicy::All,
deadline_at: 2_000,
runtime_generation: 1,
};
storage
.accept_agent_runs(AcceptAgentRequest {
group: Some(group),
runs: vec![
new_run("run-a", "exec-a", "cli:test:d1"),
new_run("run-b", "exec-b", "cli:test:d1"),
],
now: 100,
})
.await
.unwrap();
assert!(
storage
.mark_agent_run_running("run-a", "exec-a", 110)
.await
.unwrap()
);
let first = storage
.commit_agent_terminal(
"run-a",
"exec-a",
1,
&AgentTerminalOutcome::Completed {
result: "done".to_string(),
prompt_tokens: Some(2),
completion_tokens: Some(3),
cost: None,
tool_calls: 1,
iterations: 2,
signal_ids: Vec::new(),
},
None,
120,
)
.await
.unwrap()
.unwrap();
assert!(!first.group_finished);
assert_eq!(first.group.as_ref().unwrap().terminal_runs, 1);
let second = storage
.commit_agent_terminal(
"run-b",
"exec-b",
1,
&AgentTerminalOutcome::Failed {
error: "boom".to_string(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: Vec::new(),
},
None,
130,
)
.await
.unwrap()
.unwrap();
assert!(second.group_finished);
let group = second.group.unwrap();
assert_eq!(group.status, AgentGroupStatus::Partial);
assert_eq!(group.finished_at, Some(130));
let runs = storage.list_agent_group_runs("group-1").await.unwrap();
assert_eq!(runs.len(), 2);
}
#[tokio::test] #[tokio::test]
async fn stale_execution_cannot_commit_terminal() { async fn stale_execution_cannot_commit_terminal() {
let (storage, _dir) = create_test_storage().await; let (storage, _dir) = create_test_storage().await;
storage storage
.accept_agent_runs(AcceptAgentRequest { .accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")], runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
now: 100, now: 100,
}) })
@ -1537,7 +1143,6 @@ mod tests {
let (storage, _dir) = create_test_storage().await; let (storage, _dir) = create_test_storage().await;
storage storage
.accept_agent_runs(AcceptAgentRequest { .accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")], runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
now: 100, now: 100,
}) })
@ -1593,7 +1198,6 @@ mod tests {
run.plan_item_id = Some("T1".to_string()); run.plan_item_id = Some("T1".to_string());
storage storage
.accept_agent_runs(AcceptAgentRequest { .accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![run.clone()], runs: vec![run.clone()],
now: 100, now: 100,
}) })
@ -1610,7 +1214,6 @@ mod tests {
run.execution_id = "exec-2".to_string(); run.execution_id = "exec-2".to_string();
let error = storage let error = storage
.accept_agent_runs(AcceptAgentRequest { .accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![run], runs: vec![run],
now: 110, now: 110,
}) })
@ -1661,7 +1264,6 @@ mod tests {
} }
storage storage
.accept_agent_runs(AcceptAgentRequest { .accept_agent_runs(AcceptAgentRequest {
group: None,
runs, runs,
now: 100, now: 100,
}) })
@ -1692,7 +1294,6 @@ mod tests {
let (storage, _dir) = create_test_storage().await; let (storage, _dir) = create_test_storage().await;
storage storage
.accept_agent_runs(AcceptAgentRequest { .accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")], runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
now: 100, now: 100,
}) })
@ -1723,7 +1324,6 @@ mod tests {
run.completion_slot_reserved = true; run.completion_slot_reserved = true;
storage storage
.accept_agent_runs(AcceptAgentRequest { .accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![run], runs: vec![run],
now: 100, now: 100,
}) })

View File

@ -1,19 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackgroundTask {
pub id: String,
pub session_id: String,
pub channel: String,
pub chat_id: String,
pub prompt: String,
pub allowed_tools: Option<String>,
pub status: String,
pub result: Option<String>,
pub error: Option<String>,
pub tool_calls_count: i64,
pub iterations: i64,
pub started_at: Option<i64>,
pub finished_at: Option<i64>,
pub created_at: i64,
}

View File

@ -1,6 +1,5 @@
pub mod agent_inbox; pub mod agent_inbox;
pub mod agent_run; pub mod agent_run;
pub mod background_task;
pub mod error; pub mod error;
pub mod memory; pub mod memory;
pub mod message; pub mod message;
@ -8,7 +7,6 @@ pub mod scheduler;
pub mod session; pub mod session;
pub mod usage; pub mod usage;
pub use background_task::BackgroundTask;
pub use error::StorageError; pub use error::StorageError;
pub use scheduler::{DeliveryPolicy, JobKind, JobRun, ScheduledJob}; pub use scheduler::{DeliveryPolicy, JobKind, JobRun, ScheduledJob};
pub use usage::{SessionUsageTotals, TurnUsageRecord}; pub use usage::{SessionUsageTotals, TurnUsageRecord};
@ -20,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 = 6; const SCHEMA_VERSION: i64 = 8;
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,
@ -192,48 +190,6 @@ impl Storage {
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
// Background tasks table — for async sub-agent tasks.
// Note: No FOREIGN KEY on session_id because sessions use soft delete (deleted_at IS NULL).
// Session and task association is maintained at the application level.
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS background_tasks (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
channel TEXT NOT NULL,
chat_id TEXT NOT NULL,
prompt TEXT NOT NULL,
allowed_tools TEXT,
status TEXT NOT NULL DEFAULT 'pending',
result TEXT,
error TEXT,
tool_calls_count INTEGER DEFAULT 0,
iterations INTEGER DEFAULT 0,
started_at INTEGER,
finished_at INTEGER,
created_at INTEGER NOT NULL
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_bg_tasks_session ON background_tasks(session_id)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_bg_tasks_status ON background_tasks(status)
"#,
)
.execute(&self.pool)
.await?;
// Session-scoped task plans. A session may have at most one active plan, // Session-scoped task plans. A session may have at most one active plan,
// while independent items can be executed concurrently. // while independent items can be executed concurrently.
sqlx::query( sqlx::query(
@ -439,6 +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
// keep dead rows around.
sqlx::query("DROP TABLE IF EXISTS background_tasks")
.execute(&mut *tx)
.await?;
// Schema v8 removes the batch "group" concept entirely: the
// `agent_run_groups` table is gone, and the run/inbox tables are
// rebuilt without their `group_id`/`scope_kind`/`scope_id` columns.
// Drop in dependency order (inbox -> runs -> groups) so foreign-key
// enforcement never blocks the implicit row delete.
sqlx::query("DROP TABLE IF EXISTS agent_inbox_events")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_runs")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_run_groups")
.execute(&mut *tx)
.await?;
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"),
@ -1459,149 +1434,6 @@ impl Storage {
unreachable!() unreachable!()
} }
// ── Background Task CRUD ──
pub async fn create_background_task(
&self,
task: &crate::storage::background_task::BackgroundTask,
) -> Result<(), StorageError> {
sqlx::query(
r#"
INSERT INTO background_tasks (id, session_id, channel, chat_id, prompt, allowed_tools, status, result, error, tool_calls_count, iterations, started_at, finished_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&task.id)
.bind(&task.session_id)
.bind(&task.channel)
.bind(&task.chat_id)
.bind(&task.prompt)
.bind(&task.allowed_tools)
.bind(&task.status)
.bind(&task.result)
.bind(&task.error)
.bind(task.tool_calls_count)
.bind(task.iterations)
.bind(task.started_at)
.bind(task.finished_at)
.bind(task.created_at)
.execute(self.pool())
.await?;
Ok(())
}
pub async fn get_background_task(
&self,
id: &str,
) -> Result<crate::storage::background_task::BackgroundTask, StorageError> {
let row = sqlx::query(
r#"
SELECT id, session_id, channel, chat_id, prompt, allowed_tools, status, result, error,
tool_calls_count, iterations, started_at, finished_at, created_at
FROM background_tasks WHERE id = ?
"#,
)
.bind(id)
.fetch_optional(self.pool())
.await?
.ok_or_else(|| StorageError::NotFound(id.to_string()))?;
Ok(crate::storage::background_task::BackgroundTask {
id: row.get("id"),
session_id: row.get("session_id"),
channel: row.get("channel"),
chat_id: row.get("chat_id"),
prompt: row.get("prompt"),
allowed_tools: row.get("allowed_tools"),
status: row.get("status"),
result: row.get("result"),
error: row.get("error"),
tool_calls_count: row.get("tool_calls_count"),
iterations: row.get("iterations"),
started_at: row.get("started_at"),
finished_at: row.get("finished_at"),
created_at: row.get("created_at"),
})
}
pub async fn list_background_tasks(
&self,
session_id: &str,
) -> Result<Vec<crate::storage::background_task::BackgroundTask>, StorageError> {
let rows = sqlx::query(
r#"
SELECT id, session_id, channel, chat_id, prompt, allowed_tools, status, result, error,
tool_calls_count, iterations, started_at, finished_at, created_at
FROM background_tasks
WHERE session_id = ?
ORDER BY created_at DESC
"#,
)
.bind(session_id)
.fetch_all(self.pool())
.await?;
Ok(rows
.into_iter()
.map(|row| crate::storage::background_task::BackgroundTask {
id: row.get("id"),
session_id: row.get("session_id"),
channel: row.get("channel"),
chat_id: row.get("chat_id"),
prompt: row.get("prompt"),
allowed_tools: row.get("allowed_tools"),
status: row.get("status"),
result: row.get("result"),
error: row.get("error"),
tool_calls_count: row.get("tool_calls_count"),
iterations: row.get("iterations"),
started_at: row.get("started_at"),
finished_at: row.get("finished_at"),
created_at: row.get("created_at"),
})
.collect())
}
/// List recent background tasks across sessions for the management UI.
pub async fn list_recent_background_tasks(
&self,
limit: usize,
) -> Result<Vec<crate::storage::background_task::BackgroundTask>, StorageError> {
let rows = sqlx::query(
r#"
SELECT id, session_id, channel, chat_id, prompt, allowed_tools, status, result, error,
tool_calls_count, iterations, started_at, finished_at, created_at
FROM background_tasks
ORDER BY created_at DESC
LIMIT ?
"#,
)
.bind(limit as i64)
.fetch_all(self.pool())
.await?;
Ok(rows
.into_iter()
.map(|row| crate::storage::background_task::BackgroundTask {
id: row.get("id"),
session_id: row.get("session_id"),
channel: row.get("channel"),
chat_id: row.get("chat_id"),
prompt: row.get("prompt"),
allowed_tools: row.get("allowed_tools"),
status: row.get("status"),
result: row.get("result"),
error: row.get("error"),
tool_calls_count: row.get("tool_calls_count"),
iterations: row.get("iterations"),
started_at: row.get("started_at"),
finished_at: row.get("finished_at"),
created_at: row.get("created_at"),
})
.collect())
}
/// Persist the channel's durable delivery context for a session. Only /// Persist the channel's durable delivery context for a session. Only
/// channel-declared reusable values (thread/root identity) ever reach /// channel-declared reusable values (thread/root identity) ever reach
/// this column; one-shot reply/reaction ids never do. /// this column; one-shot reply/reaction ids never do.
@ -1637,17 +1469,6 @@ impl Storage {
.await?; .await?;
Ok(context) Ok(context)
} }
pub async fn cleanup_old_tasks(&self, ttl_ms: i64) -> Result<usize, StorageError> {
let cutoff = chrono::Utc::now().timestamp_millis() - ttl_ms;
let result = sqlx::query(
"DELETE FROM background_tasks WHERE status IN ('completed', 'failed', 'cancelled') AND finished_at IS NOT NULL AND finished_at < ?",
)
.bind(cutoff)
.execute(self.pool())
.await?;
Ok(result.rows_affected() as usize)
}
} }
#[cfg(test)] #[cfg(test)]
@ -1796,42 +1617,6 @@ mod tests {
assert_eq!(sentinel_count, 1); assert_eq!(sentinel_count, 1);
} }
#[tokio::test]
async fn webui_lists_recent_tasks_across_sessions() {
let (storage, _dir) = create_test_storage().await;
for (id, session_id, created_at) in [("old", "cli:a:d1", 1), ("new", "cli:b:d2", 2)] {
storage
.create_background_task(&crate::storage::BackgroundTask {
id: id.into(),
session_id: session_id.into(),
channel: "cli".into(),
chat_id: "chat".into(),
prompt: id.into(),
allowed_tools: None,
status: "pending".into(),
result: None,
error: None,
tool_calls_count: 0,
iterations: 0,
started_at: None,
finished_at: None,
created_at,
})
.await
.unwrap();
}
let tasks = storage.list_recent_background_tasks(10).await.unwrap();
assert_eq!(
tasks
.iter()
.map(|task| task.id.as_str())
.collect::<Vec<_>>(),
vec!["new", "old"]
);
assert_eq!(tasks[0].session_id, "cli:b:d2");
}
#[tokio::test] #[tokio::test]
async fn webui_lists_and_filters_memories_without_search_text() { async fn webui_lists_and_filters_memories_without_search_text() {
let (storage, _dir) = create_test_storage().await; let (storage, _dir) = create_test_storage().await;
@ -1997,7 +1782,6 @@ mod tests {
"task_plans", "task_plans",
"task_items", "task_items",
"session_turn_usage", "session_turn_usage",
"agent_run_groups",
"agent_runs", "agent_runs",
"agent_session_state", "agent_session_state",
"agent_inbox_events", "agent_inbox_events",

View File

@ -5,7 +5,7 @@ use serde_json::{Value, json};
use crate::agent::AgentCoordinator; use crate::agent::AgentCoordinator;
use crate::storage::agent_run::AgentRunRecord; use crate::storage::agent_run::AgentRunRecord;
use crate::tools::traits::{DelegationPolicy, Tool, ToolExecutionContext, ToolOutput, ToolResult}; use crate::tools::traits::{Tool, ToolExecutionContext, ToolOutput, ToolResult};
const RESULT_PREVIEW_CHARS: usize = 2_000; const RESULT_PREVIEW_CHARS: usize = 2_000;
@ -32,8 +32,8 @@ impl Tool for AgentTaskTool {
"Inspect or control delegated Agent runs: get reads one run, list shows the session's runs, get_result returns the full terminal result, cancel stops a non-terminal run." "Inspect or control delegated Agent runs: get reads one run, list shows the session's runs, get_result returns the full terminal result, cancel stops a non-terminal run."
} }
fn delegation_policy(&self) -> DelegationPolicy { fn runtime_injected(&self) -> bool {
DelegationPolicy::RuntimeInjected true
} }
fn parameters_schema(&self) -> Value { fn parameters_schema(&self) -> Value {
@ -199,7 +199,6 @@ impl AgentTaskTool {
fn run_projection(run: &AgentRunRecord, include_result_preview: bool) -> Value { fn run_projection(run: &AgentRunRecord, include_result_preview: bool) -> Value {
let mut value = json!({ let mut value = json!({
"run_id": run.id, "run_id": run.id,
"group_id": run.group_id,
"agent_id": run.agent_id, "agent_id": run.agent_id,
"status": run.status.as_str(), "status": run.status.as_str(),
"mode": run.mode.as_str(), "mode": run.mode.as_str(),

View File

@ -86,10 +86,6 @@ impl Tool for BashTool {
"bash" "bash"
} }
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
crate::tools::DelegationPolicy::Delegatable
}
fn description(&self) -> &str { fn description(&self) -> &str {
"Execute a bash shell command and return its output. Use with caution." "Execute a bash shell command and return its output. Use with caution."
} }

View File

@ -51,10 +51,6 @@ impl BrowserTool {
#[async_trait] #[async_trait]
impl Tool for BrowserTool { impl Tool for BrowserTool {
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
crate::tools::DelegationPolicy::Delegatable
}
fn name(&self) -> &str { fn name(&self) -> &str {
"browser" "browser"
} }

View File

@ -18,10 +18,6 @@ impl Default for CalculatorTool {
#[async_trait] #[async_trait]
impl Tool for CalculatorTool { impl Tool for CalculatorTool {
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
crate::tools::DelegationPolicy::Delegatable
}
fn name(&self) -> &str { fn name(&self) -> &str {
"calculator" "calculator"
} }

View File

@ -51,10 +51,6 @@ impl Default for ContentSearchTool {
#[async_trait] #[async_trait]
impl Tool for ContentSearchTool { impl Tool for ContentSearchTool {
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
crate::tools::DelegationPolicy::Delegatable
}
fn name(&self) -> &str { fn name(&self) -> &str {
"content_search" "content_search"
} }

View File

@ -47,8 +47,8 @@ impl Tool for ScopedDelegateTool {
self.inner.read_only() self.inner.read_only()
} }
fn delegation_policy(&self) -> crate::tools::DelegationPolicy { fn runtime_injected(&self) -> bool {
crate::tools::DelegationPolicy::RuntimeInjected true
} }
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> { async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
@ -155,7 +155,11 @@ impl DelegateTool {
args: &Value, args: &Value,
context: &ToolExecutionContext, context: &ToolExecutionContext,
) -> anyhow::Result<ToolResult> { ) -> anyhow::Result<ToolResult> {
let mode = match args.get("mode").and_then(Value::as_str).unwrap_or("foreground") { let mode = match args
.get("mode")
.and_then(Value::as_str)
.unwrap_or("foreground")
{
"foreground" => ExecutionMode::Foreground, "foreground" => ExecutionMode::Foreground,
"background" => ExecutionMode::Background, "background" => ExecutionMode::Background,
other => { other => {
@ -250,11 +254,6 @@ impl DelegateTool {
}) })
} }
ExecutionMode::Background => { ExecutionMode::Background => {
if configs.len() != 1 {
return Ok(failure(
"background batches require durable group admission and are not available yet",
));
}
if context.agent.is_some() { if context.agent.is_some() {
return Ok(failure( return Ok(failure(
"child Agents cannot create background runs in the current implementation", "child Agents cannot create background runs in the current implementation",
@ -265,18 +264,23 @@ impl DelegateTool {
"delegate requires agent_orchestration to be enabled (named Agents only)", "delegate requires agent_orchestration to be enabled (named Agents only)",
)); ));
}; };
let config = configs.into_iter().next().expect("checked non-empty"); match coordinator.delegate_background(context, configs).await {
match coordinator.delegate_background(context, config).await { Ok(admission) => {
Ok(run_id) => Ok(success(json!({ let runs: Vec<_> = admission
.run_ids
.into_iter()
.map(|run_id| json!({ "run_id": run_id, "status": "queued" }))
.collect();
Ok(success(json!({
"status": "accepted", "status": "accepted",
"runs": vec![json!({ "run_id": run_id, "status": "queued" })] "runs": runs
}))), })))
}
Err(error) => Ok(failure(error.to_string())), Err(error) => Ok(failure(error.to_string())),
} }
} }
} }
} }
} }
#[async_trait] #[async_trait]
@ -327,8 +331,8 @@ impl Tool for DelegateTool {
false false
} }
fn delegation_policy(&self) -> crate::tools::DelegationPolicy { fn runtime_injected(&self) -> bool {
crate::tools::DelegationPolicy::RuntimeInjected true
} }
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> { async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {

View File

@ -18,7 +18,7 @@ use crate::agent::coordinator::AgentCoordinator;
use crate::agent::definition::SignalContract; use crate::agent::definition::SignalContract;
use crate::agent::run::{AgentExecutionContext, EmittedSignal}; use crate::agent::run::{AgentExecutionContext, EmittedSignal};
use crate::storage::agent_inbox::{AgentEventDelivery, AgentEventType, NewInboxEvent}; use crate::storage::agent_inbox::{AgentEventDelivery, AgentEventType, NewInboxEvent};
use crate::tools::{DelegationPolicy, Tool, ToolExecutionContext, ToolOutput, ToolResult}; use crate::tools::{Tool, ToolExecutionContext, ToolOutput, ToolResult};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SignalInput { pub struct SignalInput {
@ -117,8 +117,8 @@ impl Tool for EmitSignalTool {
}) })
} }
fn delegation_policy(&self) -> DelegationPolicy { fn runtime_injected(&self) -> bool {
DelegationPolicy::RuntimeInjected true
} }
async fn execute_with_context( async fn execute_with_context(
@ -302,10 +302,7 @@ pub fn build_signal_event(
NewInboxEvent { NewInboxEvent {
id: event_id, id: event_id,
root_session_id: context.root_session_id.clone(), root_session_id: context.root_session_id.clone(),
scope_kind: "run".to_string(),
scope_id: context.run_id.clone(),
run_id: Some(context.run_id.clone()), run_id: Some(context.run_id.clone()),
group_id: context.group_id.clone(),
event_type: AgentEventType::Signal, event_type: AgentEventType::Signal,
event_key: input.event_key.clone(), event_key: input.event_key.clone(),
delivery, delivery,

View File

@ -36,10 +36,6 @@ impl Default for FileReadTool {
#[async_trait] #[async_trait]
impl Tool for FileReadTool { impl Tool for FileReadTool {
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
crate::tools::DelegationPolicy::Delegatable
}
fn name(&self) -> &str { fn name(&self) -> &str {
"file_read" "file_read"
} }

View File

@ -51,10 +51,6 @@ impl Default for FileSearchTool {
#[async_trait] #[async_trait]
impl Tool for FileSearchTool { impl Tool for FileSearchTool {
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
crate::tools::DelegationPolicy::Delegatable
}
fn name(&self) -> &str { fn name(&self) -> &str {
"file_search" "file_search"
} }

View File

@ -44,8 +44,8 @@ impl GetSkillTool {
#[async_trait] #[async_trait]
impl Tool for GetSkillTool { impl Tool for GetSkillTool {
fn delegation_policy(&self) -> crate::tools::DelegationPolicy { fn runtime_injected(&self) -> bool {
crate::tools::DelegationPolicy::RuntimeInjected true
} }
fn name(&self) -> &str { fn name(&self) -> &str {

View File

@ -52,9 +52,9 @@ pub use send_message::SendMessageTool;
pub use sleep::SleepTool; pub use sleep::SleepTool;
pub use todo::TodoTool; pub use todo::TodoTool;
pub use traits::{ pub use traits::{
DelegationPolicy, InputInterruptPolicy, OutboundDelivery, OutboundMessenger, InputInterruptPolicy, OutboundDelivery, OutboundMessenger, ProcessedToolOutput, Tool,
ProcessedToolOutput, Tool, ToolArtifact, ToolArtifactAudience, ToolExecutionContext, ToolArtifact, ToolArtifactAudience, ToolExecutionContext, ToolOutput, ToolOutputProcessor,
ToolOutput, ToolOutputProcessor, ToolResult, ToolResult,
}; };
pub use web_fetch::WebFetchTool; pub use web_fetch::WebFetchTool;

View File

@ -3,7 +3,6 @@ use std::sync::{Arc, Mutex};
use crate::providers::{Tool, ToolFunction}; use crate::providers::{Tool, ToolFunction};
use super::traits::DelegationPolicy;
use super::traits::Tool as ToolTrait; use super::traits::Tool as ToolTrait;
pub struct ToolRegistry { pub struct ToolRegistry {
@ -101,15 +100,12 @@ impl ToolRegistry {
let tool = self let tool = self
.get(name) .get(name)
.ok_or_else(|| format!("tool '{name}' is not registered"))?; .ok_or_else(|| format!("tool '{name}' is not registered"))?;
if tool.delegation_policy() != DelegationPolicy::Delegatable {
return Err(format!("tool '{name}' is not delegatable"));
}
scoped.register_raw(name.clone(), tool); scoped.register_raw(name.clone(), tool);
} }
for tool in runtime_tools { for tool in runtime_tools {
if tool.delegation_policy() != DelegationPolicy::RuntimeInjected { if !tool.runtime_injected() {
return Err(format!( return Err(format!(
"runtime tool '{}' is missing RuntimeInjected policy", "runtime tool '{}' is missing the runtime-injected marker",
tool.name() tool.name()
)); ));
} }

View File

@ -135,7 +135,6 @@ target_chat_id 支持两种格式:<channel>:<chat_id>(发送到该聊天下
task_id: None, task_id: None,
from_run_id: None, from_run_id: None,
from_agent_id: None, from_agent_id: None,
group_id: None,
}; };
// 3. Parse files into MediaItems // 3. Parse files into MediaItems

View File

@ -36,10 +36,6 @@ fn parse_seconds(args: &serde_json::Value) -> Result<u64, String> {
#[async_trait] #[async_trait]
impl Tool for SleepTool { impl Tool for SleepTool {
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
crate::tools::DelegationPolicy::Delegatable
}
fn input_interrupt_policy(&self) -> crate::tools::InputInterruptPolicy { fn input_interrupt_policy(&self) -> crate::tools::InputInterruptPolicy {
crate::tools::InputInterruptPolicy::WakeOnly crate::tools::InputInterruptPolicy::WakeOnly
} }
@ -196,11 +192,6 @@ fn wake_message(state: &TurnWakeupState, waited: std::time::Duration, planned: u
" 收到一条 steer AgentCompletionrun_id={run_id}, agent={agent_id}),将在当前 Turn 的下一个安全边界注入。" " 收到一条 steer AgentCompletionrun_id={run_id}, agent={agent_id}),将在当前 Turn 的下一个安全边界注入。"
)); ));
} }
Some(WakeupSource::AgentGroupCompletion { group_id }) => {
message.push_str(&format!(
" 收到一条 steer AgentGroupCompletiongroup={group_id}),将在当前 Turn 的下一个安全边界注入。"
));
}
Some(WakeupSource::AgentQueue) | None => { Some(WakeupSource::AgentQueue) | None => {
message.push_str(&format!( message.push_str(&format!(
" 收到 {} 条排队输入。内容不会进入当前 Turn将在当前工作结束后的下一 Turn处理。", " 收到 {} 条排队输入。内容不会进入当前 Turn将在当前工作结束后的下一 Turn处理。",

View File

@ -73,13 +73,6 @@ impl ToolExecutionContext {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DelegationPolicy {
RootOnly,
Delegatable,
RuntimeInjected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputInterruptPolicy { pub enum InputInterruptPolicy {
Never, Never,
@ -218,10 +211,14 @@ pub trait Tool: Send + Sync + 'static {
fn parameters_schema(&self) -> serde_json::Value; fn parameters_schema(&self) -> serde_json::Value;
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>; async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
/// Whether a named Agent definition may receive this tool. New tools fail /// Whether this tool is injected at runtime from the caller context
/// closed until their delegated behavior has been reviewed explicitly. /// (delegate targets, signal contract, skill allowlist) and therefore
fn delegation_policy(&self) -> DelegationPolicy { /// must never be declared directly in an Agent definition's `tools`
DelegationPolicy::RootOnly /// list. Every ordinary tool returns false: which tools a named Agent
/// receives is decided solely by its definition file, not by tool-side
/// delegation flags.
fn runtime_injected(&self) -> bool {
false
} }
/// Whether new Turn input may interrupt an in-flight invocation. /// Whether new Turn input may interrupt an in-flight invocation.

View File

@ -331,10 +331,6 @@ fn is_private_ip(ip: &std::net::IpAddr) -> bool {
#[async_trait] #[async_trait]
impl Tool for WebFetchTool { impl Tool for WebFetchTool {
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
crate::tools::DelegationPolicy::Delegatable
}
fn name(&self) -> &str { fn name(&self) -> &str {
"web_fetch" "web_fetch"
} }

View File

@ -1,12 +1,12 @@
{ {
"name": "picobot-webui", "name": "picobot-webui",
"version": "1.8.0", "version": "1.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picobot-webui", "name": "picobot-webui",
"version": "1.8.0", "version": "1.11.0",
"dependencies": { "dependencies": {
"bits-ui": "^2.0.0", "bits-ui": "^2.0.0",
"dompurify": "^3.4.12", "dompurify": "^3.4.12",
@ -469,7 +469,7 @@
} }
}, },
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.8.0", "version": "1.10.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/@floating-ui/core/-/core-1.8.0.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT", "license": "MIT",
@ -478,7 +478,7 @@
} }
}, },
"node_modules/@floating-ui/dom": { "node_modules/@floating-ui/dom": {
"version": "1.8.0", "version": "1.10.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/@floating-ui/dom/-/dom-1.8.0.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT", "license": "MIT",

View File

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

View File

@ -9,6 +9,7 @@
import ActivitySpine from "./lib/components/ActivitySpine.svelte"; import ActivitySpine from "./lib/components/ActivitySpine.svelte";
import ChatPage from "./pages/ChatPage.svelte"; import ChatPage from "./pages/ChatPage.svelte";
import TasksPage from "./pages/TasksPage.svelte"; import TasksPage from "./pages/TasksPage.svelte";
import AgentsPage from "./pages/AgentsPage.svelte";
import MemoryPage from "./pages/MemoryPage.svelte"; import MemoryPage from "./pages/MemoryPage.svelte";
import LogsPage from "./pages/LogsPage.svelte"; import LogsPage from "./pages/LogsPage.svelte";
import SettingsPage from "./pages/SettingsPage.svelte"; import SettingsPage from "./pages/SettingsPage.svelte";
@ -20,6 +21,7 @@
{ 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: "logs", label: "日志", description: "检查实时事件与运行记录" }, { name: "logs", label: "日志", description: "检查实时事件与运行记录" },
{ name: "memory", label: "记忆", description: "查找和维护长期记忆" }, { name: "memory", label: "记忆", description: "查找和维护长期记忆" },
{ name: "tasks", label: "任务", description: "跟踪定时任务与后台工作" }, { name: "tasks", label: "任务", description: "跟踪定时任务与后台工作" },
@ -29,6 +31,7 @@
chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>', chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
overview: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>', overview: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
tools: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>', tools: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
agents: '<rect x="4" y="7" width="16" height="12" rx="2"/><path d="M12 7V4"/><path d="M8 4h8"/><circle cx="9" cy="13" r="1"/><circle cx="15" cy="13" r="1"/>',
logs: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>', logs: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
memory: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>', memory: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>',
tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>', tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
@ -150,6 +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}<div class="empty-card">即将上线</div>{/if} {:else}<div class="empty-card">即将上线</div>{/if}
</main> </main>
</div> </div>

View File

@ -0,0 +1,306 @@
<script>
import { onMount } from "svelte";
import { api } from "../lib/api.js";
import Icon from "../lib/Icon.svelte";
import StatusBadge from "../lib/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 { notify } = $props();
const blank = () => ({
id: "",
description: "",
provider: "",
model: "",
token_limit: null,
max_tool_iterations: null,
tools: [],
skills: [],
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;
}
}
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) {
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 {
const payload = {
id: editing.id,
description: editing.description,
provider: editing.provider || null,
model: editing.model || null,
token_limit: editing.token_limit,
max_tool_iterations: editing.max_tool_iterations,
tools: editing.tools,
skills: editing.skills,
delegates: editing.delegates,
role_prompt: editing.role_prompt,
enabled: editing.enabled,
};
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 || [],
delegates: agent.delegates || [],
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);
</script>
<section class="page active content-page">
<div class="toolbar">
<div>
<h2 style="margin:0">具名子代理</h2>
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">
子代理由 <code>~/.picobot/agents/*.md</code> 定义工具、Skill、Provider 与模型在此直接指定。改动需热重载后生效。
</p>
</div>
<button class="primary" onclick={startNew}><Icon name="add" size={16} />新增子代理</button>
</div>
{#if loading}
<div class="loading">加载中…</div>
{:else if error}
<div class="empty-card error-text">{error}</div>
{:else if agents.length === 0}
<div class="empty-card">暂无子代理定义</div>
{:else}
<div class="cards">
{#each agents as agent (agent.id)}
<article class="card">
<div class="card-row">
<div>
<h3>{agent.id} <StatusBadge status={agent.enabled ? "enabled" : "disabled"} /></h3>
<p>{agent.description}</p>
<div class="meta">
<span>provider: {agent.provider || agent.llm_profile || "—"}</span>
<span>model: {agent.model || "—"}</span>
{#if agent.tools?.length}<span>{agent.tools.length} 个工具</span>{/if}
{#if agent.skills?.length}<span>{agent.skills.length} 个 Skill</span>{/if}
{#if agent.delegates?.length}<span>委托: {agent.delegates.join(", ")}</span>{/if}
</div>
{#if agent.tools?.length}
<div class="tag-row">
{#each agent.tools as tool (tool)}<span class="tag" title={toolDesc(tool)}>{tool}</span>{/each}
</div>
{/if}
</div>
<div class="card-actions">
<button class="secondary" onclick={() => editAgent(agent)}><Icon name="more" size={15} />编辑</button>
<button class="switch" data-state={agent.enabled ? "checked" : "unchecked"} aria-label="启用/禁用" onclick={() => toggleEnabled(agent)}>
<span class="switch-thumb" data-state={agent.enabled ? "checked" : "unchecked"}></span>
</button>
<button class="icon-button danger" aria-label="删除" onclick={() => remove(agent)}><Icon name="dismiss" size={16} /></button>
</div>
</div>
</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>
<div class="tag-row selectable">
{#each agents.filter((a) => a.id !== editing.id) as agent (agent.id)}
<button class="tag pick" class:picked={editing.delegates.includes(agent.id)} onclick={() => toggleTool(editing.delegates, agent.id)}>{agent.id}</button>
{/each}
</div>
<div class="form-label">角色正文</div>
<textarea bind:value={editing.role_prompt} placeholder="# Role&#10;&#10;你是一名…"></textarea>
</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}
</section>
<style>
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.tag { padding: 2px 8px; border: 1px solid var(--line); border-radius: 4px; color: var(--text-soft); background: var(--code-bg); font-size: 11px; font-family: var(--font-mono); }
.tag-row.selectable .tag { cursor: pointer; user-select: none; }
.tag-row.selectable .tag.picked { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
.card-actions { display: flex; align-items: center; gap: 8px; }
.icon-button.danger:hover { color: var(--danger); border-color: var(--danger-border); }
.modal-scrim { position: fixed; inset: 0; z-index: 40; border: 0; padding: 0; cursor: default; background: rgba(0,0,0,.45); }
.modal { position: fixed; z-index: 41; top: 6vh; left: 50%; transform: translateX(-50%); width: min(720px, 94vw); max-height: 88vh; overflow: auto; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); box-shadow: var(--shadow-16, var(--shadow-8)); }
.editor-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--line); }
.editor-head strong { display: block; font-size: 15px; }
.editor-head small { color: var(--muted); font-size: 11px; }
.agent-form { display: grid; gap: 14px; padding: 18px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; }
input, select, textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--text); background: var(--panel-2); font-size: 13px; }
input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
textarea { min-height: 140px; resize: vertical; font-family: var(--font-mono); line-height: 1.6; }
.form-label { color: var(--muted); font-size: 12px; font-weight: 600; }
.form-label small { font-weight: 400; color: var(--muted); }
.editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 18px; border-top: 1px solid var(--line); }
</style>

View File

@ -70,32 +70,6 @@
return "var(--danger)"; return "var(--danger)";
} }
function groupTasks(all) {
const groups = [];
const byGroup = new Map();
const roots = [];
for (const task of all) {
if (task.source === "legacy_background_task") {
groups.push({ key: `legacy-${task.id}`, title: task.prompt.slice(0, 100), status: task.status, runs: [task] });
} else if (task.group_id) {
if (!byGroup.has(task.group_id)) {
byGroup.set(task.group_id, { key: `group-${task.group_id}`, title: `任务组 ${task.group_id.slice(0, 8)}`, status: task.status, runs: [] });
groups.push(byGroup.get(task.group_id));
}
byGroup.get(task.group_id).runs.push(task);
} else {
roots.push({ key: `run-${task.id}`, title: task.prompt.slice(0, 100), status: task.status, runs: [task] });
}
}
for (const group of byGroup.values()) {
group.runs.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
group.status = group.runs.find((task) => task.status === "running")?.status || group.runs[0]?.status || "pending";
}
groups.push(...roots);
groups.sort((a, b) => (b.runs[0]?.created_at || 0) - (a.runs[0]?.created_at || 0));
return groups;
}
onMount(() => { onMount(() => {
load(); load();
const timer = setInterval(() => { tick += 1; }, 30000); const timer = setInterval(() => { tick += 1; }, 30000);
@ -117,40 +91,28 @@
{#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"} {:else if tab === "background"}
{#each groupTasks(tasks) as group (group.key)} {#each tasks as task (task.id)}
{#if group.runs.length === 0}
<div class="empty-card">暂无后台任务</div>
{:else}
<article class="card"> <article class="card">
<div class="card-row"> <div class="card-row">
<div> <div class="task-main">
<h3>{group.title}</h3>
<div class="meta">
<span>{group.runs[0].session_id}</span>
<span>{formatTime(group.runs[0].created_at)}</span>
{#if group.runs[0].status === "running"}<span class="elapsed">{elapsed(group.runs[0].created_at)}</span>{/if}
</div>
</div>
<StatusBadge status={group.status} />
</div>
<div class="run-tree">
{#each group.runs as task}
<div class="run-node" style="--depth:{Math.min(task.depth ?? 1, 6)}">
<div class="run-row"> <div class="run-row">
<span class="pulse" class:visible={task.status === "running"}></span> <span class="pulse" class:visible={task.status === "running"}></span>
<span class="agent-tag">{task.agent_id || "general"}</span> <span class="agent-tag">{task.agent_id || "general"}</span>
<span class="run-prompt">{task.prompt.slice(0, 80)}</span> <span class="run-prompt">{task.prompt?.slice(0, 120) || ""}</span>
<StatusBadge status={task.status} /> <StatusBadge status={task.status} />
<span class="meta">{task.tool_calls_count} 次工具调用 · {task.iterations}</span> </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> </div>
{#if task.result}<div class="details"><p>{task.result.slice(0, 300)}</p></div>{/if} {#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} {#if task.error}<p class="error-text">{task.error.slice(0, 200)}</p>{/if}
</div>
{/each}
</div>
</article> </article>
{/if} {:else}<div class="empty-card">暂无后台任务</div>{/each}
{/each}
{:else} {:else}
{#each jobs as job (job.id)} {#each jobs as job (job.id)}
<article class="card"> <article class="card">
@ -186,8 +148,7 @@
.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; } .elapsed { color: var(--info); font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
.run-tree { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; } .task-main { flex: 1; min-width: 0; }
.run-node { margin-left: calc(var(--depth) * 18px); border-left: 1px solid var(--line); padding-left: 10px; }
.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); } .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; } .run-prompt { flex: 1 1 200px; min-width: 120px; }