From 5501c539fcd8935bdd6c8b746d644117dc6ca244 Mon Sep 17 00:00:00 2001 From: xiaoxixi Date: Thu, 13 Aug 2026 14:03:01 +0800 Subject: [PATCH] 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 --- AGENTS.md | 4 +- Cargo.toml | 2 +- README.md | 4 +- build.rs | 7 +- config.json | 2 +- docs/ARCHITECTURE.md | 8 +- docs/SUB_AGENT_ORCHESTRATION_DESIGN.md | 147 ++--- .../SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md | 147 ++--- ...SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md | 4 +- resources/skills/about-picobot/SKILL.md | 4 +- .../about-picobot/references/architecture.md | 6 +- .../skills/about-picobot/references/config.md | 3 +- .../about-picobot/references/db-schema.md | 61 +- .../skills/about-picobot/references/tools.md | 10 +- resources/templates/config.example.json | 2 +- src/agent/builtin.rs | 5 +- src/agent/catalog.rs | 218 ++++++- src/agent/coordinator.rs | 391 ++++++++----- src/agent/definition.rs | 157 +++-- src/agent/gate.rs | 9 + src/agent/run.rs | 3 - src/agent/steering.rs | 23 - src/agent/sub_agent.rs | 34 +- src/agent/system_prompt.rs | 6 +- src/bus/message.rs | 6 - src/config/mod.rs | 3 - src/gateway/http.rs | 332 +++++++++-- src/gateway/mod.rs | 30 +- src/protocol.rs | 9 - src/session/messenger.rs | 2 - src/session/session.rs | 76 +-- src/storage/agent_inbox.rs | 294 ++++++---- src/storage/agent_run.rs | 550 +++--------------- src/storage/background_task.rs | 19 - src/storage/mod.rs | 256 +------- src/tools/agent_task.rs | 7 +- src/tools/bash.rs | 4 - src/tools/browser/mod.rs | 4 - src/tools/calculator.rs | 4 - src/tools/content_search.rs | 4 - src/tools/delegate.rs | 38 +- src/tools/emit_signal.rs | 9 +- src/tools/file_read.rs | 4 - src/tools/file_search.rs | 4 - src/tools/get_skill.rs | 4 +- src/tools/mod.rs | 6 +- src/tools/registry.rs | 8 +- src/tools/send_message.rs | 1 - src/tools/sleep.rs | 9 - src/tools/traits.rs | 19 +- src/tools/web_fetch.rs | 4 - webui/package-lock.json | 8 +- webui/package.json | 2 +- webui/src/App.svelte | 4 + webui/src/pages/AgentsPage.svelte | 306 ++++++++++ webui/src/pages/TasksPage.svelte | 81 +-- 56 files changed, 1676 insertions(+), 1688 deletions(-) delete mode 100644 src/storage/background_task.rs create mode 100644 webui/src/pages/AgentsPage.svelte diff --git a/AGENTS.md b/AGENTS.md index 94c86a9..23b9f3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Cargo.toml b/Cargo.toml index 5d09491..4efb903 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picobot" -version = "1.8.0" +version = "1.11.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 655b12e..a8b1aff 100644 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/build.rs b/build.rs index be45325..4574ed7 100644 --- a/build.rs +++ b/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); } diff --git a/config.json b/config.json index 48c3e6f..5bedb38 100644 --- a/config.json +++ b/config.json @@ -25,7 +25,7 @@ }, "gateway": { "host": "127.0.0.1", - "port": 19876, + "port": 19877, "require_pairing": true }, "channels": {}, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c4a182f..3f91aee 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 后台任务都应通过它注册。 两种注册方式: diff --git a/docs/SUB_AGENT_ORCHESTRATION_DESIGN.md b/docs/SUB_AGENT_ORCHESTRATION_DESIGN.md index 6ab48b7..7d5f7d8 100644 --- a/docs/SUB_AGENT_ORCHESTRATION_DESIGN.md +++ b/docs/SUB_AGENT_ORCHESTRATION_DESIGN.md @@ -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, pub run_id: String, - pub group_id: Option, pub parent_run_id: Option, 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:`,有 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:`,有 dedupe key 的 signal 加冷却窗口 ID;run completion 固定为 `completion:`。由同一次 `/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, group_id: Option }, + BackgroundAgentResults { event_ids: Vec }, 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 返回 diff --git a/docs/SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md b/docs/SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md index 03db8d0..ee00ed2 100644 --- a/docs/SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md +++ b/docs/SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md @@ -6,7 +6,7 @@ > > 本文不是“代码已经实现”的声明。实现完成前,运行时事实仍以当前代码、测试和 [`ARCHITECTURE.md`](ARCHITECTURE.md) 为准。 -> 实施进度(2026-08):Phase 1 已落地具名 Definition/Catalog、不同 Provider profile、工具/Skill fail-closed 裁剪、显式 `AgentExecutionContext`、父子委托边与 ancestry 校验、canonical `foreground/background` schema 以及批量 foreground 并发。旧 general background 仅作为兼容路径保留。Phase 2A 已落地结构化取消:`AgentError::Cancelled/TimedOut`、CancellationToken 贯穿 root Turn、Provider 连接/stream、并行与串行工具批次以及 sleep;`/stop` 保留 oneshot 兼容桥接并同时取消 Turn token,协作式与强制路径提交同一 Cancelled 终态;树级 `max_runs_per_tree` 由共享原子计数在 foreground 委托接纳时强制。Phase 2B 已落地 durable foreground 编排:schema v6(`agent_run_groups`/`agent_runs`/`agent_session_state`/`agent_inbox_events` 与 messages/sessions 扩展列)在单一迁移事务中原子创建;Storage 领域 API(接纳、running/waiting_children 条件转换、execution-ID 条件 terminal commit、plan item 原子领取/完成、游标分页);`ExecutionGate` 分离 run quota 与 provider/tool step gate(global→session 顺序获取、弱引用键控回收、取消可中断等待),root Turn 步骤同样占用 step gate;`AgentCoordinator` 持久化全部具名 foreground run(单任务不建 group、批量建 group 并按请求顺序返回)、父 run `waiting_children` 转换、迟到结果丢弃与树位置授权;`agent_task` 工具提供 scoped get/list/get_result/cancel。foreground 不占用 run quota,嵌套 foreground 在并发上限为 1 时不死锁。Phase 3 已落地 durable inbox 与 queue continuation:具名 background 单任务经 Coordinator 接纳(completion slot 预留 → 持久化 queued → TaskSupervisor 托管 runner → terminal commit 原子转换 reservation 为 completion 事件 → notifier wake),spawn 拒绝执行补偿事务;`agent_inbox_events`/`agent_session_state` 容量条件更新与 claim/lease/admit/release/supersede/dead-letter API;`AgentInboxNotifier`(弱引用 late-bound wake)与 Session 双 lane worker(user mpsc + inbox watch 合并 wake),公平调度按 `max_user_turn_burst_before_inbox` 与 `max_inbox_wait_secs` 强制 continuation;continuation Turn 使用 hidden trigger、只读工具集,`commit_continuation_turn` 同事务提交 hidden trigger + assistant/tool/usage + event consume,失败显式 release lease;`client_visibility`/`turn_origin` 贯穿 ChatMessage/MessageMeta/协议 DTO,客户端历史查询默认过滤 hidden,`message_count` 只统计可见用户输入;activation recovery 收敛旧代 run(interrupted + failure completion)、过期 lease、group counter 与容量计数;`/stop`/archive/delete 走 `cancel_session`(suppress_continuation 写 consumed completion)并 dead-letter。background completion 不再直接通知 Channel。Phase 3 审查修复:worker 通过 `next_pending_due_at` 定时器在 release backoff 到期后重新 claim(不再依赖 wake);达到 `max_inbox_delivery_attempts` 的事件在正常运行中即 dead-letter 而非无限重试;`recover_on_activation` 对含 due 事件的在内存 session 发送合并 wake;修正 MIN 聚合无行时 NULL 被解码为 0 导致 worker 空转的缺陷(`oldest_pending_due`/`next_pending_due_at` 用 `Option>` 显式解码)。Phase 4 已落地 emit_signal 与 steer:Agent Definition frontmatter 新增 `signal:` 块(`SignalContract`:delivery queue/steer、总数/字节/间隔/burst/severity allowlist/dedupe 冷却窗/JSON 深度,全部由工具与 Coordinator 强制,模型只提供 key/severity/summary/details/dedupe_key);`EmitSignalTool` 仅在带 contract 的 run 注册(fail-closed),`insert_agent_signal` 支持同冷却窗 dedupe(返回原 event + deduplicated);Coordinator `emit_signal` 校验 run 活跃与 execution ID、容量条件插入、投影+notifier wake,取消 run 时未消费 signal 自动 supersede;SteeringMailbox 泛化为来源感知 TurnMailbox(user lane 32/64KiB 与 agent lane 8/32KiB 独立容量,`TurnInput{source,delivery,durable_event_id,lease_token}`,agent steer 投影为 hidden user 消息保留 source 元数据);steer 两阶段 admission(claim → mailbox 预留 → DB admit(turn_id) → 同 Turn/generation 激活),任何失败 release lease 并 wake queue lane;`/stop`/generation 变更时已 admit 的 steer 事件按 lease token 条件释放回 pending(绝不静默丢弃),用户 Turn commit 与 steer 事件 consume 同事务(`persist_turn_batch_with_steer_consumption`)。Phase 3 收尾已落地:`ChannelContext.durable_private`(Feishu 仅 thread/root/chat_type 进入)持久化到 `sessions.delivery_context` 并被 continuation 投递复用;WS 协议 `GetAgentRuns`/`GetAgentRun` 与 `SessionAgentRuns`/`AgentRunUpdated`/`AgentEventUpdated`(有界 `AgentRunView`/`AgentEventView`,不暴露 budget/contract/delivery context/execution id);`AgentProjectionHub` broadcast(复用 plan-change 模式,lag 由客户端 GetAgentRuns 校准);HTTP `/api/agent-runs*`(列表游标分页/详情/events/cancel)+ `/api/tasks` legacy/new union;WebUI TasksPage 后台 tab 渲染 run tree(group 折叠、agent 标签、深度缩进),ChatPage 显示 continuation 标签与 Signal 卡片(投影事件,不插入 history)。Phase 5 已落地 wake-aware sleep:`TurnWakeupPublisher`/`TurnWakeupHandle` 随 active Turn 创建(root interactive Turn 专用,sub-run 与 continuation 永远没有 handle);watch revision 单调,四个 admission 点(用户进 TurnMailbox、用户进 next-turn mpsc、Agent steer durable admitted、Agent event 保持 pending 且 wake Session)在事实可见后 publish(先更新状态再发 wake,sleep 醒来必能查到输入);`SleepTool` root 上下文先 `borrow_and_update` 预检 pending>0 立即返回,否则 select timer/changed/cancellation,steer 唤醒消息携带 run_id/agent_id 与安全摘要、queue 唤醒只给类型/数量并明确不泄漏正文;child/continuation sleep 仅 timer/cancel(不受 root 输入或 sibling signal 影响)。`InputInterruptPolicy` 元数据沿用(sleep=WakeOnly,其余默认 Never,不扩展)。legacy general background 的 direct notification 兼容路径按设计保留一个版本观察,随后删除旧 adapter 与 `background_tasks` 写入。 +> 实施进度(2026-08):Phase 1 已落地具名 Definition/Catalog、不同 Provider profile、工具/Skill fail-closed 裁剪、显式 `AgentExecutionContext`、父子委托边与 ancestry 校验、canonical `foreground/background` schema 以及批量 foreground 并发。旧 general background 仅作为兼容路径保留。Phase 2A 已落地结构化取消:`AgentError::Cancelled/TimedOut`、CancellationToken 贯穿 root Turn、Provider 连接/stream、并行与串行工具批次以及 sleep;`/stop` 保留 oneshot 兼容桥接并同时取消 Turn token,协作式与强制路径提交同一 Cancelled 终态;树级 `max_runs_per_tree` 由共享原子计数在 foreground 委托接纳时强制。Phase 2B 已落地 durable foreground 编排:schema v6(`agent_run_groups`/`agent_runs`/`agent_session_state`/`agent_inbox_events` 与 messages/sessions 扩展列)在单一迁移事务中原子创建;Storage 领域 API(接纳、running/waiting_children 条件转换、execution-ID 条件 terminal commit、plan item 原子领取/完成、游标分页);`ExecutionGate` 分离 run quota 与 provider/tool step gate(global→session 顺序获取、弱引用键控回收、取消可中断等待),root Turn 步骤同样占用 step gate;`AgentCoordinator` 持久化全部具名 foreground run(单任务不建 group、批量建 group 并按请求顺序返回)、父 run `waiting_children` 转换、迟到结果丢弃与树位置授权;`agent_task` 工具提供 scoped get/list/get_result/cancel。foreground 不占用 run quota,嵌套 foreground 在并发上限为 1 时不死锁。Phase 3 已落地 durable inbox 与 queue continuation:具名 background 单任务经 Coordinator 接纳(completion slot 预留 → 持久化 queued → TaskSupervisor 托管 runner → terminal commit 原子转换 reservation 为 completion 事件 → notifier wake),spawn 拒绝执行补偿事务;`agent_inbox_events`/`agent_session_state` 容量条件更新与 claim/lease/admit/release/supersede/dead-letter API;`AgentInboxNotifier`(弱引用 late-bound wake)与 Session 双 lane worker(user mpsc + inbox watch 合并 wake),公平调度按 `max_user_turn_burst_before_inbox` 与 `max_inbox_wait_secs` 强制 continuation;continuation Turn 使用 hidden trigger、只读工具集,`commit_continuation_turn` 同事务提交 hidden trigger + assistant/tool/usage + event consume,失败显式 release lease;`client_visibility`/`turn_origin` 贯穿 ChatMessage/MessageMeta/协议 DTO,客户端历史查询默认过滤 hidden,`message_count` 只统计可见用户输入;activation recovery 收敛旧代 run(interrupted + failure completion)、过期 lease、group counter 与容量计数;`/stop`/archive/delete 走 `cancel_session`(suppress_continuation 写 consumed completion)并 dead-letter。background completion 不再直接通知 Channel。Phase 3 审查修复:worker 通过 `next_pending_due_at` 定时器在 release backoff 到期后重新 claim(不再依赖 wake);达到 `max_inbox_delivery_attempts` 的事件在正常运行中即 dead-letter 而非无限重试;`recover_on_activation` 对含 due 事件的在内存 session 发送合并 wake;修正 MIN 聚合无行时 NULL 被解码为 0 导致 worker 空转的缺陷(`oldest_pending_due`/`next_pending_due_at` 用 `Option>` 显式解码)。Phase 4 已落地 emit_signal 与 steer:Agent Definition frontmatter 新增 `signal:` 块(`SignalContract`:delivery queue/steer、总数/字节/间隔/burst/severity allowlist/dedupe 冷却窗/JSON 深度,全部由工具与 Coordinator 强制,模型只提供 key/severity/summary/details/dedupe_key);`EmitSignalTool` 仅在带 contract 的 run 注册(fail-closed),`insert_agent_signal` 支持同冷却窗 dedupe(返回原 event + deduplicated);Coordinator `emit_signal` 校验 run 活跃与 execution ID、容量条件插入、投影+notifier wake,取消 run 时未消费 signal 自动 supersede;SteeringMailbox 泛化为来源感知 TurnMailbox(user lane 32/64KiB 与 agent lane 8/32KiB 独立容量,`TurnInput{source,delivery,durable_event_id,lease_token}`,agent steer 投影为 hidden user 消息保留 source 元数据);steer 两阶段 admission(claim → mailbox 预留 → DB admit(turn_id) → 同 Turn/generation 激活),任何失败 release lease 并 wake queue lane;`/stop`/generation 变更时已 admit 的 steer 事件按 lease token 条件释放回 pending(绝不静默丢弃),用户 Turn commit 与 steer 事件 consume 同事务(`persist_turn_batch_with_steer_consumption`)。Phase 3 收尾已落地:`ChannelContext.durable_private`(Feishu 仅 thread/root/chat_type 进入)持久化到 `sessions.delivery_context` 并被 continuation 投递复用;WS 协议 `GetAgentRuns`/`GetAgentRun` 与 `SessionAgentRuns`/`AgentRunUpdated`/`AgentEventUpdated`(有界 `AgentRunView`/`AgentEventView`,不暴露 budget/contract/delivery context/execution id);`AgentProjectionHub` broadcast(复用 plan-change 模式,lag 由客户端 GetAgentRuns 校准);HTTP `/api/agent-runs*`(列表游标分页/详情/events/cancel)+ `/api/tasks` legacy/new union;WebUI TasksPage 后台 tab 渲染 run tree(group 折叠、agent 标签、深度缩进),ChatPage 显示 continuation 标签与 Signal 卡片(投影事件,不插入 history)。Phase 5 已落地 wake-aware sleep:`TurnWakeupPublisher`/`TurnWakeupHandle` 随 active Turn 创建(root interactive Turn 专用,sub-run 与 continuation 永远没有 handle);watch revision 单调,四个 admission 点(用户进 TurnMailbox、用户进 next-turn mpsc、Agent steer durable admitted、Agent event 保持 pending 且 wake Session)在事实可见后 publish(先更新状态再发 wake,sleep 醒来必能查到输入);`SleepTool` root 上下文先 `borrow_and_update` 预检 pending>0 立即返回,否则 select timer/changed/cancellation,steer 唤醒消息携带 run_id/agent_id 与安全摘要、queue 唤醒只给类型/数量并明确不泄漏正文;child/continuation sleep 仅 timer/cancel(不受 root 输入或 sibling signal 影响)。`InputInterruptPolicy` 元数据沿用(sleep=WakeOnly,其余默认 Never,不扩展)。legacy general background 的 direct notification 兼容路径按设计保留一个版本观察,随后删除旧 adapter 与 `background_tasks` 写入。后续破坏性收敛(2026-08,schema v7):删除 legacy 匿名 general 与 `background_tasks` 表(旧 adapter 移除、`/api/tasks` 只回 agent_runs);工具可派发门槛取消,普通工具由定义文件 `tools` 决定、`runtime_injected` 仅标记 delegate/emit_signal/get_skill/agent_task;内置 general-purpose 定义随二进制释放、`root_delegates` 默认指向它、WebUI「子代理」页可增删改启停并内联 provider/model;bash 开放 Delegatable。background 批量开放:`delegate_background` 批量接纳(单/批量,批量建 group,每 run 独立 completion slot),run permit 移入 runner(delegate 立即返回、排队计入 timeout),N 超 `max_concurrent_runs` 硬拒绝;取消 `completion_policy`(all/each 与 group_completion 事件移除,统一 each 语义);公平调度修正——空闲(无用户积压)时 due 事件立即 claim(完成即返回),忙碌时仍以 burst/age 防饥饿,顺带消除空闲时 0ms 定时器空转。后续收敛(2026-08,schema v8):彻底删除 group id——`agent_run_groups` 表删除、`agent_runs.group_id`/`completion_delivery`/`failure_delivery` 与 `agent_inbox_events.scope_kind`/`scope_id`/`group_id` 列删除(`run_id` 改 NOT NULL、`UNIQUE(run_id, event_type, event_key)`),`AgentExecutionContext.group_id` 移除,recovery 不再收敛 group counter,`RecoveryReport.groups_converged` 移除;WebUI TasksPage 后台 tab 由 group 折叠的 run tree 改为平铺 run 列表。 三方对齐审查(2026-08)修复:RunQuota 接线(background admission 前按 global→session 获取 run permit,随 runner 持有至 terminal commit;foreground 不占 run permit,嵌套 limit=1 永不死锁,与 gate 注释语义一致);`signal_contract_json`/`signal_delivery` 在 run 接纳时持久化(此前 steer delivery 静默回退为 queue);terminal completion payload 携带本 run 已发出的 signal IDs(§12.3);ROOT 的 `caller_scope_id` 固定为字面量 `"ROOT"`(§9.6);legacy general 委托返回弃用迁移提示(§4.3)。已知剩余偏差:`resource_scope_id` 未建模为显式字段(browser 瞬态隔离经 session_id 惯例达成,行为等效);`idempotency_key` 仅 schema 预留、工具未开放入口;`all` policy 的 group completion 事件在 background 批量开放前不可达;legacy 适配器与 `background_tasks` 写入按设计保留一个版本后删除。 @@ -38,7 +38,7 @@ | history | 所有 role=user 都作为用户消息 | internal replay 读 hidden,客户端默认过滤 hidden | 高 | | Channel context | `reply_to` 与 opaque `private` 未区分稳定性 | Channel 显式提供 `durable_private`,核心不得猜 key | 高 | | 重载恢复 | 候选代创建完整 SessionManager | Catalog 在 prepare 校验,run/inbox 恢复只能 activation 后执行 | 高 | -| 客户端 | `/api/tasks` 只读旧 `background_tasks` | 新 run/event 投影、revision、旧表 union 过渡 | 中 | +| 客户端 | `/api/tasks` 只读旧 `background_tasks` | 新 run/event 投影、revision(旧表已在 schema v7 删除) | 中 | 实现的关键路径为: @@ -47,7 +47,7 @@ Catalog/Tool policy ↓ AgentLoop cancellation + execution gate ↓ -Run/group durable lifecycle +Run durable lifecycle ↓ Inbox state/capacity + Session wake + queue continuation ↓ @@ -70,7 +70,7 @@ Phase 3 依赖前面全部基础。若跳过 cancellation、持久化状态机 6. queue continuation 不伪造 `InboundMessage`。它通过 Session 内部 typed task 启动,并在成功时原子保存 hidden trigger、可见结果和 event consumption。 7. `steer` 不取消 Provider 或普通工具;它只在安全边界注入。`/stop` 和显式 cancel 才触发 cancellation token。 8. 候选运行代只解析和校验 Catalog,不扫描或修改数据库运行状态。恢复动作仅在新代 activation 后执行。 -9. 新工具默认 `RootOnly`。只有经过逐项审计的工具可以标记 `Delegatable`;runtime control 工具由 Coordinator 注入。 +9. 工具可用性由具名 Agent 定义文件的 `tools` 列表决定;`delegate`/`emit_signal`/`get_skill`/`agent_task` 为 runtime-injected,由 Coordinator 按 `delegates`/`signal`/`skills` 字段注入。 10. session 归档/删除、run 取消、event supersede 和 dead-letter 都保留审计事实,不通过物理删除表达状态变化。 ## 3. 目标运行时组件与依赖装配 @@ -165,7 +165,7 @@ pub struct AgentOrchestrationConfig { } ``` -`Config` 增加 `#[serde(default)] pub agent_orchestration: AgentOrchestrationConfig`。所有默认值必须保持现有配置可加载。旧 `gateway.max_concurrent_background_tasks` 在迁移期只控制 legacy adapter;新实现不复用该字段表达三类不同配额。 +`Config` 增加 `#[serde(default)] pub agent_orchestration: AgentOrchestrationConfig`。所有默认值必须保持现有配置可加载。旧 `gateway.max_concurrent_background_tasks` 字段已随 legacy adapter 一并删除。 配置校验需要拒绝 0 容量、session 上限大于 global 上限、TTL/timeout 超过硬上限,以及 definitions 目录越界。配置示例、README 和运行时 config reference 在功能合并时同步更新。 @@ -211,47 +211,38 @@ pub struct AgentCatalog { 第一版 Catalog 只接受候选代准备阶段已经注册的 built-in 工具。现有 MCP 连接只允许在 activation 发生,为维持候选代无外部副作用和“引用错误拒绝整代”的不变量,MCP 工具不得出现在 Agent Definition;未来只有在 MCP 提供可离线校验的 tool manifest 后才能开放。 -### 4.3 legacy general +### 4.3 内置 general-purpose -当 orchestration 未配置或旧调用没有 `target` 时,兼容层提供代码内置 `general` definition: - -- Provider 使用当前 root agent profile。 -- 工具只取旧 default list 与 `Delegatable` 的交集。 -- 不能继续 delegate,不能 emit signal。 -- 保留旧 transient browser scope 的兼容行为并返回弃用提示。 - -显式配置的具名 Agent 永远不继承该例外。 +随二进制打包内置 `general-purpose` Agent definition(`resources/agents/general-purpose.md`),首次运行释放到 `/agents/`(已存在不覆盖,用户可编辑)。`root_delegates` 默认指向它,开箱即用;未启用编排或缺少 `target` 时委托会直接报错(旧匿名 general 兼容路径已移除)。 ## 5. ToolRegistry 与执行上下文改造 ### 5.1 Tool 元数据 -在 `Tool` trait 增加默认安全元数据: +在 `Tool` trait 上只有一个运行时注入标记(工具可用性由定义文件决定): ```rust -fn delegation_policy(&self) -> DelegationPolicy { - DelegationPolicy::RootOnly -} +/// 该工具由运行上下文注入(delegate 目标、信号契约、skill allowlist), +/// 不能直接写进 Definition 的 `tools` 列表。普通工具默认 false。 +fn runtime_injected(&self) -> bool { false } fn input_interrupt_policy(&self) -> InputInterruptPolicy { InputInterruptPolicy::Never } ``` -`DelegationPolicy` 为 `RootOnly | Delegatable | RuntimeInjected`;`InputInterruptPolicy` 为 `Never | WakeOnly | CancelSafe`。二者不能从 Markdown 覆盖。 +`InputInterruptPolicy` 为 `Never | WakeOnly | CancelSafe`,不能从 Markdown 覆盖。 -第一轮审计建议: +运行时注入工具: -| 工具类型 | 初始策略 | 说明 | +| 工具 | 标记 | 说明 | |----------|----------|------| -| file/content search、file read | Delegatable | 仍受现有进程文件权限约束,不代表硬 sandbox | -| browser 普通动作 | Delegatable | 具名 run 使用独立 transient resource scope | -| `get_skill` | RuntimeInjected wrapper | 只能读取 definition 的 skill allowlist | -| HTTP request | RootOnly | 当前实现支持写方法;以后可增加 delegated GET-only wrapper | -| bash、file write/edit | RootOnly | 需单独威胁建模后才开放 | -| send_message、todo、cron、reload、browser_profiles、管理工具 | RootOnly | 有外部或全局状态副作用 | -| delegate、agent_task、emit_signal | RuntimeInjected | 依据 caller context 动态注入 | -| sleep | Delegatable + WakeOnly | child 无 TurnWakeupHandle,只响应 timer/cancel | +| `delegate` | runtime-injected | 由 Definition 的 `delegates` 白名单注入 ScopedDelegateTool | +| `emit_signal` | runtime-injected | 仅在带 `signal:` 契约的 run 注入 | +| `get_skill` | runtime-injected(例外) | 写进 `tools` 表示启用 scoped skill 包装器 | +| `agent_task` | runtime-injected | 由 Coordinator 注入 | + +其余任何已注册工具(含 `bash`、`send_message`、`todo` 等)都可由管理员在定义文件的 `tools` 里显式授权。 `ToolRegistry` 增加只读构建方法,不在共享 registry 上删除工具: @@ -347,7 +338,6 @@ Phase 4 将 `SteeringMailbox` 替换为 `TurnMailbox`。 pub struct DelegateRequest { pub mode: ExecutionMode, pub tasks: Vec, - pub completion_policy: CompletionPolicy, pub idempotency_key: Option, } @@ -376,7 +366,7 @@ impl AgentCoordinator { 5. 把 cancellation token 和 execution ID 注册到 Coordinator active map。 6. 通过 `TaskSupervisor::spawn_graceful` 接纳 runner。 7. spawn 被拒绝时执行补偿事务:queued → cancelled、释放 completion reservation、回滚/阻塞已领取 plan item;不得向模型返回可用 run ID。 -8. spawn 成功后返回 run/group ID。 +8. spawn 成功后返回 run ID。 第一版在步骤 1 强制 `background caller == ROOT`;child 只能 foreground 委托。该限制属于 Coordinator policy,不仅是 delegate schema 提示。以后开放 nested background 时仍必须把 run 归属到原 root session,并重新审查取消所有权和 completion reservation。 @@ -409,7 +399,7 @@ Coordinator 用 `(run_id, execution_id, runtime_generation, nonterminal status)` - 写终态和完整 result/error。 - 更新 group terminal counter。 - 转换或释放 completion reservation。 -- 插入 run/group inbox event。 +- 插入 run inbox event。 - 更新绑定的 task item。 - 递增 session agent revision。 @@ -455,7 +445,7 @@ CREATE TABLE IF NOT EXISTS agent_session_state ( ); ``` -这是 inbox 容量和客户端 revision 的权威计数行。每个改变客户端 run/group/event 投影的事务只分配一个新 revision,并把同一 revision 写回本事务改变的所有行;仅内部读取不递增。所有容量增加操作使用条件更新: +这是 inbox 容量和客户端 revision 的权威计数行。每个改变客户端 run/event 投影的事务只分配一个新 revision,并把同一 revision 写回本事务改变的所有行;仅内部读取不递增。所有容量增加操作使用条件更新: ```sql UPDATE agent_session_state @@ -469,55 +459,15 @@ RETURNING revision; 若 session 尚无行,先 `INSERT ... ON CONFLICT DO NOTHING`,仍在同一写事务中执行条件更新。 -### 8.3 agent_run_groups +### 8.3 agent_run_groups(已删除,schema v8) -```sql -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; -``` - -批量请求把 request idempotency key 写在 group,所有 child run 的 `idempotency_key` 为 NULL;单任务请求不创建只含一个 child 的 group,key 直接写在 run。这样 retry 能返回原 group/run,又不会让同一批 children 触发唯一索引冲突。 +批量委托的组头表在 schema v8 中移除。批量请求现在只是多个独立 run 的集合:单/批量请求的 idempotency key 都写在各自 run 行,每个 background run 独立预留 completion slot、独立生成完成事件,不存在组级收敛或 all/each 策略。 ### 8.4 agent_runs ```sql 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, @@ -538,8 +488,6 @@ CREATE TABLE IF NOT EXISTS agent_runs ( 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, @@ -562,7 +510,6 @@ CREATE TABLE IF NOT EXISTS agent_runs ( '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 ); @@ -583,6 +530,8 @@ CREATE INDEX IF NOT EXISTS idx_agent_runs_recovery ON agent_runs(runtime_generation, status, deadline_at); ``` +批量请求的 request idempotency key 写在各 run 行的 `idempotency_key`(`caller_scope_id` 相同),retry 能返回原 run 集合。 + Session 使用软删除,agent 表不对 `root_session_id` 建级联外键;生命周期由 `cancel_session` 和恢复扫描显式收敛。 ### 8.5 agent_inbox_events @@ -591,10 +540,7 @@ Session 使用软删除,agent 表不对 `root_session_id` 建级联外键; 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, @@ -615,21 +561,14 @@ CREATE TABLE IF NOT EXISTS agent_inbox_events ( dead_lettered_at INTEGER, fallback_notified_at INTEGER, fallback_suppressed_reason TEXT, - CHECK (scope_kind IN ('run', 'group')), - CHECK (event_type IN ('signal', 'completion', 'group_completion')), + updated_at INTEGER NOT NULL, + 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 @@ -642,7 +581,7 @@ CREATE INDEX IF NOT EXISTS idx_agent_inbox_revision ON agent_inbox_events(root_session_id, revision); ``` -run/group 外键使用 RESTRICT。清理默认只清理大正文或归档整组记录;若未来需要物理删除,必须先按明确保留策略删除 terminal inbox event,再删除 run/group,不能留下失去审计来源的事件。 +run 外键使用 RESTRICT。清理默认只清理大正文或归档整组记录;若未来需要物理删除,必须先按明确保留策略删除 terminal inbox event,再删除 run,不能留下失去审计来源的事件。 ### 8.6 事务 API @@ -661,7 +600,7 @@ commit_continuation_turn(batch) -> CommittedTurnDelta recover_agent_state(active_generation, now) -> RecoveryReport ``` -`commit_agent_terminal` 在一个事务内更新 run/group、容量计数、event、plan item 和 revision。提交后 Coordinator 调用 `WorkManager::refresh_after_external_commit()` 刷新 cache 并广播;WorkManager 不再为这条路径另开事务。 +`commit_agent_terminal` 在一个事务内更新 run、容量计数、event、plan item 和 revision。提交后 Coordinator 调用 `WorkManager::refresh_after_external_commit()` 刷新 cache 并广播;WorkManager 不再为这条路径另开事务。 ## 9. Agent Inbox、唤醒与公平调度 @@ -694,7 +633,7 @@ watch value 是最新 durable revision,只合并 wake,不携带 payload。wo ### 9.2 queue continuation -claim batch 必须有大小与总字节上限,例如 8 events/32 KiB envelope。`completion_policy=each` 可在 300–500ms 内 debounce 已经 pending 的 siblings,但不能等待未终态 sibling。 +claim batch 必须有大小与总字节上限,例如 8 events/32 KiB envelope。每个 run 的 completion 独立落库;主 Agent 空闲时收到即处理,忙碌时由公平调度合并(burst/age 上限)。 执行过程: @@ -753,7 +692,7 @@ TurnMailbox 分 user lane 与 agent lane 容量,最终按 session sequence 合 5. 插入 event 和新 revision;commit 后发布 projection + wake。 6. 返回 accepted event ID。事件已存在时返回同一 ID 和 `deduplicated=true`。 -Completion 永远由 Coordinator 生成。`completion_policy=all` 时每个 run terminal outcome 只存在于 `agent_runs`,不生成 per-run inbox completion;最后一个 terminal child 或 group deadline 的事务赢家生成唯一 group completion。group 有任一 failed/timed_out/interrupted/cancelled 时使用 `failure_delivery`,否则使用 `completion_delivery`。 +Completion 永远由 Coordinator 生成:每个 background run 的终态独立物化为一个 run completion inbox event(无 all/each 策略),批量也只是逐 run 生成。 显式 `agent_task.cancel` 可以把该 run 尚未消费的普通 signal 更新为 `superseded` 并减少 pending count;completion 事实不能 supersede。 @@ -769,7 +708,7 @@ Completion 永远由 Coordinator 生成。`completion_policy=all` 时每个 run 4. cancellation terminal event 使用 `requires_continuation=false` 并直接 consumed,防止 stop 后又自动启动“已取消”Turn。 5. stop 前已经 pending 的其他事件仍保持 pending;Session 下次可调度时处理。 -现有 oneshot 可在过渡期由 token adapter 驱动,最终移除 `current_cancel: Option>`,统一为 `CancellationToken`。 +`current_cancel` oneshot 兼容桥已移除,统一为 `CancellationToken` + 一个纯观测用的 `turn_busy` 标志。 ### 11.2 archive/delete @@ -788,7 +727,7 @@ activation recovery 分批执行,避免长事务: 1. 将旧 generation 的 queued/running/waiting_children 条件更新为 interrupted。 2. 为 background interrupted outcome 转换预留并插入 failure completion;foreground 只保存终态。 -3. group counter 收敛并生成必要的 group completion。 +3. 批量背景的每个 run 独立收敛为 interrupted/failure completion。 4. expired leased/admitted events 恢复 pending,attempt +1,写 next retry。 5. 按每 session 重算 `pending_event_count` 和有效 reservation;差异修复并记录 structured warning。 6. 对有 pending due events 的 session 只发一次合并 wake。 @@ -914,13 +853,13 @@ Sleep 只提前结束工具 future,不消费 mailbox/inbox,也不自行改 | `src/config/mod.rs` | orchestration config、默认值、边界校验 | | `src/agent/definition.rs`(新) | Markdown/frontmatter 类型、严格 parser、definition hash | | `src/agent/catalog.rs`(新) | immutable catalog、委托图与引用校验 | -| `src/agent/run.rs`(新) | run/group/outcome DTO、AgentRunner、ProviderFactory | +| `src/agent/run.rs`(新) | run/outcome DTO、AgentRunner、ProviderFactory | | `src/agent/coordinator.rs`(新) | 授权、预算、接纳、取消、deadline、terminal commit | | `src/agent/inbox.rs`(新) | event/lease/projection/notifier contracts | | `src/agent/agent_loop.rs` | cancellation、step gate、typed input safe boundaries | | `src/agent/steering.rs` | 迁移为 typed TurnMailbox 与 reservation | | `src/agent/sub_agent.rs` | legacy adapter,逐阶段缩减并最终删除旧 manager | -| `src/tools/traits.rs` | ToolExecutionContext、DelegationPolicy、interrupt policy | +| `src/tools/traits.rs` | ToolExecutionContext、runtime-injected 标记、interrupt policy | | `src/tools/delegate.rs` | 仅 run/run_many 和兼容参数转换 | | `src/tools/agent_task.rs`(新) | scoped get/list/cancel/get_result | | `src/tools/emit_signal.rs`(新) | contract-bound signal | @@ -928,7 +867,7 @@ Sleep 只提前结束工具 future,不消费 mailbox/inbox,也不自行改 | `src/session/session.rs` | dual lane worker、internal task、atomic continuation、stop release | | `src/session/agent_inbox.rs`(新) | claim/admit/release、fair scheduler、lease guard | | `src/storage/mod.rs` | schema v6 migration 和领域 API re-export | -| `src/storage/agent_run.rs`(新) | run/group transaction SQL | +| `src/storage/agent_run.rs`(新) | run transaction SQL | | `src/storage/agent_inbox.rs`(新) | capacity/event/lease/recovery SQL | | `src/storage/message.rs`、`session.rs` | visibility/origin/durable delivery context | | `src/gateway/mod.rs`、`reload.rs` | prepare/activation、recovery task、projection relay | @@ -968,7 +907,7 @@ Sleep 只提前结束工具 future,不消费 mailbox/inbox,也不自行改 ### Phase 2B:Coordinator、run persistence 与 execution gate -- schema v6 的 run/group 部分。 +- schema v6 的 run 部分。 - ProviderFactory、AgentRunner、Coordinator。 - run quota/provider gate/tool gate。 - foreground 单/批量、parent waiting_children、agent_task。 @@ -1093,7 +1032,7 @@ cargo build | steer admission 竞态重复 | lease token + 不可排空 reservation | 配置强制所有 Agent delivery=queue | | background 新路径影响现有用户 | legacy adapter 与新表分离 | 一个版本内启用 direct-notification rollback flag,二者互斥 | | reload 双代同时恢复 | prepare/activation 硬边界 | 关闭新代 admission,旧代继续服务 | -| 工具权限误放大 | default RootOnly + Catalog fail closed | 从 definition 移除工具并 reload;已启动 run 固定旧快照 | +| 工具权限误放大 | 工具集完全由定义文件决定 + Catalog fail closed | 从 definition 移除工具并 reload;已启动 run 固定旧快照 | | continuation 重试重复外部副作用 | 默认只读 registry | dead-letter,要求用户显式处理 | rollback flag 只能在 Phase 3 过渡期存在,并保证新 inbox continuation 与旧 direct notification 互斥。即使回滚投递方式,新 `agent_runs`/inbox 表仍保留审计事实,不能降 schema 或删除记录。 diff --git a/docs/SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md b/docs/SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md index 6950afe..72af9b6 100644 --- a/docs/SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md +++ b/docs/SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md @@ -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 | diff --git a/resources/skills/about-picobot/SKILL.md b/resources/skills/about-picobot/SKILL.md index 3a49a39..aaa1039 100644 --- a/resources/skills/about-picobot/SKILL.md +++ b/resources/skills/about-picobot/SKILL.md @@ -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 设备配对、启动客户端、运行测试 | diff --git a/resources/skills/about-picobot/references/architecture.md b/resources/skills/about-picobot/references/architecture.md index 6377cf5..9b58189 100644 --- a/resources/skills/about-picobot/references/architecture.md +++ b/resources/skills/about-picobot/references/architecture.md @@ -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 关停时先收到取消信号,再在总宽限期内清理。 diff --git a/resources/skills/about-picobot/references/config.md b/resources/skills/about-picobot/references/config.md index 7964ea7..cf50681 100644 --- a/resources/skills/about-picobot/references/config.md +++ b/resources/skills/about-picobot/references/config.md @@ -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 字段 diff --git a/resources/skills/about-picobot/references/db-schema.md b/resources/skills/about-picobot/references/db-schema.md index 918ce49..a9a7969 100644 --- a/resources/skills/about-picobot/references/db-schema.md +++ b/resources/skills/about-picobot/references/db-schema.md @@ -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 表 diff --git a/resources/skills/about-picobot/references/tools.md b/resources/skills/about-picobot/references/tools.md index 1ac8efb..74ef2c9 100644 --- a/resources/skills/about-picobot/references/tools.md +++ b/resources/skills/about-picobot/references/tools.md @@ -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 查询与控制 diff --git a/resources/templates/config.example.json b/resources/templates/config.example.json index a202b84..816d9f1 100644 --- a/resources/templates/config.example.json +++ b/resources/templates/config.example.json @@ -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, diff --git a/src/agent/builtin.rs b/src/agent/builtin.rs index f0b062d..4dc2576 100644 --- a/src/agent/builtin.rs +++ b/src/agent/builtin.rs @@ -12,7 +12,10 @@ mod embedded { /// Install built-in Agent definitions into `/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) { +pub fn install_builtin_agents( + config_dir: &Path, + profiles: &std::collections::HashMap, +) { 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"); diff --git a/src/agent/catalog.rs b/src/agent/catalog.rs index 3fd6acb..cba7d67 100644 --- a/src/agent/catalog.rs +++ b/src/agent/catalog.rs @@ -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, + providers: &HashMap, + models: &HashMap, + 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, AgentCatalogError> Ok(paths) } -fn read_profile_name(path: &Path) -> Result { +/// 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, + provider: Option, + model: Option, + token_limit: Option, + max_tool_iterations: Option, + enabled: bool, +} + +fn read_provider_spec(path: &Path) -> Result { 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 { )) })?; #[derive(serde::Deserialize)] - struct ProfileOnly { - llm_profile: String, + struct Spec { + id: String, + llm_profile: Option, + provider: Option, + model: Option, + token_limit: Option, + max_tool_iterations: Option, + #[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, + providers: &HashMap, + models: &HashMap, + workspace_dir: &Path, +) -> Result { + 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() ); } } diff --git a/src/agent/coordinator.rs b/src/agent/coordinator.rs index 102eda6..5f77d80 100644 --- a/src/agent/coordinator.rs +++ b/src/agent/coordinator.rs @@ -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, +} + pub struct AgentCoordinator { storage: Arc, manager: Arc, @@ -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, caller: &ToolExecutionContext, - config: SubAgentConfig, - ) -> Result { + configs: Vec, + ) -> Result { 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 { @@ -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, tempfile::TempDir) { @@ -1122,6 +1176,13 @@ mod tests { async fn coordinator_with_inbox_limit( max_pending: usize, + ) -> (Arc, 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, ) -> (Arc, 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, 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(), diff --git a/src/agent/definition.rs b/src/agent/definition.rs index 5652a9d..6214867 100644 --- a/src/agent/definition.rs +++ b/src/agent/definition.rs @@ -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, + /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tool_iterations: Option, + /// 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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub delegates: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skills: Vec, #[serde(default)] - tools: Vec, - #[serde(default)] - delegates: Vec, - #[serde(default)] - skills: Vec, - #[serde(default)] - limits: AgentLimits, - #[serde(default)] - signal: Option, + pub limits: AgentLimits, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signal: Option, +} + +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, + pub provider: Option, + pub model: Option, pub provider_config: Arc, + pub enabled: bool, pub tools: Vec, pub delegates: Vec, pub skills: Vec, @@ -253,6 +276,68 @@ pub(crate) fn parse_definition( path: &Path, provider_config: Arc, ) -> Result { + 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 { + 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> { diff --git a/src/agent/gate.rs b/src/agent/gate.rs index b5b2d03..611cb22 100644 --- a/src/agent/gate.rs +++ b/src/agent/gate.rs @@ -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, run_session: Arc, provider_global: Arc, @@ -91,6 +92,7 @@ impl std::fmt::Debug for ExecutionGate { impl ExecutionGate { pub fn new(config: &crate::config::AgentOrchestrationConfig) -> Arc { 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 { 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, session_id: &str, diff --git a/src/agent/run.rs b/src/agent/run.rs index 4901499..d700738 100644 --- a/src/agent/run.rs +++ b/src/agent/run.rs @@ -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, pub parent_run_id: Option, 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(), diff --git a/src/agent/steering.rs b/src/agent/steering.rs index 5c37738..1ef3798 100644 --- a/src/agent/steering.rs +++ b/src/agent/steering.rs @@ -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)) } diff --git a/src/agent/sub_agent.rs b/src/agent/sub_agent.rs index a15377e..cf6fd9f 100644 --- a/src/agent/sub_agent.rs +++ b/src/agent/sub_agent.rs @@ -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")) + ); } } diff --git a/src/agent/system_prompt.rs b/src/agent/system_prompt.rs index 56ef7b4..596585e 100644 --- a/src/agent/system_prompt.rs +++ b/src/agent/system_prompt.rs @@ -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() } } diff --git a/src/bus/message.rs b/src/bus/message.rs index 218fc34..4a2e82c 100644 --- a/src/bus/message.rs +++ b/src/bus/message.rs @@ -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, - /// Durable group identity for `agent_group_result` sources. - #[serde(default)] - pub group_id: Option, } impl ChatMessage { diff --git a/src/config/mod.rs b/src/config/mod.rs index 4af79a4..9ce9376 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -315,8 +315,6 @@ pub struct GatewayConfig { pub cleanup_interval_minutes: Option, #[serde(default, rename = "session_db_path")] pub session_db_path: Option, - #[serde(default, rename = "max_concurrent_background_tasks")] - pub max_concurrent_background_tasks: usize, #[serde(default)] pub scheduler: Option, #[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(), } diff --git a/src/gateway/http.rs b/src/gateway/http.rs index 71bbb03..5978cc8 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -857,6 +857,259 @@ pub async fn get_tools(State(state): State>) -> Result>) -> Result, 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>, + Json(body): Json, +) -> Result, 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 = 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>, + Path(id): Path, +) -> Result, 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>, +) -> Result, ApiError> { + let providers: Vec = state.config.providers.keys().cloned().collect(); + let models: Vec = 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 = 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 = 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 { + 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::(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 { + 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>) -> Result, ApiError> { let loader = state.session_manager.skills_loader(); let skills: Vec = loader @@ -879,67 +1132,34 @@ pub async fn get_tasks( Query(query): Query, ) -> Result, 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 = 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 = 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 }))) } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index b6a5ab4..970b743 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -53,6 +53,8 @@ pub struct GatewayState { pub(crate) reload: reload::ReloadHandle, pub(crate) admission: reload::RuntimeAdmission, pub agent_catalog: Arc, + /// 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) -> 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}", diff --git a/src/protocol.rs b/src/protocol.rs index 72d60f3..db77fdd 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -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, - #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_run_id: Option, 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub group_id: Option, 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, diff --git a/src/session/messenger.rs b/src/session/messenger.rs index 9445001..c0fd0cd 100644 --- a/src/session/messenger.rs +++ b/src/session/messenger.rs @@ -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")]; diff --git a/src/session/session.rs b/src/session/session.rs index 0a5f4e0..0e72e66 100644 --- a/src/session/session.rs +++ b/src/session/session.rs @@ -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, + pub providers: HashMap, + pub models: HashMap, + 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); diff --git a/src/storage/agent_inbox.rs b/src/storage/agent_inbox.rs index 6b8cb65..475acf3 100644 --- a/src/storage/agent_inbox.rs +++ b/src/storage/agent_inbox.rs @@ -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, - pub group_id: Option, 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, - pub group_id: Option, 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 = 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" + ); + } } diff --git a/src/storage/agent_run.rs b/src/storage/agent_run.rs index efce074..3bb8245 100644 --- a/src/storage/agent_run.rs +++ b/src/storage/agent_run.rs @@ -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 { - 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 { - 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, - pub caller_scope_id: String, - pub idempotency_key: Option, - 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, - pub failure_delivery: Option, - 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, -} - #[derive(Debug, Clone)] pub struct AgentRunRecord { pub id: String, - pub group_id: Option, pub root_session_id: String, pub root_turn_id: Option, pub parent_run_id: Option, @@ -352,8 +207,6 @@ pub struct AgentRunRecord { pub budget_json: String, pub signal_contract_json: Option, pub signal_delivery: Option, - pub completion_delivery: Option, - pub failure_delivery: Option, pub status: AgentRunStatus, pub result: Option, pub error: Option, @@ -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, - pub caller_scope_id: String, - pub idempotency_key: Option, - 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, pub runs: Vec, pub now: i64, } #[derive(Debug)] pub enum AcceptedAgentRuns { - Accepted { - group: Option, - runs: Vec, - }, - /// Idempotent retry: the group/run already existed for this key. - Existing { - group: Option, - runs: Vec, - }, + Accepted { runs: Vec }, + /// Idempotent retry: the run already existed for this key. + Existing { runs: Vec }, } /// 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, - 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 { 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("status"))?, result: row.get("result"), error: row.get("error"), @@ -558,38 +377,11 @@ fn run_record_from_row(row: &sqlx::sqlite::SqliteRow) -> Result Result { - 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::("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, 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, 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, }) diff --git a/src/storage/background_task.rs b/src/storage/background_task.rs deleted file mode 100644 index a01d1ed..0000000 --- a/src/storage/background_task.rs +++ /dev/null @@ -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, - pub status: String, - pub result: Option, - pub error: Option, - pub tool_calls_count: i64, - pub iterations: i64, - pub started_at: Option, - pub finished_at: Option, - pub created_at: i64, -} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index a0f1f67..e583d68 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -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 { - 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, 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, 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 { - 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!["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", diff --git a/src/tools/agent_task.rs b/src/tools/agent_task.rs index 57ff613..76a8020 100644 --- a/src/tools/agent_task.rs +++ b/src/tools/agent_task.rs @@ -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(), diff --git a/src/tools/bash.rs b/src/tools/bash.rs index 98ddcd5..74f6d89 100644 --- a/src/tools/bash.rs +++ b/src/tools/bash.rs @@ -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." } diff --git a/src/tools/browser/mod.rs b/src/tools/browser/mod.rs index b27e356..ceca425 100644 --- a/src/tools/browser/mod.rs +++ b/src/tools/browser/mod.rs @@ -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" } diff --git a/src/tools/calculator.rs b/src/tools/calculator.rs index e4bdae0..a11ef1d 100644 --- a/src/tools/calculator.rs +++ b/src/tools/calculator.rs @@ -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" } diff --git a/src/tools/content_search.rs b/src/tools/content_search.rs index f536081..86152b6 100644 --- a/src/tools/content_search.rs +++ b/src/tools/content_search.rs @@ -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" } diff --git a/src/tools/delegate.rs b/src/tools/delegate.rs index a73cffc..f629c63 100644 --- a/src/tools/delegate.rs +++ b/src/tools/delegate.rs @@ -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 { @@ -155,7 +155,11 @@ impl DelegateTool { args: &Value, context: &ToolExecutionContext, ) -> anyhow::Result { - 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 { diff --git a/src/tools/emit_signal.rs b/src/tools/emit_signal.rs index 7956d00..8600790 100644 --- a/src/tools/emit_signal.rs +++ b/src/tools/emit_signal.rs @@ -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, diff --git a/src/tools/file_read.rs b/src/tools/file_read.rs index a4d0304..55cb90f 100644 --- a/src/tools/file_read.rs +++ b/src/tools/file_read.rs @@ -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" } diff --git a/src/tools/file_search.rs b/src/tools/file_search.rs index 18d6fd4..b9544a4 100644 --- a/src/tools/file_search.rs +++ b/src/tools/file_search.rs @@ -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" } diff --git a/src/tools/get_skill.rs b/src/tools/get_skill.rs index 48713e6..02dac92 100644 --- a/src/tools/get_skill.rs +++ b/src/tools/get_skill.rs @@ -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 { diff --git a/src/tools/mod.rs b/src/tools/mod.rs index f87effc..a5851d7 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -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; diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 62fade7..ed573a0 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -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() )); } diff --git a/src/tools/send_message.rs b/src/tools/send_message.rs index a1d1391..32285ab 100644 --- a/src/tools/send_message.rs +++ b/src/tools/send_message.rs @@ -135,7 +135,6 @@ target_chat_id 支持两种格式::(发送到该聊天下 task_id: None, from_run_id: None, from_agent_id: None, - group_id: None, }; // 3. Parse files into MediaItems diff --git a/src/tools/sleep.rs b/src/tools/sleep.rs index 36b4caa..70b559c 100644 --- a/src/tools/sleep.rs +++ b/src/tools/sleep.rs @@ -36,10 +36,6 @@ fn parse_seconds(args: &serde_json::Value) -> Result { #[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处理。", diff --git a/src/tools/traits.rs b/src/tools/traits.rs index 74f01cf..7754555 100644 --- a/src/tools/traits.rs +++ b/src/tools/traits.rs @@ -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; - /// 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. diff --git a/src/tools/web_fetch.rs b/src/tools/web_fetch.rs index 22e08b9..afe7bda 100644 --- a/src/tools/web_fetch.rs +++ b/src/tools/web_fetch.rs @@ -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" } diff --git a/webui/package-lock.json b/webui/package-lock.json index 7752d35..7e1de8a 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -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", diff --git a/webui/package.json b/webui/package.json index 58451a6..86df5e3 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "picobot-webui", "private": true, - "version": "1.8.0", + "version": "1.11.0", "type": "module", "engines": { "node": ">=20" diff --git a/webui/src/App.svelte b/webui/src/App.svelte index 110c20f..22d3e6b 100644 --- a/webui/src/App.svelte +++ b/webui/src/App.svelte @@ -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: '', overview: '', tools: '', + agents: '', logs: '', memory: '', tasks: '', @@ -150,6 +153,7 @@ {:else if current === "settings"} toast.show(text, error)} /> {:else if current === "overview"} {:else if current === "tools"} + {:else if current === "agents"} toast.show(text, error)} /> {:else}
即将上线
{/if} diff --git a/webui/src/pages/AgentsPage.svelte b/webui/src/pages/AgentsPage.svelte new file mode 100644 index 0000000..0d3b644 --- /dev/null +++ b/webui/src/pages/AgentsPage.svelte @@ -0,0 +1,306 @@ + + +
+
+
+

具名子代理

+

+ 子代理由 ~/.picobot/agents/*.md 定义;工具、Skill、Provider 与模型在此直接指定。改动需热重载后生效。 +

+
+ +
+ + {#if loading} +
加载中…
+ {:else if error} +
{error}
+ {:else if agents.length === 0} +
暂无子代理定义
+ {:else} +
+ {#each agents as agent (agent.id)} +
+
+
+

{agent.id}

+

{agent.description}

+
+ provider: {agent.provider || agent.llm_profile || "—"} + model: {agent.model || "—"} + {#if agent.tools?.length}{agent.tools.length} 个工具{/if} + {#if agent.skills?.length}{agent.skills.length} 个 Skill{/if} + {#if agent.delegates?.length}委托: {agent.delegates.join(", ")}{/if} +
+ {#if agent.tools?.length} +
+ {#each agent.tools as tool (tool)}{tool}{/each} +
+ {/if} +
+
+ + + +
+
+
+ {/each} +
+ {/if} + + {#if editing} + + + {/if} +
+ + diff --git a/webui/src/pages/TasksPage.svelte b/webui/src/pages/TasksPage.svelte index f01f705..125aaad 100644 --- a/webui/src/pages/TasksPage.svelte +++ b/webui/src/pages/TasksPage.svelte @@ -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}
加载中…
{:else if error}
{error}
{:else if tab === "background"} - {#each groupTasks(tasks) as group (group.key)} - {#if group.runs.length === 0} -
暂无后台任务
- {:else} -
-
-
-

{group.title}

-
- {group.runs[0].session_id} - {formatTime(group.runs[0].created_at)} - {#if group.runs[0].status === "running"}{elapsed(group.runs[0].created_at)}{/if} -
+ {#each tasks as task (task.id)} +
+
+
+
+ + {task.agent_id || "general"} + {task.prompt?.slice(0, 120) || ""} + +
+
+ {task.session_id} + {formatTime(task.created_at)} + {#if task.status === "running"}{elapsed(task.created_at)}{/if} + {task.tool_calls_count} 次工具调用 · {task.iterations} 轮
-
-
- {#each group.runs as task} -
-
- - {task.agent_id || "general"} - {task.prompt.slice(0, 80)} - - {task.tool_calls_count} 次工具调用 · {task.iterations} 轮 -
- {#if task.result}

{task.result.slice(0, 300)}

{/if} - {#if task.error}

{task.error.slice(0, 200)}

{/if} -
- {/each} -
-
- {/if} - {/each} +
+ {#if task.result}

{task.result.slice(0, 300)}

{/if} + {#if task.error}

{task.error.slice(0, 200)}

{/if} +
+ {:else}
暂无后台任务
{/each} {:else} {#each jobs as job (job.id)}
@@ -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; }