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
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
- **AgentLoop** is stateless across turns; it receives prepared history, drains same-Turn steering only at safe model boundaries, calls LLM providers, executes tools, and returns one result
- **AgentCatalog** is immutable per runtime generation; when orchestration is enabled, candidate preparation strictly validates trusted Markdown definitions, Provider profiles, 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 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
@ -106,7 +106,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **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
- **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`
- **Stateful tools** receive `ToolExecutionContext`; browser calls without `persistent_id` map each PicoBot dialog to an opaque transient agent-browser session. For long-running work an Agent may autonomously create a persistent identity and must pass its validated ID on every related action; the same ID shares one agent-browser session and serialization gate across dialogs, while different IDs have independent sessions/gates. `browser_profiles` may create IDs, persist bounded semantic labels, list, or delete only validated IDs beneath the configured profile root. Do not introduce a global persistence mode switch, a default persistent ID, automatic per-dialog persistent Profiles, Fantoccini, ChromeDriver, WebDriver, model-controlled raw agent-browser session IDs, or arbitrary Profile paths
- **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, and `/health`; checks must not install/fix dependencies, call Provider APIs, or expose secrets

View File

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

View File

@ -408,7 +408,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
### 具名子 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
---
@ -430,7 +430,7 @@ limits:
你是一名严谨的研究 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)。

View File

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

View File

@ -25,7 +25,7 @@
},
"gateway": {
"host": "127.0.0.1",
"port": 19876,
"port": 19877,
"require_pairing": true
},
"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。
当前 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. 持久化
@ -219,7 +219,7 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
- 5 秒 busy timeout。
- 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/迁移逻辑。
2. 保留已有数据库的升级路径。
@ -236,7 +236,7 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
## 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 的模型上下文 |
| Steer | 输入尝试进入当前 Turn并在最近安全边界注入失败时可靠退化为 queue |
| 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 结果与信号的权威来源 |
| Turn Mailbox | 当前 Turn 接受 steer 输入的有界内存邮箱,保留来源、顺序和 durable event ID |
@ -216,7 +216,7 @@ flowchart LR
Sub[Sub Agent] --> DT
DT --> C[AgentCoordinator]
C --> AC[AgentCatalog]
C --> DP[DelegationPolicy]
C --> RI[runtime-injected tool marker]
C --> PF[ProviderFactory]
C --> TR[Filtered ToolRegistry]
C --> AR[AgentRunner]
@ -239,11 +239,11 @@ flowchart LR
替代当前承担过多职责的 `SubAgentManager`,负责:
- 解析 caller/target 和授权委托边。
- 创建 run/group ID、父子关系和预算。
- 创建 run ID、父子关系和预算。
- 持久化接纳状态后启动 AgentRunner。
- 管理 foreground await、background spawn、取消和超时。
- 控制全局、session、Agent 与任务树的 run admission quota以及 Provider/普通工具步骤的 execution permit两类配额不共用生命周期。
- 生成自动 completion terminal outcome按 group policy 物化 inbox event。
- 生成自动 completion terminal outcome逐 run 物化 inbox event。
- 向 WorkManager 条件提交计划子项结果。
### 6.3 AgentRunner
@ -259,7 +259,7 @@ flowchart LR
### 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
@ -290,30 +290,24 @@ Root 的 allowed targets 来自 `root_delegates`;子 Agent 来自自身 Markdo
### 7.2 工具权限
调用参数不再提供 `allowed_tools` 扩权。有效工具集为:
工具可用性完全由具名 Agent 定义文件决定:有效工具集为
```text
AgentDefinition.tools
∩ 当前运行代已注册工具
∩ 系统可委托工具策略
AgentDefinition.tools ∩ 当前运行代已注册工具
```
Tool 增加安全元数据
不再有工具侧的「可派发」标志。Tool trait 只保留一个运行时注入标记
```rust
enum DelegationPolicy {
RootOnly,
Delegatable,
RuntimeInjected,
}
/// 该工具由运行上下文按需注入delegate 目标、信号契约、skill allowlist
/// 不能直接写进 Definition 的 `tools` 列表。普通工具默认 false。
fn runtime_injected(&self) -> bool { false }
```
- `reload_config``todo`、管理配置和任意外部发送默认 `RootOnly`
- 普通只读工具在明确审查后标记 `Delegatable`
- `delegate``emit_signal` 等由 Coordinator 根据运行上下文注入,标记 `RuntimeInjected`,不能仅靠 Markdown 获得。
- 新工具默认 `RootOnly`,避免未来工具无意暴露。
- `delegate``emit_signal``get_skill``agent_task` 标记 `runtime_injected=true`,由 Coordinator 根据 `delegates`/`signal`/`skills` 字段和运行上下文注入,不能仅靠 Markdown 的 `tools` 声明;`get_skill` 是唯一例外——把它写进 `tools` 表示启用 scoped skill 包装器。
- 其余任何已注册工具(含 `bash``send_message``todo` 等)都可由管理员在定义文件的 `tools` 列表显式授权,这是知情的选择。
目标 Agent 可以拥有调用方没有的专业工具,因为委托边本身就是管理员授权调用该能力;模型不能在单次调用中越过 Definition 扩权
`allowed_tools` 调用参数只能收窄 Definition 的工具集,不能扩权;模型不能在单次调用中越过 Definition。
## 8. AgentExecutionContext
@ -324,7 +318,6 @@ pub struct AgentExecutionContext {
pub root_session_id: String,
pub root_turn_id: Option<String>,
pub run_id: String,
pub group_id: Option<String>,
pub parent_run_id: Option<String>,
pub caller_agent_id: String,
pub current_agent_id: String,
@ -387,7 +380,6 @@ agent_task → get / list / cancel / get_result
```json
{
"group_id": "group-123",
"runs": [
{"run_id": "run-a", "agent": "researcher", "status": "queued"},
{"run_id": "run-b", "agent": "coder", "status": "queued"},
@ -411,11 +403,10 @@ agent_task → get / list / cancel / get_result
}
```
- `signal`:运行中主动事件的投递方式。
- `completion`:正常终态结果的投递方式,默认 queue。
- `failure`:失败、超时、异常中断的投递方式,默认 queue可显式 steer。
- `signal`:运行中主动事件的投递方式(当前由 Definition 的 `signal:` 契约 `delivery` 字段决定 queue/steer
- `completion` / `failure`:投递契约为未来扩展;当前 completion 与 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 返回
@ -436,7 +427,7 @@ Foreground 请求直接把 completion 作为 tool result 返回,因此不接
### 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 与上下文隔离
@ -506,7 +497,7 @@ stateDiagram-v2
| Signal | 子 Agent 主动调用 `emit_signal` | 否 | 重要中间状态、监控告警 |
| 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
@ -550,7 +541,7 @@ Coordinator 强制执行:
### 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 与附件职责
@ -564,7 +555,7 @@ attach_artifact 工具产物 → 当前 Turn → DeliveryCoordinator当前回
### 13.1 send_message
只负责用户明确授权的跨 Channel/跨会话外部消息,具有真实外部副作用。默认 `RootOnly`目标和文件参数继续受 Channel/file transfer 限制`origin` 不再由模型自由填写,改由 ToolExecutionContext 生成,避免来源伪造。
只负责用户明确授权的跨 Channel/跨会话外部消息,具有真实外部副作用。目标和文件参数继续受 Channel/file transfer 限制`origin` 不再由模型自由填写,改由 ToolExecutionContext 生成,避免来源伪造。是否对子 Agent 开放由管理员在定义文件的 `tools` 里显式决定。
### 13.2 emit_signal
@ -576,7 +567,7 @@ attach_artifact 工具产物 → 当前 Turn → DeliveryCoordinator当前回
### 13.4 自动 completion
Completion 不是工具。AgentRunner 的终结路径统一返回 terminal outcomeCoordinator 保存结果并按 foreground/background 与 group policy 创建相应投递 event避免模型遗漏或重复。
Completion 不是工具。AgentRunner 的终结路径统一返回 terminal outcomeCoordinator 保存结果并按 foreground/background 创建相应投递 event避免模型遗漏或重复。
## 14. 持久化模型
@ -586,7 +577,6 @@ Completion 不是工具。AgentRunner 的终结路径统一返回 terminal outco
agent_runs
----------
id TEXT PRIMARY KEY
group_id TEXT
root_session_id TEXT NOT NULL
root_turn_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 不受影响。
### 14.2 agent_run_groups
### 14.2 agent_run_groups已删除schema v8
```text
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` 都绑定各自的 run 行,批量只是多个独立 run 的集合,使用 `(root_session_id, caller_scope_id, idempotency_key)` partial unique index避免批量 children 互相冲突。
批量请求的 `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/group 事实重算计数,发现差异时修复并记录告警。
容量判断不能在每次接纳时通过无锁 `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 事实重算计数,发现差异时修复并记录告警。
### 14.3 agent_inbox_events
@ -670,11 +641,8 @@ agent_inbox_events
------------------
id TEXT PRIMARY KEY
root_session_id TEXT NOT NULL
scope_kind run | group
scope_id TEXT NOT NULL
run_id TEXT
group_id TEXT
event_type signal | completion | group_completion
run_id TEXT NOT NULL
event_type signal | completion
event_key TEXT NOT NULL
delivery queue | steer
requires_continuation BOOLEAN NOT NULL DEFAULT TRUE
@ -693,16 +661,12 @@ dead_lettered_at INTEGER
fallback_notified_at INTEGER
revision INTEGER NOT NULL
UNIQUE(scope_kind, scope_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)
)
UNIQUE(run_id, event_type, event_key)
```
完整结果保存在 `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 原子事务
@ -710,16 +674,13 @@ Agent completion 必须在一个 Storage 事务中:
```text
UPDATE agent_runs terminal state/result/usage
UPDATE agent_run_groups terminal count/status
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
COMMIT
```
事务失败时不能对外宣称任务完成。内存 wakeup 只有在 commit 成功后发送。
`completion_policy=all` 只有把 group 从 non-terminal 条件更新为 terminal 的事务赢家可以插入 group completion其他 sibling 的迟到终态只完成自己的 run 条件更新,不能重复生成 event。
事务失败时不能对外宣称任务完成。内存 wakeup 只有在 commit 成功后发送。每个 run 的终端事务独立生成自己的 completion 事件;迟到终态只更新自己的 run 行,不影响其它 sibling。
### 14.5 continuation 消息与投递绑定
@ -758,7 +719,6 @@ pub enum TurnInputSource {
User,
AgentSignal { run_id: String, agent_id: String },
AgentCompletion { run_id: String, agent_id: String },
AgentGroupCompletion { group_id: String },
}
pub enum InputDelivery {
@ -859,7 +819,7 @@ Steer event 在没有活动 Turn 时按 queue 处理。用户输入通常优先
```rust
enum AgentTaskSource {
UserInput,
BackgroundAgentResults { event_ids: Vec<String>, group_id: Option<String> },
BackgroundAgentResults { event_ids: Vec<String> },
ScheduledTask,
}
```
@ -1074,7 +1034,7 @@ WebUI 管理面展示:
### 20.2 Chat 表现
- Foreground delegate 继续作为当前 Turn 的可折叠工具块。
- Background delegate 启动后显示 run/group ID不假装任务已完成。
- Background delegate 启动后显示 run ID不假装任务已完成。
- AgentSignal 显示为独立运行时信号卡片,不显示成用户气泡。
- queue completion 在主 Agent内部 continuation 后只显示主 Agent汇总回复。
- 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 限制。
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.1 Delegate 参数
旧模式映射:
```text
inline → foreground
parallel → foreground + tasks[]
background → background
```
过渡期只解析代码中确实存在的旧值并在 tool result/日志中给出弃用提示;`async` 从未是有效值,不新增该别名。新 system prompt 只描述 canonical 值 `foreground/background`
旧模式映射已在迁移完成后移除:`inline`/`parallel` 别名与 legacy general 委托均不再解析,只保留 canonical `foreground`/`background` 生命周期词与具名 `target`
### 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
新增 `agent_runs` 后:
- 新任务只写新表。
- 管理 API 在过渡期 union 读取旧 `background_tasks` 与新 `agent_runs`
- 旧终态记录按原 TTL 清理,不强制迁移正文。
- 旧 pending/running 记录在升级启动时按 interrupted/cancelled 规则收敛。
`background_tasks` 表已在 schema v7 中删除(`DROP TABLE`),旧 adapter 与 direct notification 路径一并移除;`/api/tasks` 只读取 `agent_runs`。无历史兼容需求。
### 22.4 版本与文档
@ -1162,8 +1109,8 @@ background → background
- Delegate schema 使用 target + foreground/background canonical modes。
- 批量 foreground 并发执行并聚合。
- 显式 AgentExecutionContext 和委托图授权。
- 明确 skills/memory 不继承、具名 Agent browser scope 隔离和 legacy general scope 兼容
- 保持旧 background 通知路径作为兼容,但不开放嵌套 background
- 明确 skills/memory 不继承、具名 Agent browser scope 隔离。
- 不开放嵌套 background子 Agent 发起的 background
### Phase 2AAgentLoop 结构化取消
@ -1173,7 +1120,7 @@ background → background
### Phase 2B统一 Agent Run 持久化
- 新增 `agent_runs``agent_run_groups`Storage transaction API。
- 新增 `agent_runs`、Storage transaction API。
- 拆分 `delegate``agent_task`
- Foreground 结果也持久化,修复截断结果不可查询。
- 实现预算、run admission quota 与 step execution permit 释放。
@ -1216,7 +1163,7 @@ src/agent/
└── sub_agent.rs 迁移兼容层,最终缩减或删除
src/tools/
├── delegate.rs create run/group only
├── delegate.rs create run only
├── agent_task.rs get/list/cancel/get_result
├── emit_signal.rs constrained internal signal
├── sleep.rs wake-aware wait
@ -1260,7 +1207,7 @@ src/storage/
- 不同 Agent 使用不同 provider/model profile。
- Provider storage/observer 正确注入。
- RootOnly 工具不能通过 Markdown 或兼容 allowed_tools 获得
- 工具集完全由定义文件的 `tools` 列表决定runtime-injected 工具不能通过 Markdown 声明
- runtime-injected delegate/emit_signal 只在上下文允许时存在。
- 并行 run 的 browser/resource scope 隔离。
- persistent browser profile 可显式共享;具名 Agent不继承 transient parent scope。
@ -1280,7 +1227,7 @@ src/storage/
- user mpsc 满不影响 durable wakewake revision 丢失后扫描可恢复。
- 用户持续输入时 burst/age 公平上限仍调度 continuation。
- completion capacity 在 background 接纳时预留signal 不能抢占。
- `each` 逐 run 提前投递;`all` 只触发一次 group 汇总 Turn。
- 每个 run 独立 completion逐 run 投递;无 group 汇总 Turn。
- retries/TTL 耗尽进入 dead-lettersystem fallback 最多发送一次。
### 25.5 Signal
@ -1332,7 +1279,7 @@ src/storage/
3. Foreground/Background 只描述委托方等待行为;并发是独立调度维度。
4. Queue 输入永不泄漏正文到当前 TurnSteer 只在安全边界注入。
5. Signal 先持久化后唤醒;内存通知不是事实来源。
6. Signal 是非终态事件run Completion 由运行时自动生成且恰好对应一个 run 终态`all` policy 的 Group Completion 恰好对应一个 group 终态
6. Signal 是非终态事件run Completion 由运行时自动生成且恰好对应一个 run 终态(批量背景也逐 run 生成,无 group completion
7. SendMessage 是外部输出EmitSignal 是内部输入,不能用一个公开万能工具混合权限。
8. Durable Agent event 在 `/stop`、Turn 失败或 Gateway 崩溃时不能静默丢失。
9. Agent Definition 和 Provider 绑定 runtime generation运行中不热切换。
@ -1351,7 +1298,7 @@ src/storage/
```text
Markdown Agent Definition
AgentCatalog + DelegationPolicy
AgentCatalog + runtime-injected 工具标记
AgentCoordinator
├─ 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 后:
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 为最终可诊断出口。
### B3 — `completion_policy=each`
@ -197,7 +197,7 @@ Provider stream、可取消等待和工具批次外层都观察 tokenAgentRun
|-------|------------------|
| 1 | 除原内容外,明确 browser 兼容 scope、skills/memory 规则和 sub-run sleep 行为 |
| 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 |
| 4 | typed TurnMailbox、emit_signal、steer admission以及同一 `AgentEventUpdated` 的 Signal 卡片呈现 |
| 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/db-schema.md` | 数据库表结构与运行约束sessions、messages、memories、scheduled_jobs、job_runs、llm_calls、background_tasks |
| `references/config.md` | 配置字段详解providers、models、agents、agent_orchestration、gateway、client、channels、memory、mcp、browser |
| `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/faq.md` | 常见问题模型切换、渠道添加、Skill 安装、历史查询、定时任务、MCP 等 |
| `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 内
- Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext`
- 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 不能修改计划
- WebUI 聊天复用 `/ws``cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
- 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 目标)会拒绝整个候选运行代,绝不静默裁剪。
- `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` 预算。
- `signal`:可选信号契约。带该块的 run 才获得 `emit_signal` 工具fail-closed`delivery: steer` 使信号在活动 Turn 的安全边界注入主 Agent`queue` 走 continuation。
- 角色正文(`---` 之后)即 `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 的完成事件 payload 携带该 run 已发出的 signal IDs主 Agent 可识别重复报告。
未启用编排或省略 target 时使用旧 general 兼容路径(结果带迁移提示)。其工具也只能取旧默认集合与 Delegatable 策略的交集;旧后台任务写入 `background_tasks` 表,完成后通过原 channel/chat 直接通知,默认 24 小时后清理,不具备 durable inbox 语义,等待一个版本观察后随旧适配器移除
旧匿名 general 兼容路径已移除:委托必须指定具名 `target`
后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册Gateway 关停时先收到取消信号,再在总宽限期内清理。

View File

@ -72,7 +72,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `max_user_turn_burst_before_inbox` | 4 | 用户 Turn 公平调度阈值 |
| `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 字段
@ -84,7 +84,6 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 |
| `session_db_path` | string | - | SQLite 数据库路径,默认在 workspace 下 |
| `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 |
| `max_concurrent_background_tasks` | int | 10 | delegate 后台子任务最大并发数 |
| `scheduler` | object | - | 调度器配置 |
### gateway.scheduler 字段

View File

@ -2,7 +2,7 @@
数据库为 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 表
@ -55,55 +55,13 @@
`(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 使用软删除,关联关系由应用层维护。
| 字段 | 类型 | 说明 |
|------|------|------|
| `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` 条件更新保证迟到结果丢弃。
每次具名委托foreground 与 background 一致)先落库再执行;`execution_id` 条件更新保证迟到结果丢弃。批量委托只是多个 run 的集合不再存在组头schema v7 的 `agent_run_groups` 表已删除)。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | TEXT PK | run ID |
| `group_id` | TEXT FK | 所属组RESTRICT |
| `root_session_id` | TEXT | 根会话 |
| `parent_run_id` | TEXT FK | 父 runRESTRICTNULL 表示 Root 直接委托 |
| `caller_agent_id` / `caller_scope_id` | TEXT | 调用方身份Root 的 caller_scope_id 固定 `"ROOT"` |
@ -116,7 +74,6 @@
| `task` / `context_json` | TEXT | 任务与调用方上下文 |
| `budget_json` | TEXT | 树级剩余预算 |
| `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 |
| `result` / `error` | TEXT | 终态完整结果/错误get_result 与 tool 结果同源) |
| `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)`(恢复扫描)。
## agent_session_state 表schema v6Agent 编排)
## agent_session_state 表schema v8Agent 编排)
每根会话一行inbox 容量与客户端 revision 的权威计数:
@ -142,16 +99,16 @@
容量判断在同一写事务内做条件 `UPDATE``pending + reserved + 新增 <= 上限`),杜绝并发 `COUNT(*)` 漂移。
## agent_inbox_events 表schema v6Agent 编排)
## agent_inbox_events 表schema v8Agent 编排)
background 完成/信号投递的唯一事实源:`pending → leased → admitted → consumed`,失败按 token 释放回 pending超限进 dead-letter崩溃靠 lease 过期恢复。
| 字段 | 说明 |
|------|------|
| `id` | TEXT PK |
| `root_session_id` / `scope_kind` / `scope_id` | 归属run 或 groupCHECK 互斥) |
| `run_id` / `group_id` | TEXT FKRESTRICT |
| `event_type` | signal / completion / group_completion |
| `root_session_id` | TEXT | 根会话 |
| `run_id` | TEXT FK NOT NULLRESTRICT |
| `event_type` | signal / completion |
| `event_key` | 去重键signal 含冷却窗口 id |
| `delivery` | queue / steer |
| `requires_continuation` | 是否反向启动 continuation Turncancel 产物为 false |
@ -164,7 +121,7 @@ background 完成/信号投递的唯一事实源:`pending → leased → admit
| `updated_at` | 最后更新时间 |
| `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 表

View File

@ -137,19 +137,15 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
| 参数 | 必填 | 说明 |
|------|------|------|
| `action` | 否 | 默认 `run`;迁移期仍支持 `check_task`, `cancel_task`, `list_tasks` |
| `target` | 具名 Agent 必填 | `root_delegates` 或当前 Agent Definition 允许的目标 ID |
| `task` | 单任务必填 | 明确、独立、可验收的子任务;旧 `prompt` 仅兼容解析 |
| `task` | 单任务必填 | 明确、独立、可验收的子任务 |
| `context` | 否 | 子 Agent 所需的显式事实;不会继承完整主会话历史 |
| `mode` | 否 | `foreground`(默认)或 `background`;批量并发不是第三种 mode |
| `tasks` | 批量必填 | 子任务数组foreground 并发执行、结果保持请求顺序 |
| `allowed_tools` | 否 | 迁移字段,只能收窄具名 Definition 或旧 general 默认集,不能扩权 |
| `max_iterations` | 否 | 旧 general 兼容限制;具名 Agent使用 Definition limits |
| `timeout_secs` | 否 | 旧 general 兼容限制;具名 Agent使用 Definition limits |
| `task_id` | 查询/取消必填 | 后台任务 ID |
| `allowed_tools` | 否 | 只能收窄具名 Definition 的工具集,不能扩权 |
| `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 查询与控制

