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:
parent
78d6e29672
commit
5501c539fc
@ -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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "1.8.0"
|
||||
version = "1.11.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -408,7 +408,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
||||
|
||||
### 具名子 Agent(Phase 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)。
|
||||
|
||||
|
||||
7
build.rs
7
build.rs
@ -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);
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
},
|
||||
"gateway": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 19876,
|
||||
"port": 19877,
|
||||
"require_pairing": true
|
||||
},
|
||||
"channels": {},
|
||||
|
||||
@ -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 后台任务都应通过它注册。
|
||||
|
||||
两种注册方式:
|
||||
|
||||
|
||||
@ -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 result;background `each` 将每个 outcome 物化为 run completion inbox event,`all` 只在 group 终态时物化一个 group completion inbox event。
|
||||
最终结果不能依赖模型记得调用工具。即使 Provider 异常、超时或任务被取消,Coordinator 也必须持久化 run 的终态 outcome。Foreground 将其返回为 tool result;background 的每个 run 终态都物化为一个独立的 run completion inbox event(无 all/each 策略,批量也只是逐 run 生成)。
|
||||
|
||||
### 12.2 EmitSignalTool
|
||||
|
||||
@ -550,7 +541,7 @@ Coordinator 强制执行:
|
||||
|
||||
### 12.3 Completion 去重
|
||||
|
||||
run completion payload 包含本 run 已发出的 signal IDs;group 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 outcome,Coordinator 保存结果并按 foreground/background 与 group policy 创建相应投递 event,避免模型遗漏或重复。
|
||||
Completion 不是工具。AgentRunner 的终结路径统一返回 terminal outcome,Coordinator 保存结果并按 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 projection:Provider 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` event;deadline 到达时先把未终态 child 条件更新为 `timed_out`。`completion_policy=each` 则在每个 run 终态时立即创建独立 completion event,不等待 sibling;Router 可通过 300–500ms 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 接纳先增加 reservation;signal 只有在 `pending + reserved < limit` 时增加 pending;completion 将 reservation 原子转换为 pending;consume/dead-letter 减少 pending。启动恢复会以事件与 run/group 事实重算计数,发现差异时修复并记录告警。
|
||||
容量判断不能在每次接纳时通过无锁 `COUNT(*)` 推断。新增每 root session 一行的 `agent_session_state`,在同一 SQLite 写事务中以条件 `UPDATE` 维护 `pending_event_count`、`reserved_completion_slots` 和单调 `revision`。background 接纳先增加 reservation;signal 只有在 `pending + reserved < limit` 时增加 pending;completion 将 reservation 原子转换为 pending;consume/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 加冷却窗口 ID;run 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 加冷却窗口 ID;run 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 2A:AgentLoop 结构化取消
|
||||
|
||||
@ -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 wake;wake revision 丢失后扫描可恢复。
|
||||
- 用户持续输入时 burst/age 公平上限仍调度 continuation。
|
||||
- completion capacity 在 background 接纳时预留,signal 不能抢占。
|
||||
- `each` 逐 run 提前投递;`all` 只触发一次 group 汇总 Turn。
|
||||
- 每个 run 独立 completion,逐 run 投递;无 group 汇总 Turn。
|
||||
- retries/TTL 耗尽进入 dead-letter,system fallback 最多发送一次。
|
||||
|
||||
### 25.5 Signal
|
||||
@ -1332,7 +1279,7 @@ src/storage/
|
||||
3. Foreground/Background 只描述委托方等待行为;并发是独立调度维度。
|
||||
4. Queue 输入永不泄漏正文到当前 Turn;Steer 只在安全边界注入。
|
||||
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
@ -129,7 +129,7 @@ Provider stream、可取消等待和工具批次外层都观察 token;AgentRun
|
||||
事件进入 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、可取消等待和工具批次外层都观察 token;AgentRun
|
||||
|-------|------------------|
|
||||
| 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 sleep;sub-run 保持 timer/cancellation-only |
|
||||
|
||||
@ -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 设备配对、启动客户端、运行测试 |
|
||||
|
||||
@ -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 + Vite,Bits 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` key,Definition 绑定 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 信号经两阶段 admission(claim → 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 关停时先收到取消信号,再在总宽限期内清理。
|
||||
|
||||
|
||||
@ -72,7 +72,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
||||
| `max_user_turn_burst_before_inbox` | 4 | 用户 Turn 公平调度阈值 |
|
||||
| `max_inbox_wait_secs` | 30 | inbox 最大等待阈值 |
|
||||
|
||||
已实现:具名 foreground 与 background(Root 单任务)、不同 `llm_profile`、固定工具/Skill allowlist、批量并发、父子委托边校验、durable inbox continuation、`emit_signal`(queue/steer)、run quota 与 step gate。未开放:background 批量、子 Agent 发起的 background、`idempotency_key` 工具入口。未启用编排时旧 general background 兼容路径保持可用(带迁移提示,等待一个版本观察后移除)。
|
||||
已实现:具名 foreground 与 background(Root 单任务或批量)、内联 `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 字段
|
||||
|
||||
@ -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 v8,Agent 编排)
|
||||
|
||||
旧 general(无 target)delegate 后台子任务表,由 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 v6,Agent 编排)
|
||||
|
||||
批量委托的组头。单任务委托不建组;`completion_policy` 决定 background 完成事件形态(当前 background 批量未开放,组仅用于批量 foreground)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | TEXT PK | 组 ID |
|
||||
| `root_session_id` | TEXT | 根会话(软删除,无级联外键,由应用层收敛) |
|
||||
| `caller_run_id` | TEXT | 发起方 run ID(NULL 表示 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 | 组完成/失败投递 lane(queue/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 v6,Agent 编排)
|
||||
|
||||
每次具名委托(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 | 父 run(RESTRICT),NULL 表示 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 信号契约快照与投递 lane(queue/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 v6,Agent 编排)
|
||||
## agent_session_state 表(schema v8,Agent 编排)
|
||||
|
||||
每根会话一行,inbox 容量与客户端 revision 的权威计数:
|
||||
|
||||
@ -142,16 +99,16 @@
|
||||
|
||||
容量判断在同一写事务内做条件 `UPDATE`(`pending + reserved + 新增 <= 上限`),杜绝并发 `COUNT(*)` 漂移。
|
||||
|
||||
## agent_inbox_events 表(schema v6,Agent 编排)
|
||||
## agent_inbox_events 表(schema v8,Agent 编排)
|
||||
|
||||
background 完成/信号投递的唯一事实源:`pending → leased → admitted → consumed`,失败按 token 释放回 pending,超限进 dead-letter,崩溃靠 lease 过期恢复。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | TEXT PK |
|
||||
| `root_session_id` / `scope_kind` / `scope_id` | 归属(run 或 group,CHECK 互斥) |
|
||||
| `run_id` / `group_id` | TEXT FK(RESTRICT) |
|
||||
| `event_type` | signal / completion / group_completion |
|
||||
| `root_session_id` | TEXT | 根会话 |
|
||||
| `run_id` | TEXT FK NOT NULL(RESTRICT) |
|
||||
| `event_type` | signal / completion |
|
||||
| `event_key` | 去重键(signal 含冷却窗口 id) |
|
||||
| `delivery` | queue / steer |
|
||||
| `requires_continuation` | 是否反向启动 continuation Turn(cancel 产物为 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 表
|
||||
|
||||
|
||||
@ -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 查询与控制
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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 })))
|
||||
}
|
||||
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")];
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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,
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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()
|
||||
));
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 AgentCompletion(run_id={run_id}, agent={agent_id}),将在当前 Turn 的下一个安全边界注入。"
|
||||
));
|
||||
}
|
||||
Some(WakeupSource::AgentGroupCompletion { group_id }) => {
|
||||
message.push_str(&format!(
|
||||
" 收到一条 steer AgentGroupCompletion(group={group_id}),将在当前 Turn 的下一个安全边界注入。"
|
||||
));
|
||||
}
|
||||
Some(WakeupSource::AgentQueue) | None => {
|
||||
message.push_str(&format!(
|
||||
" 收到 {} 条排队输入。内容不会进入当前 Turn,将在当前工作结束后的下一 Turn处理。",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
8
webui/package-lock.json
generated
8
webui/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"private": true,
|
||||
"version": "1.8.0",
|
||||
"version": "1.11.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@ -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>
|
||||
|
||||
306
webui/src/pages/AgentsPage.svelte
Normal file
306
webui/src/pages/AgentsPage.svelte
Normal 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 你是一名…"></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>
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user