View File

@ -49,7 +49,7 @@
"agent_orchestration": {
"enabled": false,
"definitions_dir": "agents",
"root_delegates": [],
"root_delegates": ["general-purpose"],
"max_tree_depth": 4,
"max_runs_per_tree": 16,
"max_concurrent_runs": 6,

View File

@ -12,7 +12,10 @@ mod embedded {
/// Install built-in Agent definitions into `<config_dir>/agents/`. Files
/// 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");
if let Err(error) = std::fs::create_dir_all(&agents_dir) {
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::sync::Arc;
use crate::config::{AgentOrchestrationConfig, LLMProviderConfig, expand_path};
use crate::config::{
AgentOrchestrationConfig, LLMProviderConfig, ModelConfig, ProviderConfig, expand_path,
};
use crate::skills::SkillsLoader;
use crate::tools::{DelegationPolicy, ToolRegistry};
use crate::tools::ToolRegistry;
use super::definition::{AgentDefinition, AgentDefinitionError, parse_definition};
@ -18,6 +20,10 @@ pub enum AgentCatalogError {
Directory(String),
#[error("Agent '{agent}' references unknown Provider profile '{profile}'")]
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}")]
InvalidTool {
agent: String,
@ -52,10 +58,14 @@ impl AgentCatalog {
}
}
#[allow(clippy::too_many_arguments)]
pub fn load(
config: &AgentOrchestrationConfig,
config_dir: &Path,
provider_profiles: &HashMap<String, LLMProviderConfig>,
providers: &HashMap<String, ProviderConfig>,
models: &HashMap<String, ModelConfig>,
workspace_dir: &Path,
tools: &ToolRegistry,
skills_loader: &SkillsLoader,
runtime_generation: u64,
@ -93,15 +103,18 @@ impl AgentCatalog {
.map(|(name, _)| name)
.collect();
let mut definitions = BTreeMap::new();
let mut disabled_ids = HashSet::new();
for path in paths {
let yaml = read_profile_name(&path)?;
let provider = provider_profiles.get(&yaml).cloned().ok_or_else(|| {
AgentCatalogError::UnknownProfile {
agent: path.display().to_string(),
profile: yaml.clone(),
}
})?;
let spec = read_provider_spec(&path)?;
// Disabled definitions stay on disk for the management UI but
// never enter the active catalog.
if !spec.enabled {
disabled_ids.insert(spec.id);
continue;
}
let provider =
resolve_provider(&spec, provider_profiles, providers, models, workspace_dir)?;
let definition = Arc::new(parse_definition(&path, Arc::new(provider))?);
if definitions.contains_key(&definition.id) {
return Err(AgentCatalogError::Config(format!(
@ -148,7 +161,7 @@ impl AgentCatalog {
));
}
for target in &root_delegates {
if !definitions.contains_key(target) {
if !definitions.contains_key(target) && !disabled_ids.contains(target) {
return Err(AgentCatalogError::UnknownDelegate {
agent: "ROOT".to_string(),
target: target.clone(),
@ -187,7 +200,7 @@ impl AgentCatalog {
}
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 {
@ -229,7 +242,20 @@ fn definition_paths(directory: &Path) -> Result<Vec<PathBuf>, AgentCatalogError>
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 {
path: path.display().to_string(),
source,
@ -267,11 +293,78 @@ fn read_profile_name(path: &Path) -> Result<String, AgentCatalogError> {
))
})?;
#[derive(serde::Deserialize)]
struct ProfileOnly {
llm_profile: String,
struct Spec {
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)?;
Ok(parsed.llm_profile)
let parsed: Spec = serde_yaml::from_str(yaml).map_err(AgentDefinitionError::Yaml)?;
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(
@ -286,14 +379,17 @@ fn validate_definition_tools(
tool: name.clone(),
reason: "tool is not registered in the prepared runtime".to_string(),
})?;
let allowed = tool.delegation_policy() == DelegationPolicy::Delegatable
|| (name == "get_skill"
&& tool.delegation_policy() == DelegationPolicy::RuntimeInjected);
if !allowed {
// Which tools a named Agent receives is decided by its definition
// file alone. Runtime-injected tools (delegate/emit_signal/
// get_skill/agent_task) are assembled from dedicated fields
// (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 {
agent: definition.id.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)]
mod tests {
use super::*;
use crate::tools::{CalculatorTool, FileWriteTool};
use crate::tools::{CalculatorTool, GetSkillTool};
fn provider() -> LLMProviderConfig {
LLMProviderConfig {
@ -380,8 +476,18 @@ mod tests {
);
let profiles = HashMap::from([("research".to_string(), provider())]);
let catalog =
AgentCatalog::load(&config(), root.path(), &profiles, &tools, &loader, 7).unwrap();
let catalog = AgentCatalog::load(
&config(),
root.path(),
&profiles,
&HashMap::new(),
&HashMap::new(),
root.path(),
&tools,
&loader,
7,
)
.unwrap();
assert!(catalog.root_can_delegate("researcher"));
assert!(catalog.can_delegate("researcher", "reviewer"));
@ -393,22 +499,61 @@ mod tests {
}
#[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();
std::fs::create_dir(root.path().join("agents")).unwrap();
write_agent(root.path(), "researcher", &["file_write"], &[]);
let tools = ToolRegistry::new();
tools.register(FileWriteTool::new());
tools.register(crate::tools::FileWriteTool::new());
let loader = SkillsLoader::new_for_testing(
root.path().join("skills"),
root.path().join("external-skills"),
);
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 =
AgentCatalog::load(&config(), root.path(), &profiles, &tools, &loader, 1).unwrap_err();
assert!(matches!(error, AgentCatalogError::InvalidTool { .. }));
// Runtime-injected tools (e.g. delegate) must not be declared in a
// definition's `tools` list; get_skill remains the one exception.
let root2 = tempfile::tempdir().unwrap();
std::fs::create_dir(root2.path().join("agents")).unwrap();
write_agent(root2.path(), "researcher", &["get_skill"], &[]);
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)]
@ -434,7 +579,18 @@ mod tests {
let profiles = HashMap::from([("research".to_string(), provider())]);
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::agent_inbox::AgentEventType;
use crate::storage::agent_run::{
AcceptAgentRequest, AcceptedAgentRuns, AgentCompletionPolicy, AgentRunMode, AgentRunRecord,
AgentRunStatus, AgentTerminalOutcome, NewAgentGroup, NewAgentRun,
AcceptAgentRequest, AcceptedAgentRuns, AgentRunMode, AgentRunRecord, AgentRunStatus,
AgentTerminalOutcome, NewAgentRun,
};
use crate::tools::ToolExecutionContext;
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
/// is materialized by the terminal commit and delivered through the Session
/// 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 {
storage: Arc<Storage>,
manager: Arc<SubAgentManager>,
@ -85,23 +91,34 @@ impl AgentCoordinator {
/// Admit a named background run for the root caller and spawn its runner.
/// Completion is guaranteed by the reserved inbox slot; the returned ID
/// 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(
self: &Arc<Self>,
caller: &ToolExecutionContext,
config: SubAgentConfig,
) -> Result<String, CoordinatorError> {
configs: Vec<SubAgentConfig>,
) -> Result<BackgroundAdmission, CoordinatorError> {
if caller.agent.is_some() {
return Err(CoordinatorError::Rejected(
"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(
"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();
let resolved = self.manager.resolve_agent(&config, caller, &run_id)?;
// Hard cap: a batch larger than the run quota would never execute
// 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
.session_id
.clone()
@ -116,28 +133,28 @@ impl AgentCoordinator {
})?;
let now = chrono::Utc::now().timestamp_millis();
// 0. Run quota + admission guard before any durable write; any
// failure here releases everything without touching SQLite. The
// permit stays with the runner until the terminal commit.
let run_permit = self
.execution_gate
.acquire_run(&root_session_id, &caller.cancellation)
.await
.map_err(|error| CoordinatorError::Rejected(error.to_string()))?;
let activity = self.admission.try_enter().ok_or_else(|| {
CoordinatorError::Rejected(
"gateway is draining for configuration reload and cannot accept background tasks"
.to_string(),
)
})?;
// Resolve every target before any durable write so a bad request
// fails closed without leaving orphan rows.
let mut run_ids = Vec::with_capacity(configs.len());
let mut resolved = Vec::with_capacity(configs.len());
for config in &configs {
if config.target.is_none() {
return Err(CoordinatorError::Rejected(
"named background targets only".to_string(),
));
}
let run_id = Uuid::new_v4().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
// nothing is admitted.
// 1. Reserve one completion slot per run; failure rejects the whole
// batch so nothing is admitted under capacity.
if self
.storage
.reserve_completion_slots(
&root_session_id,
1,
configs.len() as i64,
self.max_pending_inbox_events_per_session,
now,
)
@ -145,60 +162,60 @@ impl AgentCoordinator {
.is_none()
{
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.
let accept = NewAgentRun {
id: run_id.clone(),
root_session_id: root_session_id.clone(),
root_turn_id: caller.turn_id.clone(),
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
caller_scope_id: "ROOT".to_string(),
idempotency_key: None,
agent_id: resolved.agent_id.clone().unwrap_or_default(),
definition_hash: resolved.definition_hash.clone().unwrap_or_default(),
provider_profile: resolved.llm_profile.clone().unwrap_or_default(),
provider_name: resolved.provider_config.name.clone(),
model_id: resolved.provider_config.model_id.clone(),
mode: AgentRunMode::Background,
depth: 1,
plan_item_id: config.plan_item_id.clone(),
execution_id: run_id.clone(),
task: config.prompt.clone(),
context_json: config.context.clone(),
budget_json: serde_json::json!({
"remaining_runs": self.manager.catalog().max_runs_per_tree(),
"remaining_depth": self.manager.catalog().max_tree_depth(),
})
.to_string(),
signal_contract_json: resolved
.signal_contract
.as_ref()
.map(|contract| serde_json::to_string(contract).unwrap_or_default()),
signal_delivery: resolved
.signal_contract
.as_ref()
.map(|contract| contract.delivery.as_str().to_string()),
deadline_at: now + (resolved.timeout_secs * 1000) as i64,
runtime_generation: self.runtime_generation,
completion_slot_reserved: true,
};
// 2. Persist the queued runs atomically with the reservation.
let mut runs = Vec::with_capacity(configs.len());
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_turn_id: caller.turn_id.clone(),
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
caller_scope_id: "ROOT".to_string(),
idempotency_key: None,
agent_id: resolution.agent_id.clone().unwrap_or_default(),
definition_hash: resolution.definition_hash.clone().unwrap_or_default(),
provider_profile: resolution.llm_profile.clone().unwrap_or_default(),
provider_name: resolution.provider_config.name.clone(),
model_id: resolution.provider_config.model_id.clone(),
mode: AgentRunMode::Background,
depth: 1,
plan_item_id: config.plan_item_id.clone(),
execution_id: run_ids[index].clone(),
task: config.prompt.clone(),
context_json: config.context.clone(),
budget_json: serde_json::json!({
"remaining_runs": self.manager.catalog().max_runs_per_tree(),
"remaining_depth": self.manager.catalog().max_tree_depth(),
})
.to_string(),
signal_contract_json: resolution
.signal_contract
.as_ref()
.map(|contract| serde_json::to_string(contract).unwrap_or_default()),
signal_delivery: resolution
.signal_contract
.as_ref()
.map(|contract| contract.delivery.as_str().to_string()),
deadline_at: now + (resolution.timeout_secs * 1000) as i64,
runtime_generation: self.runtime_generation,
completion_slot_reserved: true,
});
}
match self
.storage
.accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![accept],
now,
})
.accept_agent_runs(AcceptAgentRequest { runs, now })
.await?
{
AcceptedAgentRuns::Accepted { .. } => {}
AcceptedAgentRuns::Existing { .. } => {
self.storage
.release_completion_slots(&root_session_id, 1, now)
.release_completion_slots(&root_session_id, configs.len() as i64, now)
.await?;
return Err(CoordinatorError::Rejected(
"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
// activity guard and run quota are held for the whole run, not
// released when `delegate_background` returns.
let token = CancellationToken::new();
self.active_tokens.insert(run_id.clone(), token.clone());
// 3. Spawn one runner per run. Each runner acquires its own run
// quota permit and admission guard; delegate returns immediately.
let coordinator = self.clone();
let config = config.clone();
let spawned = self
.task_supervisor
.spawn_graceful(format!("agent-run:{run_id}"), {
let run_id = run_id.clone();
async move {
coordinator
.run_background_runner(
&run_id, &config, resolved, token, run_permit, activity,
)
.await;
}
});
if !spawned {
// Compensation: undo the durable admission before returning.
// The rejected closure was dropped by the supervisor, which
// released the run quota permit and activity guard.
self.active_tokens.remove(&run_id);
let _ = self
.storage
.cancel_agent_run_with_completion(&run_id, "gateway shutdown", true, now)
.await;
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();
self.active_tokens.insert(run_id.clone(), token.clone());
let config = config.clone();
let resolution = resolved[index].clone();
let spawned = self
.task_supervisor
.spawn_graceful(format!("agent-run:{run_id}"), {
let coordinator = coordinator.clone();
let run_id = run_id.clone();
async move {
coordinator
.run_background_runner(&run_id, &config, resolution, token)
.await;
}
});
if !spawned {
// Compensation: the rejected closure was dropped by the
// supervisor. Cancel the run and release its slot.
self.active_tokens.remove(&run_id);
let _ = self
.storage
.cancel_agent_run_with_completion(&run_id, "gateway shutdown", true, now)
.await;
continue;
}
spawned_ids.push(run_id);
}
if spawned_ids.is_empty() {
return Err(CoordinatorError::Rejected(
"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(
@ -247,11 +272,46 @@ impl AgentCoordinator {
config: &SubAgentConfig,
resolved: ResolvedAgentRun,
token: CancellationToken,
_run_permit: crate::agent::gate::RunPermit,
_activity: crate::gateway::reload::ActivityGuard,
) {
let now = chrono::Utc::now().timestamp_millis();
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
.storage
.mark_agent_run_running(run_id, &execution_id, now)
@ -402,8 +462,8 @@ impl AgentCoordinator {
}
/// Startup/activation recovery: interrupt runs of older generations,
/// expire stale leases, converge group counters and reconcile the
/// per-session capacity rows. Safe to call once per activation.
/// expire stale leases and reconcile the per-session capacity rows.
/// Safe to call once per activation.
pub async fn recover_on_activation(
&self,
) -> 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()))
.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());
for (index, config) in configs.iter().enumerate() {
let resolution = &resolved[index];
@ -547,7 +590,7 @@ impl AgentCoordinator {
match self
.storage
.accept_agent_runs(AcceptAgentRequest { group, runs, now })
.accept_agent_runs(AcceptAgentRequest { runs, now })
.await?
{
AcceptedAgentRuns::Accepted { .. } => {}
@ -1113,7 +1156,18 @@ mod tests {
root_delegates: vec!["researcher".to_string()],
..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) {
@ -1122,6 +1176,13 @@ mod tests {
async fn coordinator_with_inbox_limit(
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) {
let dir = tempfile::tempdir().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,
..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(
storage,
@ -1150,7 +1222,7 @@ mod tests {
work_manager,
notifier,
Arc::new(crate::agent::AgentProjectionHub::new()),
crate::agent::gate::ExecutionGate::unbounded(),
gate,
crate::gateway::reload::RuntimeAdmission::open(),
supervisor,
1,
@ -1223,18 +1295,7 @@ mod tests {
.await
.unwrap()
.unwrap();
assert!(first.group_id.is_some());
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());
assert_ne!(first.id, second.id);
}
#[tokio::test]
@ -1280,23 +1341,21 @@ mod tests {
let (coordinator, _dir) = coordinator().await;
let caller = ToolExecutionContext::for_session("cli:test:dialog");
let run_id = coordinator
.delegate_background(&caller, foreground_config("researcher"))
let admission = coordinator
.delegate_background(&caller, vec![foreground_config("researcher")])
.await
.unwrap();
assert_eq!(admission.run_ids.len(), 1);
let run_id = &admission.run_ids[0];
let run = coordinator
.get_run(&caller, &run_id)
.await
.unwrap()
.unwrap();
let run = coordinator.get_run(&caller, run_id).await.unwrap().unwrap();
assert_eq!(run.mode, AgentRunMode::Background);
assert!(run.completion_slot_reserved);
// A second background run while the inbox is at capacity must be
// rejected, not admitted silently.
let error = coordinator
.delegate_background(&caller, foreground_config("researcher"))
.delegate_background(&caller, vec![foreground_config("researcher")])
.await
.unwrap_err();
assert!(matches!(error, CoordinatorError::Rejected(_)));
@ -1306,11 +1365,7 @@ mod tests {
// reservation into a durable completion event.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let run = coordinator
.get_run(&caller, &run_id)
.await
.unwrap()
.unwrap();
let run = coordinator.get_run(&caller, run_id).await.unwrap().unwrap();
if run.status.is_terminal() {
break;
}
@ -1342,6 +1397,44 @@ mod tests {
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(
storage: &Arc<Storage>,
run_id: &str,
@ -1382,7 +1475,6 @@ mod tests {
};
let _ = storage
.accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![run],
now,
})
@ -1418,7 +1510,6 @@ mod tests {
root_turn_id: None,
run_id: run_id.to_string(),
execution_id: run_id.to_string(),
group_id: None,
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(),
@ -1483,7 +1574,6 @@ mod tests {
root_turn_id: None,
run_id: run_id.to_string(),
execution_id: run_id.to_string(),
group_id: None,
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(),
@ -1573,7 +1663,6 @@ mod tests {
root_turn_id: None,
run_id: run_id.to_string(),
execution_id: run_id.to_string(),
group_id: None,
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(),
@ -1660,7 +1749,6 @@ mod tests {
root_turn_id: None,
run_id: "run-sig-3".to_string(),
execution_id: "run-sig-3".to_string(),
group_id: None,
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(),
@ -1711,7 +1799,6 @@ mod tests {
root_turn_id: None,
run_id: run_id.to_string(),
execution_id: run_id.to_string(),
group_id: None,
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(),

View File

@ -203,28 +203,51 @@ impl AgentLimits {
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct AgentFrontmatter {
id: String,
description: String,
llm_profile: String,
pub struct AgentFrontmatter {
pub id: String,
pub description: 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)]
tools: Vec<String>,
#[serde(default)]
delegates: Vec<String>,
#[serde(default)]
skills: Vec<String>,
#[serde(default)]
limits: AgentLimits,
#[serde(default)]
signal: Option<SignalContract>,
pub limits: AgentLimits,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signal: Option<SignalContract>,
}
pub fn default_true() -> bool {
true
}
#[derive(Debug, Clone)]
pub struct AgentDefinition {
pub id: 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 enabled: bool,
pub tools: Vec<String>,
pub delegates: Vec<String>,
pub skills: Vec<String>,
@ -253,6 +276,68 @@ pub(crate) fn parse_definition(
path: &Path,
provider_config: Arc<LLMProviderConfig>,
) -> 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 {
path: path.display().to_string(),
source,
@ -320,9 +405,20 @@ pub(crate) fn parse_definition(
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!(
"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
)));
}
@ -333,7 +429,6 @@ pub(crate) fn parse_definition(
reject_duplicates("tools", &frontmatter.tools)?;
reject_duplicates("delegates", &frontmatter.delegates)?;
reject_duplicates("skills", &frontmatter.skills)?;
let file_stem = path.file_stem().and_then(|value| value.to_str());
if file_stem != Some(frontmatter.id.as_str()) {
return Err(AgentDefinitionError::Invalid(format!(
@ -342,33 +437,7 @@ pub(crate) fn parse_definition(
path.display()
)));
}
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(),
})
Ok((frontmatter, role_prompt))
}
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
/// is always global -> session and release order is reversed.
pub struct ExecutionGate {
max_concurrent_runs: usize,
run_global: Arc<Semaphore>,
run_session: Arc<KeyedSemaphores>,
provider_global: Arc<Semaphore>,
@ -91,6 +92,7 @@ impl std::fmt::Debug for ExecutionGate {
impl ExecutionGate {
pub fn new(config: &crate::config::AgentOrchestrationConfig) -> Arc<Self> {
Arc::new(Self {
max_concurrent_runs: 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)),
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.
pub fn unbounded() -> Arc<Self> {
Arc::new(Self {
max_concurrent_runs: usize::MAX,
run_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
run_session: Arc::new(KeyedSemaphores::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(
self: &Arc<Self>,
session_id: &str,

View File

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

View File

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

View File

@ -1,7 +1,6 @@
use std::sync::Arc;
use std::time::Instant;
use crate::agent::AgentError;
use crate::agent::AgentLoop;
use crate::agent::system_prompt::build_sub_agent_system_prompt;
@ -163,7 +162,6 @@ impl SubAgentManager {
self
}
pub(crate) fn resolve_agent(
&self,
config: &SubAgentConfig,
@ -258,7 +256,6 @@ impl SubAgentManager {
root_turn_id: caller.turn_id.clone(),
run_id: task_id.to_string(),
execution_id: task_id.to_string(),
group_id: None,
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: target.to_string(),
@ -349,7 +346,7 @@ impl SubAgentManager {
skills_prompt,
agent_id: Some(target.to_string()),
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(),
tool_context: ToolExecutionContext::for_session(format!("agent-run:{task_id}"))
.with_turn_id(
@ -530,7 +527,6 @@ fn terminal_status_from_error(error: AgentError) -> TaskStatus {
}
}
fn format_duration(seconds: u64) -> String {
if seconds < 60 {
format!("{}s", seconds)
@ -602,33 +598,37 @@ mod tests {
}
#[test]
fn reload_tool_is_never_delegated_to_sub_agents() {
let tool = crate::tools::ReloadConfigTool::new(
fn runtime_injected_tools_are_marked_but_ordinary_tools_are_not() {
let reload = crate::tools::ReloadConfigTool::new(
crate::gateway::reload::ReloadHandle::unavailable(),
);
assert_eq!(
crate::tools::Tool::delegation_policy(&tool),
crate::tools::DelegationPolicy::RootOnly
);
assert!(!crate::tools::Tool::runtime_injected(&reload));
}
#[test]
fn resolve_agent_rejects_missing_target() {
let manager = manager();
let error = match manager.resolve_agent(&config(None), &ToolExecutionContext::default(), "t-1") {
Ok(_) => panic!("expected rejection"),
Err(error) => error,
};
let error =
match manager.resolve_agent(&config(None), &ToolExecutionContext::default(), "t-1") {
Ok(_) => panic!("expected rejection"),
Err(error) => error,
};
assert!(matches!(error, SubAgentError::Other(message) if message.contains("named target")));
}
#[test]
fn resolve_agent_rejects_unknown_target_without_catalog() {
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"),
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 {
"## 子 Agent 委托原则\n\n\
- \n\
- Agent \n\
- delegate Agent\n\
- Agent agents/*.md 的 tools 列表)决定,不要重复说明它已有哪些工具。\n\
- Agent delegates \n\
- prompt \n\
- background"
- background tasks run "
.to_string()
}
}

View File

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

View File

@ -315,8 +315,6 @@ pub struct GatewayConfig {
pub cleanup_interval_minutes: Option<u64>,
#[serde(default, rename = "session_db_path")]
pub session_db_path: Option<String>,
#[serde(default, rename = "max_concurrent_background_tasks")]
pub max_concurrent_background_tasks: usize,
#[serde(default)]
pub scheduler: Option<SchedulerConfig>,
#[serde(default)]
@ -332,7 +330,6 @@ impl Default for GatewayConfig {
session_ttl_hours: None,
cleanup_interval_minutes: None,
session_db_path: None,
max_concurrent_background_tasks: 10,
scheduler: None,
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 })))
}
/// 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> {
let loader = state.session_manager.skills_loader();
let skills: Vec<Value> = loader
@ -879,67 +1132,34 @@ pub async fn get_tasks(
Query(query): Query<LimitQuery>,
) -> Result<Json<Value>, ApiError> {
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
.storage
.list_all_agent_runs(None, limit as i64)
.await
.map_err(ApiError::internal)?;
let legacy = state
.storage
.list_recent_background_tasks(limit)
.await
.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",
"id": run.id,
"group_id": run.group_id,
"parent_run_id": run.parent_run_id,
"session_id": run.root_session_id,
"channel": null,
"chat_id": null,
"agent_id": run.agent_id,
"mode": run.mode.as_str(),
"depth": run.depth,
"prompt": run.task,
"status": run.status.as_str(),
"result": run.result,
"error": run.error,
"tool_calls_count": run.tool_calls_count,
"iterations": run.iterations,
"started_at": run.started_at,
"finished_at": run.finished_at,
"created_at": run.created_at,
}));
}
for task in legacy {
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);
let tasks: Vec<Value> = runs
.into_iter()
.map(|run| {
json!({
"source": "agent_run",
"id": run.id,
"parent_run_id": run.parent_run_id,
"session_id": run.root_session_id,
"agent_id": run.agent_id,
"mode": run.mode.as_str(),
"depth": run.depth,
"prompt": run.task,
"status": run.status.as_str(),
"result": run.result,
"error": run.error,
"tool_calls_count": run.tool_calls_count,
"iterations": run.iterations,
"started_at": run.started_at,
"finished_at": run.finished_at,
"created_at": run.created_at,
})
})
.collect();
Ok(Json(json!({ "tasks": tasks })))
}

View File

@ -53,6 +53,8 @@ pub struct GatewayState {
pub(crate) reload: reload::ReloadHandle,
pub(crate) admission: reload::RuntimeAdmission,
pub agent_catalog: Arc<crate::agent::AgentCatalog>,
/// Directory holding Agent definition files (resolved definitions_dir).
pub agents_dir: std::path::PathBuf,
}
impl GatewayState {
@ -190,11 +192,26 @@ impl GatewayState {
.unwrap_or_else(|| std::path::Path::new("."))
.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
let session_manager = SessionManager::new(
provider_config.clone(),
AgentCatalogPreparation {
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_dir,
runtime_generation,
@ -283,6 +300,7 @@ impl GatewayState {
reload,
admission,
agent_catalog,
agents_dir,
})
}
@ -299,9 +317,8 @@ impl GatewayState {
/// Start the message processing loops
pub async fn start_message_processing(&self) {
// Recover durable Agent state for this runtime generation: interrupt
// runs of older generations, expire stale inbox leases, converge
// group counters and reconcile capacity rows. Runs never recover
// while the generation is still a candidate.
// runs of older generations, expire stale inbox leases and reconcile
// capacity rows. Runs never recover while the generation is still a candidate.
if let Some(coordinator) = self.session_manager.agent_coordinator() {
match coordinator.recover_on_activation().await {
Ok(report) => {
@ -312,7 +329,6 @@ impl GatewayState {
leases_expired = report.leases_expired,
dead_lettered = report.dead_lettered,
sessions_reconciled = report.sessions_reconciled,
groups_converged = report.groups_converged,
"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/jobs", routing::get(http::get_jobs))
.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/{id}",

View File

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

View File

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

View File

@ -752,17 +752,6 @@ fn steer_input_from_event(
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 {
id: format!("steer:{}", event.id),
@ -1744,6 +1733,9 @@ pub struct SessionManagerServices {
pub struct AgentCatalogPreparation {
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_dir: std::path::PathBuf,
pub runtime_generation: u64,
@ -1967,6 +1959,9 @@ impl SessionManager {
&catalog_preparation.config,
&catalog_preparation.config_dir,
&catalog_preparation.provider_profiles,
&catalog_preparation.providers,
&catalog_preparation.models,
&catalog_preparation.workspace_dir,
&tools,
&skills_loader,
catalog_preparation.runtime_generation,
@ -2017,25 +2012,6 @@ impl SessionManager {
tools.register(delegate_tool);
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 {
inner: Arc::new(Mutex::new(SessionManagerInner {
sessions: HashMap::new(),
@ -3156,7 +3132,6 @@ impl SessionManager {
task_id: task_id.map(|s| s.to_string()),
from_run_id: None,
from_agent_id: None,
group_id: None,
};
let msg = ChatMessage::assistant_with_source(content, source);
append_persisted_messages(&session, vec![msg])
@ -3286,7 +3261,6 @@ impl SessionManager {
task_id: None,
from_run_id: None,
from_agent_id: None,
group_id: None,
};
let mut message =
guard.create_user_message_with_source(content, media_refs, source);
@ -3570,11 +3544,24 @@ fn spawn_agent_worker(
};
let mut consecutive_user_turns = 0usize;
'tasks: loop {
// Fairness: a due inbox event must be processed before the
// next user Turn once the user burst budget is exhausted or
// the oldest pending event has waited too long. The next
// pending due time also arms a timer so a released event is
// re-claimed after its retry backoff without needing a wake.
// Drain user tasks first so we can tell whether the session
// is idle. Admission sequence numbers are allocated while
// holding the Session lock; draining everything currently
// visible before selecting the smallest sequence keeps a
// 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 storage = {
let guard = session.lock().await;
@ -3595,8 +3582,11 @@ fn spawn_agent_worker(
)
.await
.unwrap_or(None);
let force = consecutive_user_turns >= inbox_burst
|| oldest_due.is_some_and(|created_at| now - created_at >= inbox_wait_ms);
let age_exceeded = oldest_due
.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
&& let Ok(Some(lease)) = crate::storage::Storage::claim_inbox_batch(
&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) {
task
} else {
@ -3688,7 +3671,6 @@ fn spawn_agent_worker(
task_id: None,
from_run_id: None,
from_agent_id: None,
group_id: None,
};
let mut message =
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 {
Signal,
Completion,
GroupCompletion,
}
impl AgentEventType {
@ -16,7 +15,6 @@ impl AgentEventType {
match self {
Self::Signal => "signal",
Self::Completion => "completion",
Self::GroupCompletion => "group_completion",
}
}
@ -24,7 +22,6 @@ impl AgentEventType {
match value {
"signal" => Ok(Self::Signal),
"completion" => Ok(Self::Completion),
"group_completion" => Ok(Self::GroupCompletion),
other => Err(StorageError::Migration(format!(
"corrupt agent event type '{other}'"
))),
@ -98,10 +95,7 @@ impl AgentEventStatus {
pub struct AgentInboxEventRecord {
pub id: String,
pub root_session_id: String,
pub scope_kind: String,
pub scope_id: String,
pub run_id: Option<String>,
pub group_id: Option<String>,
pub event_type: AgentEventType,
pub event_key: String,
pub delivery: AgentEventDelivery,
@ -128,10 +122,7 @@ pub struct AgentInboxEventRecord {
pub struct NewInboxEvent {
pub id: String,
pub root_session_id: String,
pub scope_kind: String,
pub scope_id: String,
pub run_id: Option<String>,
pub group_id: Option<String>,
pub event_type: AgentEventType,
pub event_key: String,
pub delivery: AgentEventDelivery,
@ -163,13 +154,12 @@ pub struct RecoveryReport {
pub leases_expired: usize,
pub dead_lettered: 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, \
event_type, event_key, delivery, requires_continuation, severity, payload_json, status, \
attempt_count, lease_token, lease_until, next_attempt_at, admitted_turn_id, last_error, \
revision, created_at, consumed_at, superseded_at, dead_lettered_at, fallback_notified_at, \
const EVENT_COLUMNS: &str = "id, root_session_id, run_id, event_type, event_key, delivery, \
requires_continuation, severity, payload_json, status, attempt_count, lease_token, \
lease_until, next_attempt_at, admitted_turn_id, last_error, revision, created_at, \
consumed_at, superseded_at, dead_lettered_at, fallback_notified_at, \
fallback_suppressed_reason";
fn event_record_from_row(
@ -178,10 +168,7 @@ fn event_record_from_row(
Ok(AgentInboxEventRecord {
id: row.get("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"),
group_id: row.get("group_id"),
event_type: AgentEventType::parse(row.get::<&str, _>("event_type"))?,
event_key: row.get("event_key"),
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?;
if let Some(existing_id) = sqlx::query_scalar::<_, String>(
"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.scope_id)
.bind(&event.run_id)
.bind(event.event_type.as_str())
.bind(&event.event_key)
.fetch_optional(&mut *tx)
@ -864,8 +850,7 @@ impl super::Storage {
/// failure completion event (the reservation is converted).
/// 2. Expired leases return to `pending` with a backoff; attempts beyond
/// the maximum become `dead_letter`.
/// 3. Group counters are recomputed from their runs and finalized.
/// 4. Per-session capacity counters are reconciled with the rows.
/// 3. Per-session capacity counters are reconciled with the rows.
pub async fn recover_agent_state(
&self,
active_generation: i64,
@ -877,14 +862,14 @@ impl super::Storage {
let mut tx = self.pool.begin().await?;
// 1. Interrupt runs of previous generations.
let interrupted: Vec<(String, String, i64)> = sqlx::query_as(
"SELECT id, root_session_id, completion_slot_reserved FROM agent_runs \
let interrupted: Vec<(String, String, i64, String, String)> = sqlx::query_as(
"SELECT id, root_session_id, completion_slot_reserved, agent_id, task FROM agent_runs \
WHERE runtime_generation != ? AND status IN ('queued', 'running', 'waiting_children')",
)
.bind(active_generation)
.fetch_all(&mut *tx)
.await?;
for (run_id, session_id, reserved) in &interrupted {
for (run_id, session_id, reserved, agent_id, task) in &interrupted {
let updated = sqlx::query(
"UPDATE agent_runs SET status = 'interrupted', error = ?, finished_at = ?, updated_at = ? \
WHERE id = ? AND status IN ('queued', 'running', 'waiting_children')",
@ -906,20 +891,21 @@ impl super::Storage {
let event = NewInboxEvent {
id: uuid::Uuid::new_v4().to_string(),
root_session_id: session_id.clone(),
scope_kind: "run".to_string(),
scope_id: 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}"),
delivery: AgentEventDelivery::Queue,
requires_continuation: true,
severity: Some("error".to_string()),
payload_json: serde_json::json!({
"status": "interrupted",
"error": "interrupted by runtime generation handover",
})
.to_string(),
payload_json: completion_payload(
run_id,
agent_id,
task,
None,
"interrupted",
Some("interrupted by runtime generation handover"),
&[],
),
};
insert_event_tx(&mut tx, &event, revision, now).await?;
report.completion_events_generated += 1;
@ -978,61 +964,10 @@ impl super::Storage {
}
}
// 3. Converge group counters from their runs.
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.
// 3. Reconcile per-session capacity counters.
let sessions: Vec<(String, i64, i64)> = sqlx::query_as(
"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_run_groups g WHERE g.root_session_id = s.root_session_id AND g.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_inbox_events e WHERE e.root_session_id = s.root_session_id AND e.status IN ('pending','leased','admitted')) \
FROM agent_session_state s",
)
@ -1116,17 +1051,14 @@ pub(crate) async fn insert_event_tx(
now: i64,
) -> Result<(), StorageError> {
sqlx::query(
"INSERT INTO agent_inbox_events (id, root_session_id, scope_kind, scope_id, run_id, \
group_id, event_type, event_key, delivery, requires_continuation, severity, \
payload_json, status, attempt_count, revision, next_attempt_at, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)",
"INSERT INTO agent_inbox_events (id, root_session_id, run_id, event_type, event_key, \
delivery, requires_continuation, severity, payload_json, status, attempt_count, \
revision, next_attempt_at, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)",
)
.bind(&event.id)
.bind(&event.root_session_id)
.bind(&event.scope_kind)
.bind(&event.scope_id)
.bind(&event.run_id)
.bind(&event.group_id)
.bind(event.event_type.as_str())
.bind(&event.event_key)
.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
/// for a background run that reserved a slot.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn insert_completion_event_tx(
tx: &mut sqlx::SqliteConnection,
run_id: &str,
session_id: &str,
agent_id: &str,
task: &str,
result: Option<&str>,
status: &str,
error: Option<&str>,
signal_ids: &[String],
@ -1161,10 +1097,7 @@ pub(crate) async fn insert_completion_event_tx(
let event = NewInboxEvent {
id: uuid::Uuid::new_v4().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()),
group_id: None,
event_type: AgentEventType::Completion,
event_key: format!("completion:{run_id}"),
delivery: AgentEventDelivery::Queue,
@ -1174,28 +1107,115 @@ pub(crate) async fn insert_completion_event_tx(
} else {
None
},
payload_json: serde_json::json!({
"status": status,
"error": error,
"signal_ids": signal_ids,
})
.to_string(),
payload_json: completion_payload(run_id, agent_id, task, result, status, error, signal_ids),
};
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(
events: &[AgentInboxEventRecord],
now: i64,
) -> crate::bus::ChatMessage {
let mut content = String::from(
"后台 Agent 任务已经完成。请结合以下结果继续当前对话,向用户呈现最相关的部分;\
",
"后台 Agent 任务已经完成,以下是完成结果。请结合这些结果继续当前对话,向用户呈现最相关的部分;\
",
);
for event in events {
content.push_str("\n\n- ");
content.push_str(&event.payload_json);
let payload: serde_json::Value =
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);
message.client_visibility = ClientVisibility::Hidden;
@ -1234,10 +1254,7 @@ mod tests {
NewInboxEvent {
id: uuid::Uuid::new_v4().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()),
group_id: None,
event_type: AgentEventType::Completion,
event_key: format!("completion:{run_id}"),
delivery: AgentEventDelivery::Queue,
@ -1251,7 +1268,6 @@ mod tests {
use crate::storage::agent_run::{AcceptAgentRequest, AgentRunMode, NewAgentRun};
storage
.accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![NewAgentRun {
id: run_id.to_string(),
root_session_id: session.to_string(),
@ -1697,7 +1713,6 @@ mod tests {
};
storage
.accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![run],
now: 10,
})
@ -1760,10 +1775,7 @@ mod tests {
NewInboxEvent {
id: uuid::Uuid::new_v4().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()),
group_id: None,
event_type: AgentEventType::Signal,
event_key: format!("signal:{dedupe_key}:{window}"),
delivery: AgentEventDelivery::Steer,
@ -2034,4 +2046,54 @@ mod tests {
assert_eq!(record.status, AgentEventStatus::Pending);
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;
/// Frozen schema v6 DDL for the Agent orchestration tables. Executed inside
/// the single migration transaction so table creation, column additions and
/// `user_version` advance atomically. The inbox tables belong to Phase 3
/// behavior but their shape is frozen together with the run tables.
/// Frozen DDL for the Agent orchestration tables. Executed inside the single
/// migration transaction so table creation, column additions and `user_version`
/// advance atomically. The inbox table belongs to Phase 3 behavior but its
/// shape is frozen together with the run tables.
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#"
CREATE TABLE IF NOT EXISTS agent_runs (
id TEXT PRIMARY KEY,
group_id TEXT,
root_session_id TEXT NOT NULL,
root_turn_id TEXT,
parent_run_id TEXT,
@ -64,8 +30,6 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
budget_json TEXT NOT NULL,
signal_contract_json TEXT,
signal_delivery TEXT,
completion_delivery TEXT,
failure_delivery TEXT,
status TEXT NOT NULL,
result TEXT,
error TEXT,
@ -88,7 +52,6 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
'failed', 'timed_out', 'cancelled', 'interrupted')),
CHECK (depth >= 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
)
"#,
@ -113,10 +76,7 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
CREATE TABLE IF NOT EXISTS agent_inbox_events (
id TEXT PRIMARY KEY,
root_session_id TEXT NOT NULL,
scope_kind TEXT NOT NULL,
scope_id TEXT NOT NULL,
run_id TEXT,
group_id TEXT,
run_id TEXT NOT NULL,
event_type TEXT NOT NULL,
event_key TEXT NOT NULL,
delivery TEXT NOT NULL,
@ -138,21 +98,13 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
fallback_notified_at INTEGER,
fallback_suppressed_reason TEXT,
updated_at INTEGER NOT NULL,
CHECK (scope_kind IN ('run', 'group')),
CHECK (event_type IN ('signal', 'completion', 'group_completion')),
CHECK (event_type IN ('signal', 'completion')),
CHECK (delivery IN ('queue', 'steer')),
CHECK (requires_continuation IN (0, 1)),
CHECK (status IN ('pending', 'leased', 'admitted', 'consumed',
'superseded', 'dead_letter')),
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)
),
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
UNIQUE(run_id, event_type, event_key),
FOREIGN KEY (run_id) REFERENCES agent_runs(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)",
@ -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)]
pub enum AgentRunStatus {
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)]
pub struct AgentRunRecord {
pub id: String,
pub group_id: Option<String>,
pub root_session_id: String,
pub root_turn_id: Option<String>,
pub parent_run_id: Option<String>,
@ -352,8 +207,6 @@ pub struct AgentRunRecord {
pub budget_json: String,
pub signal_contract_json: Option<String>,
pub signal_delivery: Option<String>,
pub completion_delivery: Option<String>,
pub failure_delivery: Option<String>,
pub status: AgentRunStatus,
pub result: Option<String>,
pub error: Option<String>,
@ -404,39 +257,19 @@ pub struct NewAgentRun {
pub completion_slot_reserved: bool,
}
/// Group header for batch admission. Single-task requests must not create a
/// group; their idempotency key lives on the run row instead.
#[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,
}
/// Batch admission request. Each run carries its own idempotency key; a
/// single-task request is just a one-element batch.
#[derive(Debug, Clone)]
pub struct AcceptAgentRequest {
pub group: Option<NewAgentGroup>,
pub runs: Vec<NewAgentRun>,
pub now: i64,
}
#[derive(Debug)]
pub enum AcceptedAgentRuns {
Accepted {
group: Option<AgentRunGroupRecord>,
runs: Vec<AgentRunRecord>,
},
/// Idempotent retry: the group/run already existed for this key.
Existing {
group: Option<AgentRunGroupRecord>,
runs: Vec<AgentRunRecord>,
},
Accepted { runs: Vec<AgentRunRecord> },
/// Idempotent retry: the run already existed for this key.
Existing { runs: Vec<AgentRunRecord> },
}
/// 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)]
pub struct TerminalCommit {
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, \
provider_profile, provider_name, model_id, mode, depth, plan_item_id, execution_id, \
task, context_json, budget_json, signal_contract_json, signal_delivery, \
completion_delivery, failure_delivery, status, result, error, prompt_tokens, \
completion_tokens, cost, tool_calls_count, iterations, runtime_generation, attempt, \
completion_slot_reserved, deadline_at, revision, started_at, finished_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";
status, result, error, prompt_tokens, completion_tokens, cost, tool_calls_count, \
iterations, runtime_generation, attempt, completion_slot_reserved, deadline_at, \
revision, started_at, finished_at, created_at, updated_at";
fn run_record_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<AgentRunRecord, StorageError> {
Ok(AgentRunRecord {
id: row.get("id"),
group_id: row.get("group_id"),
root_session_id: row.get("root_session_id"),
root_turn_id: row.get("root_turn_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"),
signal_contract_json: row.get("signal_contract_json"),
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"))?,
result: row.get("result"),
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 {
/// Admit a group (optional) and its runs in one transaction, claiming any
/// referenced plan items atomically. If any plan item was already taken
/// the whole admission rolls back so a run can never diverge from the
/// plan it claims to execute.
/// Admit a batch of runs in one transaction, claiming any referenced
/// plan items atomically. If any plan item was already taken the whole
/// admission rolls back so a run can never diverge from the plan it
/// claims to execute.
pub async fn accept_agent_runs(
&self,
request: AcceptAgentRequest,
@ -601,39 +393,9 @@ impl super::Storage {
}
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 {
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, \
agent_id, definition_hash, provider_profile, provider_name, model_id, \
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, \
revision, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
?, ?, 'queued', ?, 1, ?, ?, 0, ?, ?)",
?, 'queued', ?, 1, ?, ?, 0, ?, ?)",
)
.bind(&run.id)
.bind(request.group.as_ref().map(|group| group.id.clone()))
.bind(&run.root_session_id)
.bind(&run.root_turn_id)
.bind(&run.parent_run_id)
@ -699,13 +460,7 @@ impl super::Storage {
StorageError::NotFound(format!("agent run {} vanished after admission", run.id))
})?);
}
let group = match request.group.as_ref() {
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 })
Ok(AcceptedAgentRuns::Accepted { runs })
}
async fn existing_agent_admission(
@ -718,16 +473,12 @@ impl super::Storage {
runs.push(record);
}
}
let group = match request.group.as_ref() {
Some(group) => self.get_agent_run_group(&group.id).await?,
None => None,
};
if runs.is_empty() && group.is_none() {
if runs.is_empty() {
return Err(StorageError::Conflict(
"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(
@ -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)`.
/// The cursor is the pair of the last row the client has seen.
pub async fn list_agent_runs(
@ -834,18 +569,6 @@ impl super::Storage {
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.
pub async fn mark_agent_run_running(
@ -976,11 +699,12 @@ impl super::Storage {
.execute(&mut *tx)
.await?;
if reserved {
let session: String =
sqlx::query_scalar("SELECT root_session_id FROM agent_runs WHERE id = ?")
.bind(run_id)
.fetch_one(&mut *tx)
.await?;
let (session, agent_id, task): (String, String, String) = sqlx::query_as(
"SELECT root_session_id, agent_id, task FROM agent_runs WHERE id = ?",
)
.bind(run_id)
.fetch_one(&mut *tx)
.await?;
let revision: i64 = sqlx::query_scalar(
"UPDATE agent_session_state \
SET reserved_completion_slots = MAX(reserved_completion_slots - 1, 0), \
@ -994,33 +718,34 @@ impl super::Storage {
let event = super::agent_inbox::NewInboxEvent {
id: uuid::Uuid::new_v4().to_string(),
root_session_id: session,
scope_kind: "run".to_string(),
scope_id: run_id.to_string(),
run_id: Some(run_id.to_string()),
group_id: None,
event_type: super::agent_inbox::AgentEventType::Completion,
event_key: format!("completion:{run_id}"),
delivery: super::agent_inbox::AgentEventDelivery::Queue,
requires_continuation: !suppress_continuation,
severity: Some("warning".to_string()),
payload_json: serde_json::json!({ "status": "cancelled", "error": reason })
.to_string(),
payload_json: super::agent_inbox::completion_payload(
run_id,
&agent_id,
&task,
None,
"cancelled",
Some(reason),
&[],
),
};
if suppress_continuation {
// Directly consumed: pending count never grows.
sqlx::query(
"INSERT INTO agent_inbox_events (id, root_session_id, scope_kind, scope_id, \
run_id, group_id, event_type, event_key, delivery, requires_continuation, \
severity, payload_json, status, attempt_count, revision, next_attempt_at, \
created_at, updated_at, consumed_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'consumed', 0, ?, NULL, ?, ?, ?)",
"INSERT INTO agent_inbox_events (id, root_session_id, run_id, event_type, \
event_key, delivery, requires_continuation, severity, payload_json, \
status, attempt_count, revision, next_attempt_at, created_at, \
updated_at, consumed_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'consumed', 0, ?, NULL, ?, ?, ?)",
)
.bind(&event.id)
.bind(&event.root_session_id)
.bind(&event.scope_kind)
.bind(&event.scope_id)
.bind(&event.run_id)
.bind(&event.group_id)
.bind(event.event_type.as_str())
.bind(&event.event_key)
.bind(event.delivery.as_str())
@ -1135,50 +860,6 @@ impl super::Storage {
.await?;
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() {
finish_plan_item(
&mut tx,
@ -1196,29 +877,44 @@ impl super::Storage {
// reservation into a durable completion event in the same commit.
// The event survives restarts, queue-full conditions and lost wakes.
if run.completion_slot_reserved {
let (status, error, signal_ids) = match outcome {
AgentTerminalOutcome::Completed { signal_ids, .. } => {
("completed", None, signal_ids.as_slice())
}
let (status, error, signal_ids, result) = match outcome {
AgentTerminalOutcome::Completed {
result, signal_ids, ..
} => (
"completed",
None,
signal_ids.as_slice(),
Some(result.as_str()),
),
AgentTerminalOutcome::Failed {
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, .. } => (
"timed_out",
Some("deadline exceeded"),
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(
&mut tx,
&run.id,
&run.root_session_id,
&run.agent_id,
&run.task,
result,
status,
error,
signal_ids,
@ -1228,11 +924,7 @@ impl super::Storage {
}
tx.commit().await?;
Ok(Some(TerminalCommit {
run,
group,
group_finished,
}))
Ok(Some(TerminalCommit { run }))
}
}
@ -1369,15 +1061,14 @@ mod tests {
}
#[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 version: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(version, 6);
assert_eq!(version, 8);
for table in [
"agent_run_groups",
"agent_runs",
"agent_session_state",
"agent_inbox_events",
@ -1398,7 +1089,6 @@ mod tests {
let (storage, _dir) = create_test_storage().await;
let accepted = storage
.accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
now: 100,
})
@ -1408,98 +1098,14 @@ mod tests {
let run = storage.get_agent_run("run-1").await.unwrap().unwrap();
assert_eq!(run.status, AgentRunStatus::Queued);
assert!(run.group_id.is_none());
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]
async fn stale_execution_cannot_commit_terminal() {
let (storage, _dir) = create_test_storage().await;
storage
.accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
now: 100,
})
@ -1537,7 +1143,6 @@ mod tests {
let (storage, _dir) = create_test_storage().await;
storage
.accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
now: 100,
})
@ -1593,7 +1198,6 @@ mod tests {
run.plan_item_id = Some("T1".to_string());
storage
.accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![run.clone()],
now: 100,
})
@ -1610,7 +1214,6 @@ mod tests {
run.execution_id = "exec-2".to_string();
let error = storage
.accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![run],
now: 110,
})
@ -1661,7 +1264,6 @@ mod tests {
}
storage
.accept_agent_runs(AcceptAgentRequest {
group: None,
runs,
now: 100,
})
@ -1692,7 +1294,6 @@ mod tests {
let (storage, _dir) = create_test_storage().await;
storage
.accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
now: 100,
})
@ -1723,7 +1324,6 @@ mod tests {
run.completion_slot_reserved = true;
storage
.accept_agent_runs(AcceptAgentRequest {
group: None,
runs: vec![run],
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_run;
pub mod background_task;
pub mod error;
pub mod memory;
pub mod message;
@ -8,7 +7,6 @@ pub mod scheduler;
pub mod session;
pub mod usage;
pub use background_task::BackgroundTask;
pub use error::StorageError;
pub use scheduler::{DeliveryPolicy, JobKind, JobRun, ScheduledJob};
pub use usage::{SessionUsageTotals, TurnUsageRecord};
@ -20,7 +18,7 @@ use sqlx::{Pool, Row, Sqlite};
use std::path::Path;
use tokio::time::{Duration, sleep};
const SCHEMA_VERSION: i64 = 6;
const SCHEMA_VERSION: i64 = 8;
const INSERT_MESSAGE_SQL: &str = r#"
INSERT INTO messages (
id, session_id, seq, role, content, reasoning_content, provider_state,
@ -192,48 +190,6 @@ impl Storage {
.execute(&self.pool)
.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,
// while independent items can be executed concurrently.
sqlx::query(
@ -439,6 +395,25 @@ impl Storage {
}
let mut tx = self.pool.begin().await?;
// Legacy table removed in schema v7; drop it so old databases do not
// keep dead rows around.
sqlx::query("DROP TABLE IF EXISTS background_tasks")
.execute(&mut *tx)
.await?;
// Schema v8 removes the batch "group" concept entirely: the
// `agent_run_groups` table is gone, and the run/inbox tables are
// rebuilt without their `group_id`/`scope_kind`/`scope_id` columns.
// Drop in dependency order (inbox -> runs -> groups) so foreign-key
// enforcement never blocks the implicit row delete.
sqlx::query("DROP TABLE IF EXISTS agent_inbox_events")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_runs")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_run_groups")
.execute(&mut *tx)
.await?;
for (table, column, definition) in [
("messages", "source", "source TEXT"),
("messages", "reasoning_content", "reasoning_content TEXT"),
@ -1459,149 +1434,6 @@ impl Storage {
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
/// channel-declared reusable values (thread/root identity) ever reach
/// this column; one-shot reply/reaction ids never do.
@ -1637,17 +1469,6 @@ impl Storage {
.await?;
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)]
@ -1796,42 +1617,6 @@ mod tests {
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]
async fn webui_lists_and_filters_memories_without_search_text() {
let (storage, _dir) = create_test_storage().await;
@ -1997,7 +1782,6 @@ mod tests {
"task_plans",
"task_items",
"session_turn_usage",
"agent_run_groups",
"agent_runs",
"agent_session_state",
"agent_inbox_events",

View File

@ -5,7 +5,7 @@ use serde_json::{Value, json};
use crate::agent::AgentCoordinator;
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;
@ -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."
}
fn delegation_policy(&self) -> DelegationPolicy {
DelegationPolicy::RuntimeInjected
fn runtime_injected(&self) -> bool {
true
}
fn parameters_schema(&self) -> Value {
@ -199,7 +199,6 @@ impl AgentTaskTool {
fn run_projection(run: &AgentRunRecord, include_result_preview: bool) -> Value {
let mut value = json!({
"run_id": run.id,
"group_id": run.group_id,
"agent_id": run.agent_id,
"status": run.status.as_str(),
"mode": run.mode.as_str(),

View File

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

View File

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

View File

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

View File

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

View File

@ -47,8 +47,8 @@ impl Tool for ScopedDelegateTool {
self.inner.read_only()
}
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
crate::tools::DelegationPolicy::RuntimeInjected
fn runtime_injected(&self) -> bool {
true
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
@ -155,7 +155,11 @@ impl DelegateTool {
args: &Value,
context: &ToolExecutionContext,
) -> 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,
"background" => ExecutionMode::Background,
other => {
@ -250,11 +254,6 @@ impl DelegateTool {
})
}
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() {
return Ok(failure(
"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)",
));
};
let config = configs.into_iter().next().expect("checked non-empty");
match coordinator.delegate_background(context, config).await {
Ok(run_id) => Ok(success(json!({
"status": "accepted",
"runs": vec![json!({ "run_id": run_id, "status": "queued" })]
}))),
match coordinator.delegate_background(context, configs).await {
Ok(admission) => {
let runs: Vec<_> = admission
.run_ids
.into_iter()
.map(|run_id| json!({ "run_id": run_id, "status": "queued" }))
.collect();
Ok(success(json!({
"status": "accepted",
"runs": runs
})))
}
Err(error) => Ok(failure(error.to_string())),
}
}
}
}
}
#[async_trait]
@ -327,8 +331,8 @@ impl Tool for DelegateTool {
false
}
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
crate::tools::DelegationPolicy::RuntimeInjected
fn runtime_injected(&self) -> bool {
true
}
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::run::{AgentExecutionContext, EmittedSignal};
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)]
pub struct SignalInput {
@ -117,8 +117,8 @@ impl Tool for EmitSignalTool {
})
}
fn delegation_policy(&self) -> DelegationPolicy {
DelegationPolicy::RuntimeInjected
fn runtime_injected(&self) -> bool {
true
}
async fn execute_with_context(
@ -302,10 +302,7 @@ pub fn build_signal_event(
NewInboxEvent {
id: event_id,
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()),
group_id: context.group_id.clone(),
event_type: AgentEventType::Signal,
event_key: input.event_key.clone(),
delivery,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,10 +36,6 @@ fn parse_seconds(args: &serde_json::Value) -> Result<u64, String> {
#[async_trait]
impl Tool for SleepTool {
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
crate::tools::DelegationPolicy::Delegatable
}
fn input_interrupt_policy(&self) -> crate::tools::InputInterruptPolicy {
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 的下一个安全边界注入。"
));
}
Some(WakeupSource::AgentGroupCompletion { group_id }) => {
message.push_str(&format!(
" 收到一条 steer AgentGroupCompletiongroup={group_id}),将在当前 Turn 的下一个安全边界注入。"
));
}
Some(WakeupSource::AgentQueue) | None => {
message.push_str(&format!(
" 收到 {} 条排队输入。内容不会进入当前 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)]
pub enum InputInterruptPolicy {
Never,
@ -218,10 +211,14 @@ pub trait Tool: Send + Sync + 'static {
fn parameters_schema(&self) -> serde_json::Value;
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
/// Whether a named Agent definition may receive this tool. New tools fail
/// closed until their delegated behavior has been reviewed explicitly.
fn delegation_policy(&self) -> DelegationPolicy {
DelegationPolicy::RootOnly
/// Whether this tool is injected at runtime from the caller context
/// (delegate targets, signal contract, skill allowlist) and therefore
/// must never be declared directly in an Agent definition's `tools`
/// 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.

View File

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

View File

@ -1,12 +1,12 @@
{
"name": "picobot-webui",
"version": "1.8.0",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picobot-webui",
"version": "1.8.0",
"version": "1.11.0",
"dependencies": {
"bits-ui": "^2.0.0",
"dompurify": "^3.4.12",
@ -469,7 +469,7 @@
}
},
"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",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
@ -478,7 +478,7 @@
}
},
"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",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",

View File

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

View File

@ -9,6 +9,7 @@
import ActivitySpine from "./lib/components/ActivitySpine.svelte";
import ChatPage from "./pages/ChatPage.svelte";
import TasksPage from "./pages/TasksPage.svelte";
import AgentsPage from "./pages/AgentsPage.svelte";
import MemoryPage from "./pages/MemoryPage.svelte";
import LogsPage from "./pages/LogsPage.svelte";
import SettingsPage from "./pages/SettingsPage.svelte";
@ -20,6 +21,7 @@
{ name: "chat", label: "聊天", description: "与 PicoBot 协作并管理会话" },
{ name: "overview", label: "概览", description: "查看运行状态与系统容量" },
{ name: "tools", label: "工具", description: "浏览工具、Skills 与 MCP 连接" },
{ name: "agents", label: "子代理", description: "管理具名子代理定义" },
{ name: "logs", label: "日志", description: "检查实时事件与运行记录" },
{ name: "memory", 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"/>',
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"/>',
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"/>',
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"/>',
@ -150,6 +153,7 @@
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
{:else if current === "overview"}<OverviewPage />
{:else if current === "tools"}<ToolsPage />
{:else if current === "agents"}<AgentsPage notify={(text, error) => toast.show(text, error)} />
{:else}<div class="empty-card">即将上线</div>{/if}
</main>
</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)";
}
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(() => {
load();
const timer = setInterval(() => { tick += 1; }, 30000);
@ -117,40 +91,28 @@
{#if loading}<div class="loading">加载中…</div>
{:else if error}<div class="empty-card error-text">{error}</div>
{:else if tab === "background"}
{#each groupTasks(tasks) as group (group.key)}
{#if group.runs.length === 0}
<div class="empty-card">暂无后台任务</div>
{:else}
<article class="card">
<div class="card-row">
<div>
<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>
{#each tasks as task (task.id)}
<article class="card">
<div class="card-row">
<div class="task-main">
<div class="run-row">
<span class="pulse" class:visible={task.status === "running"}></span>
<span class="agent-tag">{task.agent_id || "general"}</span>
<span class="run-prompt">{task.prompt?.slice(0, 120) || ""}</span>
<StatusBadge status={task.status} />
</div>
<div class="meta">
<span>{task.session_id}</span>
<span>{formatTime(task.created_at)}</span>
{#if task.status === "running"}<span class="elapsed">{elapsed(task.created_at)}</span>{/if}
<span>{task.tool_calls_count} 次工具调用 · {task.iterations}</span>
</div>
<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">
<span class="pulse" class:visible={task.status === "running"}></span>
<span class="agent-tag">{task.agent_id || "general"}</span>
<span class="run-prompt">{task.prompt.slice(0, 80)}</span>
<StatusBadge status={task.status} />
<span class="meta">{task.tool_calls_count} 次工具调用 · {task.iterations}</span>
</div>
{#if task.result}<div class="details"><p>{task.result.slice(0, 300)}</p></div>{/if}
{#if task.error}<p class="error-text">{task.error.slice(0, 200)}</p>{/if}
</div>
{/each}
</div>
</article>
{/if}
{/each}
</div>
{#if task.result}<div class="details"><p>{task.result.slice(0, 300)}</p></div>{/if}
{#if task.error}<p class="error-text">{task.error.slice(0, 200)}</p>{/if}
</article>
{:else}<div class="empty-card">暂无后台任务</div>{/each}
{:else}
{#each jobs as job (job.id)}
<article class="card">
@ -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; }
.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; }
.run-tree { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
.run-node { margin-left: calc(var(--depth) * 18px); border-left: 1px solid var(--line); padding-left: 10px; }
.task-main { flex: 1; min-width: 0; }
.run-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.agent-tag { font-size: 11px; color: var(--accent); background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; padding: 0 6px; font-family: var(--font-mono); }
.run-prompt { flex: 1 1 200px; min-width: 120px; }