feat: remove agent run groups, add WebUI agent definition management
- drop agent_run_groups table and group_id/scope_kind/scope_id columns (schema v8) - remove group_id from AgentExecutionContext and recovery group counters - flatten TasksPage background tab into a per-run list - add WebUI Agents page with definition CRUD and inline provider/model - bump version to 1.11.0
This commit is contained in:
parent
78d6e29672
commit
5501c539fc
@ -93,7 +93,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
|||||||
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context
|
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context
|
||||||
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
|
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
|
||||||
- **AgentLoop** is stateless across turns; it receives prepared history, drains same-Turn steering only at safe model boundaries, calls LLM providers, executes tools, and returns one result
|
- **AgentLoop** is stateless across turns; it receives prepared history, drains same-Turn steering only at safe model boundaries, calls LLM providers, executes tools, and returns one result
|
||||||
- **AgentCatalog** is immutable per runtime generation; when orchestration is enabled, candidate preparation strictly validates trusted Markdown definitions, Provider profiles, delegated tools, Skill allowlists, and delegation edges before activation. Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; background batches, nested background, and legacy general background (kept for one transition version) remain restricted
|
- **AgentCatalog** is immutable per runtime generation; when orchestration is enabled, candidate preparation strictly validates trusted Markdown definitions, Provider profiles, tool/Skill allowlists, and delegation edges before activation. Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; a built-in general-purpose definition is released to `~/.picobot/agents/` on first run. Background batches and nested background remain restricted
|
||||||
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
|
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
|
||||||
- **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts
|
- **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts
|
||||||
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
|
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
|
||||||
@ -106,7 +106,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
|||||||
- **Providers** are pure HTTP clients; no bus/session/channel awareness
|
- **Providers** are pure HTTP clients; no bus/session/channel awareness
|
||||||
- **Provider reasoning state** is private replay data: persist it, replay it only to the matching provider, and never expose it to clients, channels, or logs
|
- **Provider reasoning state** is private replay data: persist it, replay it only to the matching provider, and never expose it to clients, channels, or logs
|
||||||
- **Tools** are executed by `AgentLoop`; every invocation is normalized to `ToolOutput` and passes through `ToolOutputProcessor`. Plain `ToolResult` implementations use the default conversion, while artifact-producing tools declare model/user audience explicitly; model capability checks, final-reply attachment, channel delivery, and Provider serialization stay outside tools
|
- **Tools** are executed by `AgentLoop`; every invocation is normalized to `ToolOutput` and passes through `ToolOutputProcessor`. Plain `ToolResult` implementations use the default conversion, while artifact-producing tools declare model/user audience explicitly; model capability checks, final-reply attachment, channel delivery, and Provider serialization stay outside tools
|
||||||
- **Delegated tool access** fails closed: new tools default to `RootOnly`; only code-reviewed `Delegatable` tools may appear in Agent Markdown, while `delegate` and scoped `get_skill` are runtime-injected. Call parameters cannot expand a named Agent's tool set
|
- **Delegated tool access**: a named Agent's tool set is decided solely by its definition file (admin-authored). `delegate`, `emit_signal`, `get_skill` and `agent_task` are runtime-injected and must never be declared in `tools` (`get_skill` is the scoped-skill switch); `allowed_tools` can only narrow the definition, never expand it
|
||||||
- **Sleep tool** is a cancellable foreground wait bounded to 24 hours; longer or durable delays belong to Scheduler/background work, and cancelling a Turn must normalize active tool blocks to `Cancelled`
|
- **Sleep tool** is a cancellable foreground wait bounded to 24 hours; longer or durable delays belong to Scheduler/background work, and cancelling a Turn must normalize active tool blocks to `Cancelled`
|
||||||
- **Stateful tools** receive `ToolExecutionContext`; browser calls without `persistent_id` map each PicoBot dialog to an opaque transient agent-browser session. For long-running work an Agent may autonomously create a persistent identity and must pass its validated ID on every related action; the same ID shares one agent-browser session and serialization gate across dialogs, while different IDs have independent sessions/gates. `browser_profiles` may create IDs, persist bounded semantic labels, list, or delete only validated IDs beneath the configured profile root. Do not introduce a global persistence mode switch, a default persistent ID, automatic per-dialog persistent Profiles, Fantoccini, ChromeDriver, WebDriver, model-controlled raw agent-browser session IDs, or arbitrary Profile paths
|
- **Stateful tools** receive `ToolExecutionContext`; browser calls without `persistent_id` map each PicoBot dialog to an opaque transient agent-browser session. For long-running work an Agent may autonomously create a persistent identity and must pass its validated ID on every related action; the same ID shares one agent-browser session and serialization gate across dialogs, while different IDs have independent sessions/gates. `browser_profiles` may create IDs, persist bounded semantic labels, list, or delete only validated IDs beneath the configured profile root. Do not introduce a global persistence mode switch, a default persistent ID, automatic per-dialog persistent Profiles, Fantoccini, ChromeDriver, WebDriver, model-controlled raw agent-browser session IDs, or arbitrary Profile paths
|
||||||
- **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, and `/health`; checks must not install/fix dependencies, call Provider APIs, or expose secrets
|
- **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, and `/health`; checks must not install/fix dependencies, call Provider APIs, or expose secrets
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "1.8.0"
|
version = "1.11.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -408,7 +408,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
|||||||
|
|
||||||
### 具名子 Agent(Phase 1)
|
### 具名子 Agent(Phase 1)
|
||||||
|
|
||||||
启用 `agent_orchestration.enabled` 后,PicoBot 会在 Gateway 候选运行代构造时从配置目录下的 `definitions_dir` 加载 `*.md`。相对路径按 `config.json` 所在目录解析,且 canonical path 不得逃逸该目录;角色文件、Provider profile、工具、Skill 和委托边任一无效都会拒绝启动或热重载。支持具名 `foreground`(单/批量)与 Root 发起的具名 `background` 单任务:background 结果经 durable inbox 由主 Agent 的 continuation Turn 汇总,可配合 `emit_signal`(queue/steer)推送内部信号。background 批量与子 Agent 发起的 background 尚未开放;未启用时,旧 general 单任务 background 仍作为兼容路径存在(带迁移提示)。
|
启用 `agent_orchestration.enabled` 后,PicoBot 会在 Gateway 候选运行代构造时从配置目录下的 `definitions_dir` 加载 `*.md`。相对路径按 `config.json` 所在目录解析,且 canonical path 不得逃逸该目录;角色文件、Provider profile、工具、Skill 和委托边任一无效都会拒绝启动或热重载。支持具名 `foreground`(单/批量)与 Root 发起的具名 `background`(单任务或 `tasks[]` 批量):每个 run 独立落库、预留 completion 槽、完成后由主 Agent 的 continuation Turn 单独汇总(空闲时完成即返回),可配合 `emit_signal`(queue/steer)推送内部信号。子 Agent 发起的 background 尚未开放;未启用编排时无法委托(旧匿名 general 已移除)。
|
||||||
|
|
||||||
```md
|
```md
|
||||||
---
|
---
|
||||||
@ -430,7 +430,7 @@ limits:
|
|||||||
你是一名严谨的研究 Agent,只返回与任务有关的结论和证据。
|
你是一名严谨的研究 Agent,只返回与任务有关的结论和证据。
|
||||||
```
|
```
|
||||||
|
|
||||||
`llm_profile` 引用顶层 `agents` 的 key,因此每个具名 Agent 可以使用不同 Provider/Model。工具权限由 Markdown 固定,并与代码内 `DelegationPolicy` 取交集;调用时的 `allowed_tools` 只能收窄旧 general Agent,不能给具名 Agent 扩权。当前可委托工具包括只读文件/内容搜索、`web_fetch`、calculator、普通 browser 动作与 sleep;写文件、Shell、HTTP 写请求、外部发送、计划和管理工具默认仅 Root 可用。`get_skill` 只读取 Definition 声明的 Skill allowlist。
|
每个具名 Agent 的工具集完全由其 Markdown `tools` 列表决定(管理员显式授权),不再有工具侧的可派发门槛;也可内联 `provider`/`model` 直接指定模型(或沿用 `llm_profile` 引用顶层 `agents` key)。`delegate`/`emit_signal`/`get_skill`/`agent_task` 为运行时注入工具,不能写进 `tools`(分别由 `delegates`/`signal`/`skills` 字段派生),`get_skill` 例外作为启用 scoped skill 的开关。WebUI「子 Agent」页可直接增删改定义、启停并选择工具/Skill/Provider/Model。
|
||||||
|
|
||||||
更完整的配置字段说明见 [resources/skills/about-picobot/references/config.md](resources/skills/about-picobot/references/config.md)。
|
更完整的配置字段说明见 [resources/skills/about-picobot/references/config.md](resources/skills/about-picobot/references/config.md)。
|
||||||
|
|
||||||
|
|||||||
7
build.rs
7
build.rs
@ -24,12 +24,7 @@ fn main() {
|
|||||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let agent_name = path
|
let agent_name = path.file_stem().unwrap().to_str().unwrap().to_string();
|
||||||
.file_stem()
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
fs::copy(&path, agents_out_dir.join(format!("{agent_name}.md"))).unwrap();
|
fs::copy(&path, agents_out_dir.join(format!("{agent_name}.md"))).unwrap();
|
||||||
agents.push(agent_name);
|
agents.push(agent_name);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
},
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 19876,
|
"port": 19877,
|
||||||
"require_pairing": true
|
"require_pairing": true
|
||||||
},
|
},
|
||||||
"channels": {},
|
"channels": {},
|
||||||
|
|||||||
@ -206,9 +206,9 @@ Session ID 格式为:
|
|||||||
|
|
||||||
SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。`session::turn_input` 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑,也不得丢失已经从 mailbox 取出的 steering。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态,但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。
|
SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。`session::turn_input` 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑,也不得丢失已经从 mailbox 取出的 steering。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态,但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。
|
||||||
|
|
||||||
当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`,不能另建字符串 notification 通道重复投递。具名子 Agent 的 Phase 1 基础已接入:候选运行代从受信任配置目录严格加载不可变 `AgentCatalog`,Definition 固定 Provider profile、工具/Skill allowlist、委托边和执行限制;`delegate` 使用 `foreground/background` 两个 canonical 生命周期词,批量并发与生命周期正交。具名 foreground 支持不同 Provider、批量并发、显式 `AgentExecutionContext`、祖先环路和委托边校验。具名 background 在 durable run/inbox 完成前明确拒绝;旧 general background 的 `TaskNotification` 兼容路径仍由独立受监督消费者直接投递,不提供新设计的可靠收件箱语义。自动标题属于非关键派生工作:Turn 持久化完成后由 `TaskSupervisor` 调度,Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
|
当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`,不能另建字符串 notification 通道重复投递。具名子 Agent 基础已接入:候选运行代从受信任配置目录严格加载不可变 `AgentCatalog`,Definition 固定 Provider/Model(内联或 `llm_profile`)、工具/Skill allowlist、委托边和执行限制,工具集完全由定义文件决定;`delegate` 使用 `foreground/background` 两个 canonical 生命周期词,批量并发与生命周期正交。具名 foreground 支持批量并发、显式 `AgentExecutionContext`、祖先环路和委托边校验;Root 对具名 Agent 的 background(单任务或批量,批量并发、每个 run 独立 completion 事件)走 durable run/inbox + continuation 投递,空闲时完成即返回。内置 general-purpose 定义随二进制释放,WebUI「子 Agent」页可增删改与启停定义。旧匿名 general 兼容路径已移除。自动标题属于非关键派生工作:Turn 持久化完成后由 `TaskSupervisor` 调度,Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
|
||||||
|
|
||||||
每个 session 最多有一个 active plan,但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 不获得 `todo` 或 `delegate`。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。
|
每个 session 最多有一个 active plan,但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 的工具集由其定义文件决定,能否继续委托由其 `delegates` 白名单决定。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。
|
||||||
|
|
||||||
## 6. 持久化
|
## 6. 持久化
|
||||||
|
|
||||||
@ -219,7 +219,7 @@ SessionManager 负责组装会话上下文:系统提示、Skills、召回的 K
|
|||||||
- 5 秒 busy timeout。
|
- 5 秒 busy timeout。
|
||||||
- schema version 迁移。
|
- schema version 迁移。
|
||||||
|
|
||||||
持久化范围包括 sessions、messages、session turn usage、memories、task plans/items、scheduled jobs、job runs 和 background tasks。消息保存可展示 `reasoning_content`、Provider 私有回放状态、`turn_id`、iteration 和 completion status;私有 Provider 状态不进入 WebSocket/Channel,且只允许回放给同一 Provider。成功 Turn 的 Provider usage 与消息批次在同一事务中写入 `session_turn_usage`,以 `turn_id` 幂等累计会话输入、输出、缓存输入和请求数;升级前的历史没有可归属 usage,统计起点必须显式呈现。成功持久化一个 Turn 后,交互 Channel 收到只包含公开字段的 `CommittedTurnDelta`,其中 `history_revision` 是本批次最高 durable sequence;客户端按 revision 幂等合并,正常完成不重新加载整段历史,断线重连和失败/取消仍使用 `SessionHistory` 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要,不保存正文、reasoning 或签名 payload。修改 schema 时应:
|
持久化范围包括 sessions、messages、session turn usage、memories、task plans/items、scheduled jobs、job runs、agent run/inbox/session state。消息保存可展示 `reasoning_content`、Provider 私有回放状态、`turn_id`、iteration 和 completion status;私有 Provider 状态不进入 WebSocket/Channel,且只允许回放给同一 Provider。成功 Turn 的 Provider usage 与消息批次在同一事务中写入 `session_turn_usage`,以 `turn_id` 幂等累计会话输入、输出、缓存输入和请求数;升级前的历史没有可归属 usage,统计起点必须显式呈现。成功持久化一个 Turn 后,交互 Channel 收到只包含公开字段的 `CommittedTurnDelta`,其中 `history_revision` 是本批次最高 durable sequence;客户端按 revision 幂等合并,正常完成不重新加载整段历史,断线重连和失败/取消仍使用 `SessionHistory` 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要,不保存正文、reasoning 或签名 payload。修改 schema 时应:
|
||||||
|
|
||||||
1. 更新集中式 schema/迁移逻辑。
|
1. 更新集中式 schema/迁移逻辑。
|
||||||
2. 保留已有数据库的升级路径。
|
2. 保留已有数据库的升级路径。
|
||||||
@ -236,7 +236,7 @@ SessionManager 负责组装会话上下文:系统提示、Skills、召回的 K
|
|||||||
|
|
||||||
## 7. 后台任务与生命周期
|
## 7. 后台任务与生命周期
|
||||||
|
|
||||||
`TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。inbound/control routers、inbound lanes、outbound dispatcher、scheduler、session workers、Turn delivery、outbound lanes、后台任务通知消费者、自动标题和子 Agent 后台任务都应通过它注册。
|
`TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。inbound/control routers、inbound lanes、outbound dispatcher、scheduler、session workers、Turn delivery、outbound lanes、自动标题和子 Agent 后台任务都应通过它注册。
|
||||||
|
|
||||||
两种注册方式:
|
两种注册方式:
|
||||||
|
|
||||||
|
|||||||
@ -73,7 +73,7 @@ PicoBot 已经具备一版子 Agent 能力:根交互 Agent 通过 `delegate`
|
|||||||
| Queue | 输入属于后续 Turn;不改变当前 Turn 的模型上下文 |
|
| Queue | 输入属于后续 Turn;不改变当前 Turn 的模型上下文 |
|
||||||
| Steer | 输入尝试进入当前 Turn,并在最近安全边界注入;失败时可靠退化为 queue |
|
| Steer | 输入尝试进入当前 Turn,并在最近安全边界注入;失败时可靠退化为 queue |
|
||||||
| Agent Signal | Background Agent 在运行中主动发出的非终态重要事件 |
|
| Agent Signal | Background Agent 在运行中主动发出的非终态重要事件 |
|
||||||
| Agent Completion | Agent Run 进入 completed/failed/timed_out/cancelled/interrupted 时由运行时自动产生的终态事实;background 按 group policy 投影为 run/group inbox event |
|
| Agent Completion | Agent Run 进入 completed/failed/timed_out/cancelled/interrupted 时由运行时自动产生的终态事实;background 逐 run 投影为 inbox completion event |
|
||||||
| Agent Inbox | 持久化的主 Agent 内部收件箱,是 Background 结果与信号的权威来源 |
|
| Agent Inbox | 持久化的主 Agent 内部收件箱,是 Background 结果与信号的权威来源 |
|
||||||
| Turn Mailbox | 当前 Turn 接受 steer 输入的有界内存邮箱,保留来源、顺序和 durable event ID |
|
| Turn Mailbox | 当前 Turn 接受 steer 输入的有界内存邮箱,保留来源、顺序和 durable event ID |
|
||||||
|
|
||||||
@ -216,7 +216,7 @@ flowchart LR
|
|||||||
Sub[Sub Agent] --> DT
|
Sub[Sub Agent] --> DT
|
||||||
DT --> C[AgentCoordinator]
|
DT --> C[AgentCoordinator]
|
||||||
C --> AC[AgentCatalog]
|
C --> AC[AgentCatalog]
|
||||||
C --> DP[DelegationPolicy]
|
C --> RI[runtime-injected tool marker]
|
||||||
C --> PF[ProviderFactory]
|
C --> PF[ProviderFactory]
|
||||||
C --> TR[Filtered ToolRegistry]
|
C --> TR[Filtered ToolRegistry]
|
||||||
C --> AR[AgentRunner]
|
C --> AR[AgentRunner]
|
||||||
@ -239,11 +239,11 @@ flowchart LR
|
|||||||
替代当前承担过多职责的 `SubAgentManager`,负责:
|
替代当前承担过多职责的 `SubAgentManager`,负责:
|
||||||
|
|
||||||
- 解析 caller/target 和授权委托边。
|
- 解析 caller/target 和授权委托边。
|
||||||
- 创建 run/group ID、父子关系和预算。
|
- 创建 run ID、父子关系和预算。
|
||||||
- 持久化接纳状态后启动 AgentRunner。
|
- 持久化接纳状态后启动 AgentRunner。
|
||||||
- 管理 foreground await、background spawn、取消和超时。
|
- 管理 foreground await、background spawn、取消和超时。
|
||||||
- 控制全局、session、Agent 与任务树的 run admission quota,以及 Provider/普通工具步骤的 execution permit;两类配额不共用生命周期。
|
- 控制全局、session、Agent 与任务树的 run admission quota,以及 Provider/普通工具步骤的 execution permit;两类配额不共用生命周期。
|
||||||
- 生成自动 completion terminal outcome,并按 group policy 物化 inbox event。
|
- 生成自动 completion terminal outcome,并逐 run 物化 inbox event。
|
||||||
- 向 WorkManager 条件提交计划子项结果。
|
- 向 WorkManager 条件提交计划子项结果。
|
||||||
|
|
||||||
### 6.3 AgentRunner
|
### 6.3 AgentRunner
|
||||||
@ -259,7 +259,7 @@ flowchart LR
|
|||||||
|
|
||||||
### 6.4 AgentEventSink / AgentResultRouter
|
### 6.4 AgentEventSink / AgentResultRouter
|
||||||
|
|
||||||
`AgentEventSink` 负责持久化 signal 和按 policy 生成的 run/group completion event;`AgentResultRouter` 负责把 pending inbox event 送到原 root session。Router 的内存 wakeup 是加速器,SQLite inbox 才是权威来源。
|
`AgentEventSink` 负责持久化 signal 和 run completion event(每个 run 终态独立生成);`AgentResultRouter` 负责把 pending inbox event 送到原 root session。Router 的内存 wakeup 是加速器,SQLite inbox 才是权威来源。
|
||||||
|
|
||||||
### 6.5 ProviderFactory
|
### 6.5 ProviderFactory
|
||||||
|
|
||||||
@ -290,30 +290,24 @@ Root 的 allowed targets 来自 `root_delegates`;子 Agent 来自自身 Markdo
|
|||||||
|
|
||||||
### 7.2 工具权限
|
### 7.2 工具权限
|
||||||
|
|
||||||
调用参数不再提供 `allowed_tools` 扩权。有效工具集为:
|
工具可用性完全由具名 Agent 定义文件决定:有效工具集为
|
||||||
|
|
||||||
```text
|
```text
|
||||||
AgentDefinition.tools
|
AgentDefinition.tools ∩ 当前运行代已注册工具
|
||||||
∩ 当前运行代已注册工具
|
|
||||||
∩ 系统可委托工具策略
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Tool 增加安全元数据:
|
不再有工具侧的「可派发」标志。Tool trait 只保留一个运行时注入标记:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
enum DelegationPolicy {
|
/// 该工具由运行上下文按需注入(delegate 目标、信号契约、skill allowlist),
|
||||||
RootOnly,
|
/// 不能直接写进 Definition 的 `tools` 列表。普通工具默认 false。
|
||||||
Delegatable,
|
fn runtime_injected(&self) -> bool { false }
|
||||||
RuntimeInjected,
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `reload_config`、`todo`、管理配置和任意外部发送默认 `RootOnly`。
|
- `delegate`、`emit_signal`、`get_skill`、`agent_task` 标记 `runtime_injected=true`,由 Coordinator 根据 `delegates`/`signal`/`skills` 字段和运行上下文注入,不能仅靠 Markdown 的 `tools` 声明;`get_skill` 是唯一例外——把它写进 `tools` 表示启用 scoped skill 包装器。
|
||||||
- 普通只读工具在明确审查后标记 `Delegatable`。
|
- 其余任何已注册工具(含 `bash`、`send_message`、`todo` 等)都可由管理员在定义文件的 `tools` 列表显式授权,这是知情的选择。
|
||||||
- `delegate`、`emit_signal` 等由 Coordinator 根据运行上下文注入,标记 `RuntimeInjected`,不能仅靠 Markdown 获得。
|
|
||||||
- 新工具默认 `RootOnly`,避免未来工具无意暴露。
|
|
||||||
|
|
||||||
目标 Agent 可以拥有调用方没有的专业工具,因为委托边本身就是管理员授权调用该能力;模型不能在单次调用中越过 Definition 扩权。
|
`allowed_tools` 调用参数只能收窄 Definition 的工具集,不能扩权;模型不能在单次调用中越过 Definition。
|
||||||
|
|
||||||
## 8. AgentExecutionContext
|
## 8. AgentExecutionContext
|
||||||
|
|
||||||
@ -324,7 +318,6 @@ pub struct AgentExecutionContext {
|
|||||||
pub root_session_id: String,
|
pub root_session_id: String,
|
||||||
pub root_turn_id: Option<String>,
|
pub root_turn_id: Option<String>,
|
||||||
pub run_id: String,
|
pub run_id: String,
|
||||||
pub group_id: Option<String>,
|
|
||||||
pub parent_run_id: Option<String>,
|
pub parent_run_id: Option<String>,
|
||||||
pub caller_agent_id: String,
|
pub caller_agent_id: String,
|
||||||
pub current_agent_id: String,
|
pub current_agent_id: String,
|
||||||
@ -387,7 +380,6 @@ agent_task → get / list / cancel / get_result
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"group_id": "group-123",
|
|
||||||
"runs": [
|
"runs": [
|
||||||
{"run_id": "run-a", "agent": "researcher", "status": "queued"},
|
{"run_id": "run-a", "agent": "researcher", "status": "queued"},
|
||||||
{"run_id": "run-b", "agent": "coder", "status": "queued"},
|
{"run_id": "run-b", "agent": "coder", "status": "queued"},
|
||||||
@ -411,11 +403,10 @@ agent_task → get / list / cancel / get_result
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `signal`:运行中主动事件的投递方式。
|
- `signal`:运行中主动事件的投递方式(当前由 Definition 的 `signal:` 契约 `delivery` 字段决定 queue/steer)。
|
||||||
- `completion`:正常终态结果的投递方式,默认 queue。
|
- `completion` / `failure`:投递契约为未来扩展;当前 completion 与 failure 恒为 queue,尚未开放可配置 steer。
|
||||||
- `failure`:失败、超时、异常中断的投递方式,默认 queue,可显式 steer。
|
|
||||||
|
|
||||||
Foreground 请求直接把 completion 作为 tool result 返回,因此不接受 completion delivery。第一阶段仅允许 Root 创建 background run;子 Agent 之间可以 foreground 委托。待持久化 task tree 与 root inbox 稳定后,再允许子 Agent 创建最终归属于 root session 的 background run。
|
Foreground 请求直接把 completion 作为 tool result 返回,因此不接受 completion delivery。仅允许 Root 创建 background run(单任务或 `tasks` 批量,批量并发执行、每个 run 独立 completion 事件);子 Agent 发起的 background 尚未开放。
|
||||||
|
|
||||||
### 9.5 Foreground 返回
|
### 9.5 Foreground 返回
|
||||||
|
|
||||||
@ -436,7 +427,7 @@ Foreground 请求直接把 completion 作为 tool result 返回,因此不接
|
|||||||
|
|
||||||
### 9.6 幂等与接纳
|
### 9.6 幂等与接纳
|
||||||
|
|
||||||
Background delegate 只有在 run/group 记录持久化成功、运行代 admission 成功、completion inbox 容量已经预留且执行任务已经被 TaskSupervisor 接纳后才返回成功。可选 `idempotency_key` 在 `(root_session_id, caller_scope_id, key)` 范围唯一,用于 Provider 重试时避免重复创建任务;Root 的 `caller_scope_id` 固定为非空字面量 `ROOT`,数据库使用仅覆盖非空 key 的 partial unique index,避免 SQLite `NULL` 破坏去重。
|
Background delegate 只有在 run 记录持久化成功、运行代 admission 成功、completion inbox 容量已经预留且执行任务已经被 TaskSupervisor 接纳后才返回成功。可选 `idempotency_key` 在 `(root_session_id, caller_scope_id, key)` 范围唯一,用于 Provider 重试时避免重复创建任务;Root 的 `caller_scope_id` 固定为非空字面量 `ROOT`,数据库使用仅覆盖非空 key 的 partial unique index,避免 SQLite `NULL` 破坏去重。
|
||||||
|
|
||||||
## 10. Prompt 与上下文隔离
|
## 10. Prompt 与上下文隔离
|
||||||
|
|
||||||
@ -506,7 +497,7 @@ stateDiagram-v2
|
|||||||
| Signal | 子 Agent 主动调用 `emit_signal` | 否 | 重要中间状态、监控告警 |
|
| Signal | 子 Agent 主动调用 `emit_signal` | 否 | 重要中间状态、监控告警 |
|
||||||
| Completion outcome | AgentCoordinator 自动生成 | 是 | completed/failed/timed_out/cancelled/interrupted |
|
| Completion outcome | AgentCoordinator 自动生成 | 是 | completed/failed/timed_out/cancelled/interrupted |
|
||||||
|
|
||||||
最终结果不能依赖模型记得调用工具。即使 Provider 异常、超时或任务被取消,Coordinator 也必须持久化 run 的终态 outcome。Foreground 将其返回为 tool 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
|
### 12.2 EmitSignalTool
|
||||||
|
|
||||||
@ -550,7 +541,7 @@ Coordinator 强制执行:
|
|||||||
|
|
||||||
### 12.3 Completion 去重
|
### 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 与附件职责
|
## 13. SendMessage、EmitSignal 与附件职责
|
||||||
|
|
||||||
@ -564,7 +555,7 @@ attach_artifact 工具产物 → 当前 Turn → DeliveryCoordinator(当前回
|
|||||||
|
|
||||||
### 13.1 send_message
|
### 13.1 send_message
|
||||||
|
|
||||||
只负责用户明确授权的跨 Channel/跨会话外部消息,具有真实外部副作用。默认 `RootOnly`,目标和文件参数继续受 Channel/file transfer 限制。`origin` 不再由模型自由填写,改由 ToolExecutionContext 生成,避免来源伪造。
|
只负责用户明确授权的跨 Channel/跨会话外部消息,具有真实外部副作用。目标和文件参数继续受 Channel/file transfer 限制;`origin` 不再由模型自由填写,改由 ToolExecutionContext 生成,避免来源伪造。是否对子 Agent 开放由管理员在定义文件的 `tools` 里显式决定。
|
||||||
|
|
||||||
### 13.2 emit_signal
|
### 13.2 emit_signal
|
||||||
|
|
||||||
@ -576,7 +567,7 @@ attach_artifact 工具产物 → 当前 Turn → DeliveryCoordinator(当前回
|
|||||||
|
|
||||||
### 13.4 自动 completion
|
### 13.4 自动 completion
|
||||||
|
|
||||||
Completion 不是工具。AgentRunner 的终结路径统一返回 terminal outcome,Coordinator 保存结果并按 foreground/background 与 group policy 创建相应投递 event,避免模型遗漏或重复。
|
Completion 不是工具。AgentRunner 的终结路径统一返回 terminal outcome,Coordinator 保存结果并按 foreground/background 创建相应投递 event,避免模型遗漏或重复。
|
||||||
|
|
||||||
## 14. 持久化模型
|
## 14. 持久化模型
|
||||||
|
|
||||||
@ -586,7 +577,6 @@ Completion 不是工具。AgentRunner 的终结路径统一返回 terminal outco
|
|||||||
agent_runs
|
agent_runs
|
||||||
----------
|
----------
|
||||||
id TEXT PRIMARY KEY
|
id TEXT PRIMARY KEY
|
||||||
group_id TEXT
|
|
||||||
root_session_id TEXT NOT NULL
|
root_session_id TEXT NOT NULL
|
||||||
root_turn_id TEXT
|
root_turn_id TEXT
|
||||||
parent_run_id TEXT
|
parent_run_id TEXT
|
||||||
@ -634,34 +624,15 @@ WHERE idempotency_key IS NOT NULL;
|
|||||||
|
|
||||||
不保存 API key、Authorization header、Provider 私有 reasoning state 或完整 connection URL。`cost` 是 nullable projection:Provider profile 未配置价格时必须为 `NULL`,usage token 不受影响。
|
不保存 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
|
批量委托不再建组头:单/批量请求的 `idempotency_key` 都绑定各自的 run 行,批量只是多个独立 run 的集合,使用 `(root_session_id, caller_scope_id, idempotency_key)` partial unique index,避免批量 children 互相冲突。
|
||||||
agent_run_groups
|
|
||||||
----------------
|
|
||||||
id
|
|
||||||
root_session_id
|
|
||||||
caller_run_id
|
|
||||||
caller_scope_id
|
|
||||||
idempotency_key
|
|
||||||
mode
|
|
||||||
completion_policy all | each
|
|
||||||
expected_runs
|
|
||||||
terminal_runs
|
|
||||||
completion_slot_reserved
|
|
||||||
deadline_at
|
|
||||||
status
|
|
||||||
created_at
|
|
||||||
finished_at
|
|
||||||
```
|
|
||||||
|
|
||||||
批量请求的 `idempotency_key` 绑定 group;其 child run 的 key 为 `NULL`。单任务请求没有 group 时,key 绑定 run。两者分别使用 `(root_session_id, caller_scope_id, idempotency_key)` partial unique index,避免批量 children 互相冲突。
|
批量 background 的每个 run 终态都立即创建独立 completion event,不等待 sibling;主 Agent 空闲时收到即处理(完成即返回),忙碌时由公平调度合并或等待。不存在 all/each 策略。
|
||||||
|
|
||||||
批量 background 默认 `completion_policy=all`:单个 run 终态只更新 group 计数,全部 run 终态或 group deadline 到达后创建唯一 `group_completion` 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 事实重算计数,发现差异时修复并记录告警。
|
||||||
|
|
||||||
容量判断不能在每次接纳时通过无锁 `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 事实重算计数,发现差异时修复并记录告警。
|
|
||||||
|
|
||||||
### 14.3 agent_inbox_events
|
### 14.3 agent_inbox_events
|
||||||
|
|
||||||
@ -670,11 +641,8 @@ agent_inbox_events
|
|||||||
------------------
|
------------------
|
||||||
id TEXT PRIMARY KEY
|
id TEXT PRIMARY KEY
|
||||||
root_session_id TEXT NOT NULL
|
root_session_id TEXT NOT NULL
|
||||||
scope_kind run | group
|
run_id TEXT NOT NULL
|
||||||
scope_id TEXT NOT NULL
|
event_type signal | completion
|
||||||
run_id TEXT
|
|
||||||
group_id TEXT
|
|
||||||
event_type signal | completion | group_completion
|
|
||||||
event_key TEXT NOT NULL
|
event_key TEXT NOT NULL
|
||||||
delivery queue | steer
|
delivery queue | steer
|
||||||
requires_continuation BOOLEAN NOT NULL DEFAULT TRUE
|
requires_continuation BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
@ -693,16 +661,12 @@ dead_lettered_at INTEGER
|
|||||||
fallback_notified_at INTEGER
|
fallback_notified_at INTEGER
|
||||||
revision INTEGER NOT NULL
|
revision INTEGER NOT NULL
|
||||||
|
|
||||||
UNIQUE(scope_kind, scope_id, event_type, event_key)
|
UNIQUE(run_id, event_type, event_key)
|
||||||
CHECK(
|
|
||||||
(scope_kind = 'run' AND run_id IS NOT NULL AND group_id IS NULL AND scope_id = run_id) OR
|
|
||||||
(scope_kind = 'group' AND group_id IS NOT NULL AND run_id IS NULL AND scope_id = group_id)
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
完整结果保存在 `agent_runs.result`,inbox payload 默认只放有界摘要、元数据和 result reference,避免复制大文本。
|
完整结果保存在 `agent_runs.result`,inbox payload 默认只放有界摘要、元数据和 result reference,避免复制大文本。
|
||||||
|
|
||||||
event key 始终非空:无 dedupe key 的 signal 用 `signal:<event_uuid>`,有 dedupe key 的 signal 加冷却窗口 ID;run completion 固定为 `completion:terminal-v1`,group completion 固定为 `group-completion:terminal-v1`。由同一次 `/stop` 产生、无需主 Agent再次解释的 cancelled completion 使用 `requires_continuation=false`,在终态事务中直接记为 consumed,但仍保留事件审计和客户端投影。
|
event key 始终非空:无 dedupe key 的 signal 用 `signal:<event_uuid>`,有 dedupe key 的 signal 加冷却窗口 ID;run completion 固定为 `completion:<run_id>`。由同一次 `/stop` 产生、无需主 Agent再次解释的 cancelled completion 使用 `requires_continuation=false`,在终态事务中直接记为 consumed,但仍保留事件审计和客户端投影。
|
||||||
|
|
||||||
### 14.4 原子事务
|
### 14.4 原子事务
|
||||||
|
|
||||||
@ -710,16 +674,13 @@ Agent completion 必须在一个 Storage 事务中:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
UPDATE agent_runs terminal state/result/usage
|
UPDATE agent_runs terminal state/result/usage
|
||||||
UPDATE agent_run_groups terminal count/status
|
|
||||||
CONSUME reserved completion capacity
|
CONSUME reserved completion capacity
|
||||||
INSERT run completion OR group completion ... ON CONFLICT DO NOTHING
|
INSERT run completion ... ON CONFLICT DO NOTHING
|
||||||
UPDATE bound task item by execution_id
|
UPDATE bound task item by execution_id
|
||||||
COMMIT
|
COMMIT
|
||||||
```
|
```
|
||||||
|
|
||||||
事务失败时不能对外宣称任务完成。内存 wakeup 只有在 commit 成功后发送。
|
事务失败时不能对外宣称任务完成。内存 wakeup 只有在 commit 成功后发送。每个 run 的终端事务独立生成自己的 completion 事件;迟到终态只更新自己的 run 行,不影响其它 sibling。
|
||||||
|
|
||||||
`completion_policy=all` 只有把 group 从 non-terminal 条件更新为 terminal 的事务赢家可以插入 group completion;其他 sibling 的迟到终态只完成自己的 run 条件更新,不能重复生成 event。
|
|
||||||
|
|
||||||
### 14.5 continuation 消息与投递绑定
|
### 14.5 continuation 消息与投递绑定
|
||||||
|
|
||||||
@ -758,7 +719,6 @@ pub enum TurnInputSource {
|
|||||||
User,
|
User,
|
||||||
AgentSignal { run_id: String, agent_id: String },
|
AgentSignal { run_id: String, agent_id: String },
|
||||||
AgentCompletion { run_id: String, agent_id: String },
|
AgentCompletion { run_id: String, agent_id: String },
|
||||||
AgentGroupCompletion { group_id: String },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum InputDelivery {
|
pub enum InputDelivery {
|
||||||
@ -859,7 +819,7 @@ Steer event 在没有活动 Turn 时按 queue 处理。用户输入通常优先
|
|||||||
```rust
|
```rust
|
||||||
enum AgentTaskSource {
|
enum AgentTaskSource {
|
||||||
UserInput,
|
UserInput,
|
||||||
BackgroundAgentResults { event_ids: Vec<String>, group_id: Option<String> },
|
BackgroundAgentResults { event_ids: Vec<String> },
|
||||||
ScheduledTask,
|
ScheduledTask,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -1074,7 +1034,7 @@ WebUI 管理面展示:
|
|||||||
### 20.2 Chat 表现
|
### 20.2 Chat 表现
|
||||||
|
|
||||||
- Foreground delegate 继续作为当前 Turn 的可折叠工具块。
|
- Foreground delegate 继续作为当前 Turn 的可折叠工具块。
|
||||||
- Background delegate 启动后显示 run/group ID,不假装任务已完成。
|
- Background delegate 启动后显示 run ID,不假装任务已完成。
|
||||||
- AgentSignal 显示为独立运行时信号卡片,不显示成用户气泡。
|
- AgentSignal 显示为独立运行时信号卡片,不显示成用户气泡。
|
||||||
- queue completion 在主 Agent内部 continuation 后只显示主 Agent汇总回复。
|
- queue completion 在主 Agent内部 continuation 后只显示主 Agent汇总回复。
|
||||||
- steer 信号可以在当前 Turn 工具状态中显示“已接纳”,最终历史由 Turn commit 校准。
|
- steer 信号可以在当前 Turn 工具状态中显示“已接纳”,最终历史由 Turn commit 校准。
|
||||||
@ -1120,34 +1080,21 @@ WsOutbound::AgentEventUpdated { session_id, revision, event }
|
|||||||
|
|
||||||
所有重试必须有次数、退避、deadline 和分类;永久错误立即终态化,不能无界重试。默认最多 8 次,退避为 `1s/5s/30s/2m/10m` 后封顶 10 分钟,并同时受 inbox event TTL 限制。
|
所有重试必须有次数、退避、deadline 和分类;永久错误立即终态化,不能无界重试。默认最多 8 次,退避为 `1s/5s/30s/2m/10m` 后封顶 10 分钟,并同时受 inbox event TTL 限制。
|
||||||
|
|
||||||
dead-letter 记录最终原因和时间,并通过 OutboundDispatcher 最多发送一次有界 system fallback,只包含 run/group ID、终态和查询提示;`fallback_notified_at` 保证幂等。fallback 渠道失败时,SQLite run/event 记录和管理 UI 是最终诊断出口,不能把 dead-letter 伪装成已交付。
|
dead-letter 记录最终原因和时间,并通过 OutboundDispatcher 最多发送一次有界 system fallback,只包含 run ID、终态和查询提示;`fallback_notified_at` 保证幂等。fallback 渠道失败时,SQLite run/event 记录和管理 UI 是最终诊断出口,不能把 dead-letter 伪装成已交付。
|
||||||
|
|
||||||
## 22. 兼容迁移
|
## 22. 兼容迁移
|
||||||
|
|
||||||
### 22.1 Delegate 参数
|
### 22.1 Delegate 参数
|
||||||
|
|
||||||
旧模式映射:
|
旧模式映射已在迁移完成后移除:`inline`/`parallel` 别名与 legacy general 委托均不再解析,只保留 canonical `foreground`/`background` 生命周期词与具名 `target`。
|
||||||
|
|
||||||
```text
|
|
||||||
inline → foreground
|
|
||||||
parallel → foreground + tasks[]
|
|
||||||
background → background
|
|
||||||
```
|
|
||||||
|
|
||||||
过渡期只解析代码中确实存在的旧值并在 tool result/日志中给出弃用提示;`async` 从未是有效值,不新增该别名。新 system prompt 只描述 canonical 值 `foreground/background`。
|
|
||||||
|
|
||||||
### 22.2 allowed_tools
|
### 22.2 allowed_tools
|
||||||
|
|
||||||
旧 `allowed_tools` 首先变成只能收紧 Definition.tools 的兼容字段,不能扩权;随后从 schema 删除。没有 target 的旧委托映射到内置 `general` Agent Definition。
|
`allowed_tools` 只能收紧 Definition.tools,不能扩权。没有 `target` 的委托不再支持(旧匿名 general 已移除);内置 `general-purpose` Agent 定义随二进制释放到 `~/.picobot/agents/`,开箱即用。
|
||||||
|
|
||||||
### 22.3 background_tasks
|
### 22.3 background_tasks
|
||||||
|
|
||||||
新增 `agent_runs` 后:
|
旧 `background_tasks` 表已在 schema v7 中删除(`DROP TABLE`),旧 adapter 与 direct notification 路径一并移除;`/api/tasks` 只读取 `agent_runs`。无历史兼容需求。
|
||||||
|
|
||||||
- 新任务只写新表。
|
|
||||||
- 管理 API 在过渡期 union 读取旧 `background_tasks` 与新 `agent_runs`。
|
|
||||||
- 旧终态记录按原 TTL 清理,不强制迁移正文。
|
|
||||||
- 旧 pending/running 记录在升级启动时按 interrupted/cancelled 规则收敛。
|
|
||||||
|
|
||||||
### 22.4 版本与文档
|
### 22.4 版本与文档
|
||||||
|
|
||||||
@ -1162,8 +1109,8 @@ background → background
|
|||||||
- Delegate schema 使用 target + foreground/background canonical modes。
|
- Delegate schema 使用 target + foreground/background canonical modes。
|
||||||
- 批量 foreground 并发执行并聚合。
|
- 批量 foreground 并发执行并聚合。
|
||||||
- 显式 AgentExecutionContext 和委托图授权。
|
- 显式 AgentExecutionContext 和委托图授权。
|
||||||
- 明确 skills/memory 不继承、具名 Agent browser scope 隔离和 legacy general scope 兼容。
|
- 明确 skills/memory 不继承、具名 Agent browser scope 隔离。
|
||||||
- 保持旧 background 通知路径作为兼容,但不开放嵌套 background。
|
- 不开放嵌套 background(子 Agent 发起的 background)。
|
||||||
|
|
||||||
### Phase 2A:AgentLoop 结构化取消
|
### Phase 2A:AgentLoop 结构化取消
|
||||||
|
|
||||||
@ -1173,7 +1120,7 @@ background → background
|
|||||||
|
|
||||||
### Phase 2B:统一 Agent Run 持久化
|
### Phase 2B:统一 Agent Run 持久化
|
||||||
|
|
||||||
- 新增 `agent_runs`、`agent_run_groups`、Storage transaction API。
|
- 新增 `agent_runs`、Storage transaction API。
|
||||||
- 拆分 `delegate` 与 `agent_task`。
|
- 拆分 `delegate` 与 `agent_task`。
|
||||||
- Foreground 结果也持久化,修复截断结果不可查询。
|
- Foreground 结果也持久化,修复截断结果不可查询。
|
||||||
- 实现预算、run admission quota 与 step execution permit 释放。
|
- 实现预算、run admission quota 与 step execution permit 释放。
|
||||||
@ -1216,7 +1163,7 @@ src/agent/
|
|||||||
└── sub_agent.rs 迁移兼容层,最终缩减或删除
|
└── sub_agent.rs 迁移兼容层,最终缩减或删除
|
||||||
|
|
||||||
src/tools/
|
src/tools/
|
||||||
├── delegate.rs create run/group only
|
├── delegate.rs create run only
|
||||||
├── agent_task.rs get/list/cancel/get_result
|
├── agent_task.rs get/list/cancel/get_result
|
||||||
├── emit_signal.rs constrained internal signal
|
├── emit_signal.rs constrained internal signal
|
||||||
├── sleep.rs wake-aware wait
|
├── sleep.rs wake-aware wait
|
||||||
@ -1260,7 +1207,7 @@ src/storage/
|
|||||||
|
|
||||||
- 不同 Agent 使用不同 provider/model profile。
|
- 不同 Agent 使用不同 provider/model profile。
|
||||||
- Provider storage/observer 正确注入。
|
- Provider storage/observer 正确注入。
|
||||||
- RootOnly 工具不能通过 Markdown 或兼容 allowed_tools 获得。
|
- 工具集完全由定义文件的 `tools` 列表决定;runtime-injected 工具不能通过 Markdown 声明。
|
||||||
- runtime-injected delegate/emit_signal 只在上下文允许时存在。
|
- runtime-injected delegate/emit_signal 只在上下文允许时存在。
|
||||||
- 并行 run 的 browser/resource scope 隔离。
|
- 并行 run 的 browser/resource scope 隔离。
|
||||||
- persistent browser profile 可显式共享;具名 Agent不继承 transient parent scope。
|
- persistent browser profile 可显式共享;具名 Agent不继承 transient parent scope。
|
||||||
@ -1280,7 +1227,7 @@ src/storage/
|
|||||||
- user mpsc 满不影响 durable wake;wake revision 丢失后扫描可恢复。
|
- user mpsc 满不影响 durable wake;wake revision 丢失后扫描可恢复。
|
||||||
- 用户持续输入时 burst/age 公平上限仍调度 continuation。
|
- 用户持续输入时 burst/age 公平上限仍调度 continuation。
|
||||||
- completion capacity 在 background 接纳时预留,signal 不能抢占。
|
- completion capacity 在 background 接纳时预留,signal 不能抢占。
|
||||||
- `each` 逐 run 提前投递;`all` 只触发一次 group 汇总 Turn。
|
- 每个 run 独立 completion,逐 run 投递;无 group 汇总 Turn。
|
||||||
- retries/TTL 耗尽进入 dead-letter,system fallback 最多发送一次。
|
- retries/TTL 耗尽进入 dead-letter,system fallback 最多发送一次。
|
||||||
|
|
||||||
### 25.5 Signal
|
### 25.5 Signal
|
||||||
@ -1332,7 +1279,7 @@ src/storage/
|
|||||||
3. Foreground/Background 只描述委托方等待行为;并发是独立调度维度。
|
3. Foreground/Background 只描述委托方等待行为;并发是独立调度维度。
|
||||||
4. Queue 输入永不泄漏正文到当前 Turn;Steer 只在安全边界注入。
|
4. Queue 输入永不泄漏正文到当前 Turn;Steer 只在安全边界注入。
|
||||||
5. Signal 先持久化后唤醒;内存通知不是事实来源。
|
5. Signal 先持久化后唤醒;内存通知不是事实来源。
|
||||||
6. Signal 是非终态事件;run Completion 由运行时自动生成且恰好对应一个 run 终态,`all` policy 的 Group Completion 恰好对应一个 group 终态。
|
6. Signal 是非终态事件;run Completion 由运行时自动生成且恰好对应一个 run 终态(批量背景也逐 run 生成,无 group completion)。
|
||||||
7. SendMessage 是外部输出,EmitSignal 是内部输入,不能用一个公开万能工具混合权限。
|
7. SendMessage 是外部输出,EmitSignal 是内部输入,不能用一个公开万能工具混合权限。
|
||||||
8. Durable Agent event 在 `/stop`、Turn 失败或 Gateway 崩溃时不能静默丢失。
|
8. Durable Agent event 在 `/stop`、Turn 失败或 Gateway 崩溃时不能静默丢失。
|
||||||
9. Agent Definition 和 Provider 绑定 runtime generation;运行中不热切换。
|
9. Agent Definition 和 Provider 绑定 runtime generation;运行中不热切换。
|
||||||
@ -1351,7 +1298,7 @@ src/storage/
|
|||||||
```text
|
```text
|
||||||
Markdown Agent Definition
|
Markdown Agent Definition
|
||||||
↓
|
↓
|
||||||
AgentCatalog + DelegationPolicy
|
AgentCatalog + runtime-injected 工具标记
|
||||||
↓
|
↓
|
||||||
AgentCoordinator
|
AgentCoordinator
|
||||||
├─ foreground:并发执行、父等待、tool result 返回
|
├─ foreground:并发执行、父等待、tool result 返回
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -129,7 +129,7 @@ Provider stream、可取消等待和工具批次外层都观察 token;AgentRun
|
|||||||
事件进入 dead-letter 后:
|
事件进入 dead-letter 后:
|
||||||
|
|
||||||
1. 保存最终原因和 `dead_lettered_at`,在任务树/API 中持续可见。
|
1. 保存最终原因和 `dead_lettered_at`,在任务树/API 中持续可见。
|
||||||
2. 通过 OutboundDispatcher 最多发送一次有界 system fallback,内容只包含 run/group ID、终态和查询提示,不复制大结果。
|
2. 通过 OutboundDispatcher 最多发送一次有界 system fallback,内容只包含 run ID、终态和查询提示,不复制大结果。
|
||||||
3. 用 `fallback_notified_at` 保证 fallback 幂等;渠道也失败时仍以 SQLite 记录和管理 UI 为最终可诊断出口。
|
3. 用 `fallback_notified_at` 保证 fallback 幂等;渠道也失败时仍以 SQLite 记录和管理 UI 为最终可诊断出口。
|
||||||
|
|
||||||
### B3 — `completion_policy=each`
|
### B3 — `completion_policy=each`
|
||||||
@ -197,7 +197,7 @@ Provider stream、可取消等待和工具批次外层都观察 token;AgentRun
|
|||||||
|-------|------------------|
|
|-------|------------------|
|
||||||
| 1 | 除原内容外,明确 browser 兼容 scope、skills/memory 规则和 sub-run sleep 行为 |
|
| 1 | 除原内容外,明确 browser 兼容 scope、skills/memory 规则和 sub-run sleep 行为 |
|
||||||
| 2A | CancellationToken 贯穿 AgentLoop,先以现有 root Turn/sleep/Provider tests 锁定取消语义 |
|
| 2A | CancellationToken 贯穿 AgentLoop,先以现有 root Turn/sleep/Provider tests 锁定取消语义 |
|
||||||
| 2B | run/group 持久化、step execution gate、foreground child cancellation 和结果查询 |
|
| 2B | run 持久化、step execution gate、foreground child cancellation 和结果查询 |
|
||||||
| 3 | durable wake lane、capacity reservation、hidden continuation trigger、bounded fairness、dead-letter fallback 与 WebSocket run/event projection |
|
| 3 | durable wake lane、capacity reservation、hidden continuation trigger、bounded fairness、dead-letter fallback 与 WebSocket run/event projection |
|
||||||
| 4 | typed TurnMailbox、emit_signal、steer admission,以及同一 `AgentEventUpdated` 的 Signal 卡片呈现 |
|
| 4 | typed TurnMailbox、emit_signal、steer admission,以及同一 `AgentEventUpdated` 的 Signal 卡片呈现 |
|
||||||
| 5 | root Turn wake-aware sleep;sub-run 保持 timer/cancellation-only |
|
| 5 | root Turn wake-aware sleep;sub-run 保持 timer/cancellation-only |
|
||||||
|
|||||||
@ -13,8 +13,8 @@ PicoBot 是一个基于 Rust 的个人 AI 助手运行时,包含本地 Gateway
|
|||||||
|
|
||||||
| 文件 | 内容 |
|
| 文件 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `references/config.md` | 配置字段详解:providers、models、agents、gateway、client、channels、memory、mcp、browser |
|
| `references/config.md` | 配置字段详解:providers、models、agents、agent_orchestration、gateway、client、channels、memory、mcp、browser |
|
||||||
| `references/db-schema.md` | 数据库表结构与运行约束:sessions、messages、memories、scheduled_jobs、job_runs、llm_calls、background_tasks |
|
| `references/db-schema.md` | 数据库表结构与运行约束:sessions、messages、memories、task plans/items、scheduled_jobs、job_runs、llm_calls、agent run/inbox/state |
|
||||||
| `references/architecture.md` | 核心架构:消息并发、会话系统、持久化、生命周期、上下文压缩、记忆、MCP、子 Agent |
|
| `references/architecture.md` | 核心架构:消息并发、会话系统、持久化、生命周期、上下文压缩、记忆、MCP、子 Agent |
|
||||||
| `references/faq.md` | 常见问题:模型切换、渠道添加、Skill 安装、历史查询、定时任务、MCP 等 |
|
| `references/faq.md` | 常见问题:模型切换、渠道添加、Skill 安装、历史查询、定时任务、MCP 等 |
|
||||||
| `references/commands.md` | 常用命令:编译、启动网关、Docker/WebUI 设备配对、启动客户端、运行测试 |
|
| `references/commands.md` | 常用命令:编译、启动网关、Docker/WebUI 设备配对、启动客户端、运行测试 |
|
||||||
|
|||||||
@ -50,7 +50,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
|||||||
- 每个活动 Turn 独占一个 TurnSink;平台 message ID 和 reaction 清理状态只存在于 sink 内
|
- 每个活动 Turn 独占一个 TurnSink;平台 message ID 和 reaction 清理状态只存在于 sink 内
|
||||||
- Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext`
|
- Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext`
|
||||||
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry
|
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry
|
||||||
- 具名子 Agent 从运行代不可变 `AgentCatalog` 加载,Definition 固定 Provider profile、工具/Skill allowlist、委托边与限制;新工具默认 RootOnly。支持单个/批量 foreground(批量并发、按请求顺序返回)和显式父子授权;Root 对具名 Agent 的 background 单任务走 durable run/inbox + continuation 投递(结果不再直接通知 Channel)。禁用编排时旧 general background 仍通过 MessageBus 直接通知原会话
|
- 具名子 Agent 从运行代不可变 `AgentCatalog` 加载,Definition 固定 Provider/Model、工具/Skill allowlist、委托边与限制;工具集完全由定义文件的 `tools` 列表决定(管理员显式授权),`delegate`/`emit_signal`/`get_skill`/`agent_task` 为运行时注入不可静态声明。支持单个/批量 foreground 和显式父子授权;Root 对具名 Agent 的 background(单任务或批量)走 durable run/inbox + continuation 投递,每个 run 独立完成、空闲时完成即返回。内置 general-purpose 定义随二进制释放到 `~/.picobot/agents/`,WebUI「子 Agent」页可增删改与启停定义
|
||||||
- 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
|
- 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
|
||||||
- WebUI 聊天复用 `/ws` 与 `cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
|
- WebUI 聊天复用 `/ws` 与 `cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
|
||||||
- WebUI 使用 Svelte 5 + Vite,Bits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
|
- 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 目标)会拒绝整个候选运行代,绝不静默裁剪。
|
- `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 与模型,运行中不热切换。
|
- `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` 预算。
|
- `delegates`:出边白名单,运行时还校验 ancestry 重复、`max_tree_depth` 与树级 `max_runs_per_tree` 预算。
|
||||||
- `signal`:可选信号契约。带该块的 run 才获得 `emit_signal` 工具(fail-closed);`delivery: steer` 使信号在活动 Turn 的安全边界注入主 Agent,`queue` 走 continuation。
|
- `signal`:可选信号契约。带该块的 run 才获得 `emit_signal` 工具(fail-closed);`delivery: steer` 使信号在活动 Turn 的安全边界注入主 Agent,`queue` 走 continuation。
|
||||||
- 角色正文(`---` 之后)即 `role_prompt`,与 frontmatter 一起做 SHA-256 `definition_hash` 快照。
|
- 角色正文(`---` 之后)即 `role_prompt`,与 frontmatter 一起做 SHA-256 `definition_hash` 快照。
|
||||||
@ -315,7 +315,7 @@ signal:
|
|||||||
- 后台 run 内可调用 `emit_signal`(key/severity/summary/details/dedupe_key),总数、速率、burst、severity allowlist、载荷大小/深度与冷却窗去重均由契约强制;steer 信号经两阶段 admission(claim → mailbox 预留 → admit(turn_id) → 激活)注入当前 Turn,`/stop` 时按 token 条件释放回 pending,绝不静默丢弃。
|
- 后台 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 可识别重复报告。
|
- 每个 run 的完成事件 payload 携带该 run 已发出的 signal IDs,主 Agent 可识别重复报告。
|
||||||
|
|
||||||
未启用编排或省略 target 时使用旧 general 兼容路径(结果带迁移提示)。其工具也只能取旧默认集合与 Delegatable 策略的交集;旧后台任务写入 `background_tasks` 表,完成后通过原 channel/chat 直接通知,默认 24 小时后清理,不具备 durable inbox 语义,等待一个版本观察后随旧适配器移除。
|
旧匿名 general 兼容路径已移除:委托必须指定具名 `target`。
|
||||||
|
|
||||||
后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册;Gateway 关停时先收到取消信号,再在总宽限期内清理。
|
后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册;Gateway 关停时先收到取消信号,再在总宽限期内清理。
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
| `max_user_turn_burst_before_inbox` | 4 | 用户 Turn 公平调度阈值 |
|
| `max_user_turn_burst_before_inbox` | 4 | 用户 Turn 公平调度阈值 |
|
||||||
| `max_inbox_wait_secs` | 30 | inbox 最大等待阈值 |
|
| `max_inbox_wait_secs` | 30 | inbox 最大等待阈值 |
|
||||||
|
|
||||||
已实现:具名 foreground 与 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 字段
|
## gateway 字段
|
||||||
|
|
||||||
@ -84,7 +84,6 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
| `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 |
|
| `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 |
|
||||||
| `session_db_path` | string | - | SQLite 数据库路径,默认在 workspace 下 |
|
| `session_db_path` | string | - | SQLite 数据库路径,默认在 workspace 下 |
|
||||||
| `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 |
|
| `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 |
|
||||||
| `max_concurrent_background_tasks` | int | 10 | delegate 后台子任务最大并发数 |
|
|
||||||
| `scheduler` | object | - | 调度器配置 |
|
| `scheduler` | object | - | 调度器配置 |
|
||||||
|
|
||||||
### gateway.scheduler 字段
|
### gateway.scheduler 字段
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
数据库为 SQLite,默认位于 workspace 下的 `picobot.db`。
|
数据库为 SQLite,默认位于 workspace 下的 `picobot.db`。
|
||||||
|
|
||||||
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout,连接池最多 8 个连接。当前 `PRAGMA user_version=6`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
|
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout,连接池最多 8 个连接。当前 `PRAGMA user_version=8`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
|
||||||
|
|
||||||
## sessions 表
|
## sessions 表
|
||||||
|
|
||||||
@ -55,55 +55,13 @@
|
|||||||
|
|
||||||
`(session_id, seq)` 有唯一索引,防止并发写入重复序号。删除 session 会通过外键级联删除 messages。索引 `(session_id, client_visibility, seq)` 支撑按可见性分层查询。
|
`(session_id, seq)` 有唯一索引,防止并发写入重复序号。删除 session 会通过外键级联删除 messages。索引 `(session_id, client_visibility, seq)` 支撑按可见性分层查询。
|
||||||
|
|
||||||
## background_tasks 表(legacy 兼容,只读过渡)
|
## agent_runs 表(schema v8,Agent 编排)
|
||||||
|
|
||||||
旧 general(无 target)delegate 后台子任务表,由 legacy 适配器写入、`/api/tasks` 只读展示,等待一个版本观察后随旧适配器一起移除。具名 Agent 的后台运行不再写此表。`session_id` 不使用数据库外键,因为 session 使用软删除,关联关系由应用层维护。
|
每次具名委托(foreground 与 background 一致)先落库再执行;`execution_id` 条件更新保证迟到结果丢弃。批量委托只是多个 run 的集合,不再存在组头(schema v7 的 `agent_run_groups` 表已删除)。
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `id` | TEXT PK | 后台任务 ID |
|
|
||||||
| `session_id` | TEXT | 所属会话 |
|
|
||||||
| `channel` | TEXT | 回传渠道 |
|
|
||||||
| `chat_id` | TEXT | 回传目标对话 |
|
|
||||||
| `prompt` | TEXT | 子任务提示 |
|
|
||||||
| `allowed_tools` | TEXT | 允许工具 JSON |
|
|
||||||
| `status` | TEXT | pending / running / completed / failed / cancelled |
|
|
||||||
| `result` | TEXT | 执行结果 |
|
|
||||||
| `error` | TEXT | 错误信息 |
|
|
||||||
| `tool_calls_count` | INTEGER | 工具调用次数 |
|
|
||||||
| `iterations` | INTEGER | Agent 迭代次数 |
|
|
||||||
| `started_at` | INTEGER | 开始时间 |
|
|
||||||
| `finished_at` | INTEGER | 结束时间 |
|
|
||||||
| `created_at` | INTEGER | 创建时间 |
|
|
||||||
|
|
||||||
## agent_run_groups 表(schema 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` 条件更新保证迟到结果丢弃。
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `id` | TEXT PK | run ID |
|
| `id` | TEXT PK | run ID |
|
||||||
| `group_id` | TEXT FK | 所属组(RESTRICT) |
|
|
||||||
| `root_session_id` | TEXT | 根会话 |
|
| `root_session_id` | TEXT | 根会话 |
|
||||||
| `parent_run_id` | TEXT FK | 父 run(RESTRICT),NULL 表示 Root 直接委托 |
|
| `parent_run_id` | TEXT FK | 父 run(RESTRICT),NULL 表示 Root 直接委托 |
|
||||||
| `caller_agent_id` / `caller_scope_id` | TEXT | 调用方身份;Root 的 caller_scope_id 固定 `"ROOT"` |
|
| `caller_agent_id` / `caller_scope_id` | TEXT | 调用方身份;Root 的 caller_scope_id 固定 `"ROOT"` |
|
||||||
@ -116,7 +74,6 @@
|
|||||||
| `task` / `context_json` | TEXT | 任务与调用方上下文 |
|
| `task` / `context_json` | TEXT | 任务与调用方上下文 |
|
||||||
| `budget_json` | TEXT | 树级剩余预算 |
|
| `budget_json` | TEXT | 树级剩余预算 |
|
||||||
| `signal_contract_json` / `signal_delivery` | TEXT | Definition 信号契约快照与投递 lane(queue/steer) |
|
| `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 |
|
| `status` | TEXT | queued / running / waiting_children / completed / failed / timed_out / cancelled / interrupted |
|
||||||
| `result` / `error` | TEXT | 终态完整结果/错误(get_result 与 tool 结果同源) |
|
| `result` / `error` | TEXT | 终态完整结果/错误(get_result 与 tool 结果同源) |
|
||||||
| `prompt_tokens` / `completion_tokens` / `cost` | INTEGER/REAL | Provider usage |
|
| `prompt_tokens` / `completion_tokens` / `cost` | INTEGER/REAL | Provider usage |
|
||||||
@ -128,7 +85,7 @@
|
|||||||
|
|
||||||
索引:`execution_id` 唯一、`(root_session_id, caller_scope_id, idempotency_key)` 部分唯一、`(root_session_id, created_at DESC)`、`(parent_run_id, created_at)`、`(runtime_generation, status, deadline_at)`(恢复扫描)。
|
索引:`execution_id` 唯一、`(root_session_id, caller_scope_id, idempotency_key)` 部分唯一、`(root_session_id, created_at DESC)`、`(parent_run_id, created_at)`、`(runtime_generation, status, deadline_at)`(恢复扫描)。
|
||||||
|
|
||||||
## agent_session_state 表(schema v6,Agent 编排)
|
## agent_session_state 表(schema v8,Agent 编排)
|
||||||
|
|
||||||
每根会话一行,inbox 容量与客户端 revision 的权威计数:
|
每根会话一行,inbox 容量与客户端 revision 的权威计数:
|
||||||
|
|
||||||
@ -142,16 +99,16 @@
|
|||||||
|
|
||||||
容量判断在同一写事务内做条件 `UPDATE`(`pending + reserved + 新增 <= 上限`),杜绝并发 `COUNT(*)` 漂移。
|
容量判断在同一写事务内做条件 `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 过期恢复。
|
background 完成/信号投递的唯一事实源:`pending → leased → admitted → consumed`,失败按 token 释放回 pending,超限进 dead-letter,崩溃靠 lease 过期恢复。
|
||||||
|
|
||||||
| 字段 | 说明 |
|
| 字段 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `id` | TEXT PK |
|
| `id` | TEXT PK |
|
||||||
| `root_session_id` / `scope_kind` / `scope_id` | 归属(run 或 group,CHECK 互斥) |
|
| `root_session_id` | TEXT | 根会话 |
|
||||||
| `run_id` / `group_id` | TEXT FK(RESTRICT) |
|
| `run_id` | TEXT FK NOT NULL(RESTRICT) |
|
||||||
| `event_type` | signal / completion / group_completion |
|
| `event_type` | signal / completion |
|
||||||
| `event_key` | 去重键(signal 含冷却窗口 id) |
|
| `event_key` | 去重键(signal 含冷却窗口 id) |
|
||||||
| `delivery` | queue / steer |
|
| `delivery` | queue / steer |
|
||||||
| `requires_continuation` | 是否反向启动 continuation Turn(cancel 产物为 false) |
|
| `requires_continuation` | 是否反向启动 continuation Turn(cancel 产物为 false) |
|
||||||
@ -164,7 +121,7 @@ background 完成/信号投递的唯一事实源:`pending → leased → admit
|
|||||||
| `updated_at` | 最后更新时间 |
|
| `updated_at` | 最后更新时间 |
|
||||||
| `created_at` / `consumed_at` / `superseded_at` / `dead_lettered_at` / `fallback_notified_at` / `fallback_suppressed_reason` | 状态时间线 |
|
| `created_at` / `consumed_at` / `superseded_at` / `dead_lettered_at` / `fallback_notified_at` / `fallback_suppressed_reason` | 状态时间线 |
|
||||||
|
|
||||||
`(scope_kind, scope_id, event_type, event_key)` 唯一(signal 冷却窗去重)。消费/死信会同步递减 `agent_session_state.pending_event_count`。
|
`(run_id, event_type, event_key)` 唯一(signal 冷却窗去重)。消费/死信会同步递减 `agent_session_state.pending_event_count`。
|
||||||
|
|
||||||
## task_plans / task_items 表
|
## task_plans / task_items 表
|
||||||
|
|
||||||
|
|||||||
@ -137,19 +137,15 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
|
|||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
| 参数 | 必填 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `action` | 否 | 默认 `run`;迁移期仍支持 `check_task`, `cancel_task`, `list_tasks` |
|
|
||||||
| `target` | 具名 Agent 必填 | `root_delegates` 或当前 Agent Definition 允许的目标 ID |
|
| `target` | 具名 Agent 必填 | `root_delegates` 或当前 Agent Definition 允许的目标 ID |
|
||||||
| `task` | 单任务必填 | 明确、独立、可验收的子任务;旧 `prompt` 仅兼容解析 |
|
| `task` | 单任务必填 | 明确、独立、可验收的子任务 |
|
||||||
| `context` | 否 | 子 Agent 所需的显式事实;不会继承完整主会话历史 |
|
| `context` | 否 | 子 Agent 所需的显式事实;不会继承完整主会话历史 |
|
||||||
| `mode` | 否 | `foreground`(默认)或 `background`;批量并发不是第三种 mode |
|
| `mode` | 否 | `foreground`(默认)或 `background`;批量并发不是第三种 mode |
|
||||||
| `tasks` | 批量必填 | 子任务数组;foreground 并发执行、结果保持请求顺序 |
|
| `tasks` | 批量必填 | 子任务数组;foreground 并发执行、结果保持请求顺序 |
|
||||||
| `allowed_tools` | 否 | 迁移字段,只能收窄具名 Definition 或旧 general 默认集,不能扩权 |
|
| `allowed_tools` | 否 | 只能收窄具名 Definition 的工具集,不能扩权 |
|
||||||
| `max_iterations` | 否 | 旧 general 兼容限制;具名 Agent使用 Definition limits |
|
|
||||||
| `timeout_secs` | 否 | 旧 general 兼容限制;具名 Agent使用 Definition limits |
|
|
||||||
| `task_id` | 查询/取消必填 | 后台任务 ID |
|
|
||||||
| `plan_item_id` | 否 | 绑定当前计划子项;批量数组中的每项可分别绑定 |
|
| `plan_item_id` | 否 | 绑定当前计划子项;批量数组中的每项可分别绑定 |
|
||||||
|
|
||||||
旧 `inline` 映射到 foreground,旧 `parallel` 映射到 foreground + `tasks[]`,但不再出现在 tool schema。具名 background 单任务(`target` + `mode=background`)经 durable run/inbox 接纳:先落 `agent_runs` 并预留 inbox completion slot,完成后由主 Agent 的 continuation Turn 处理结果,不再直接发 Channel 通知;background 批量、子 Agent 发起的 background 以及 general background 的新 durable 路径尚未开放(general 仍走旧 direct notification 兼容路径)。后台运行可以在任务中调用 `emit_signal` 发送结构化内部信号(队列或 steer 投递),Steer 信号会在当前 Turn 的安全边界注入主 Agent;`agent_task.cancel` 会把该 run 未消费的普通信号标记 superseded。
|
子 Agent 的工具集完全由其定义文件(`~/.picobot/agents/*.md`)的 `tools` 列表决定,可直接内联 `provider`/`model` 指定模型。具名 background(`target` + `mode=background`,单任务或 `tasks[]` 批量)经 durable run/inbox 接纳:每个 run 先落 `agent_runs` 并预留 inbox completion slot,完成后由主 Agent 的 continuation Turn 处理结果,不再直接发 Channel 通知;批量并发执行、每个 run 独立返回(无用户输入积压时完成即返回)。子 Agent 发起的 background 尚未开放。后台运行可以在任务中调用 `emit_signal` 发送结构化内部信号(队列或 steer 投递),Steer 信号会在当前 Turn 的安全边界注入主 Agent;`agent_task.cancel` 会把该 run 未消费的普通信号标记 superseded。
|
||||||
|
|
||||||
## agent_task — 具名 Agent Run 查询与控制
|
## agent_task — 具名 Agent Run 查询与控制
|
||||||
|
|
||||||
|
|||||||
@ -49,7 +49,7 @@
|
|||||||
"agent_orchestration": {
|
"agent_orchestration": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"definitions_dir": "agents",
|
"definitions_dir": "agents",
|
||||||
"root_delegates": [],
|
"root_delegates": ["general-purpose"],
|
||||||
"max_tree_depth": 4,
|
"max_tree_depth": 4,
|
||||||
"max_runs_per_tree": 16,
|
"max_runs_per_tree": 16,
|
||||||
"max_concurrent_runs": 6,
|
"max_concurrent_runs": 6,
|
||||||
|
|||||||
@ -12,7 +12,10 @@ mod embedded {
|
|||||||
|
|
||||||
/// Install built-in Agent definitions into `<config_dir>/agents/`. Files
|
/// Install built-in Agent definitions into `<config_dir>/agents/`. Files
|
||||||
/// that already exist (user-modified or user-created) are left untouched.
|
/// that already exist (user-modified or user-created) are left untouched.
|
||||||
pub fn install_builtin_agents(config_dir: &Path, profiles: &std::collections::HashMap<String, LLMProviderConfig>) {
|
pub fn install_builtin_agents(
|
||||||
|
config_dir: &Path,
|
||||||
|
profiles: &std::collections::HashMap<String, LLMProviderConfig>,
|
||||||
|
) {
|
||||||
let agents_dir = config_dir.join("agents");
|
let agents_dir = config_dir.join("agents");
|
||||||
if let Err(error) = std::fs::create_dir_all(&agents_dir) {
|
if let Err(error) = std::fs::create_dir_all(&agents_dir) {
|
||||||
tracing::warn!(dir = %agents_dir.display(), error = %error, "Failed to create agents directory");
|
tracing::warn!(dir = %agents_dir.display(), error = %error, "Failed to create agents directory");
|
||||||
|
|||||||
@ -2,9 +2,11 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::config::{AgentOrchestrationConfig, LLMProviderConfig, expand_path};
|
use crate::config::{
|
||||||
|
AgentOrchestrationConfig, LLMProviderConfig, ModelConfig, ProviderConfig, expand_path,
|
||||||
|
};
|
||||||
use crate::skills::SkillsLoader;
|
use crate::skills::SkillsLoader;
|
||||||
use crate::tools::{DelegationPolicy, ToolRegistry};
|
use crate::tools::ToolRegistry;
|
||||||
|
|
||||||
use super::definition::{AgentDefinition, AgentDefinitionError, parse_definition};
|
use super::definition::{AgentDefinition, AgentDefinitionError, parse_definition};
|
||||||
|
|
||||||
@ -18,6 +20,10 @@ pub enum AgentCatalogError {
|
|||||||
Directory(String),
|
Directory(String),
|
||||||
#[error("Agent '{agent}' references unknown Provider profile '{profile}'")]
|
#[error("Agent '{agent}' references unknown Provider profile '{profile}'")]
|
||||||
UnknownProfile { agent: String, profile: String },
|
UnknownProfile { agent: String, profile: String },
|
||||||
|
#[error("Agent '{agent}' references unknown provider '{provider}'")]
|
||||||
|
UnknownProvider { agent: String, provider: String },
|
||||||
|
#[error("Agent '{agent}' references unknown model '{model}'")]
|
||||||
|
UnknownModel { agent: String, model: String },
|
||||||
#[error("Agent '{agent}' references invalid tool '{tool}': {reason}")]
|
#[error("Agent '{agent}' references invalid tool '{tool}': {reason}")]
|
||||||
InvalidTool {
|
InvalidTool {
|
||||||
agent: String,
|
agent: String,
|
||||||
@ -52,10 +58,14 @@ impl AgentCatalog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn load(
|
pub fn load(
|
||||||
config: &AgentOrchestrationConfig,
|
config: &AgentOrchestrationConfig,
|
||||||
config_dir: &Path,
|
config_dir: &Path,
|
||||||
provider_profiles: &HashMap<String, LLMProviderConfig>,
|
provider_profiles: &HashMap<String, LLMProviderConfig>,
|
||||||
|
providers: &HashMap<String, ProviderConfig>,
|
||||||
|
models: &HashMap<String, ModelConfig>,
|
||||||
|
workspace_dir: &Path,
|
||||||
tools: &ToolRegistry,
|
tools: &ToolRegistry,
|
||||||
skills_loader: &SkillsLoader,
|
skills_loader: &SkillsLoader,
|
||||||
runtime_generation: u64,
|
runtime_generation: u64,
|
||||||
@ -93,15 +103,18 @@ impl AgentCatalog {
|
|||||||
.map(|(name, _)| name)
|
.map(|(name, _)| name)
|
||||||
.collect();
|
.collect();
|
||||||
let mut definitions = BTreeMap::new();
|
let mut definitions = BTreeMap::new();
|
||||||
|
let mut disabled_ids = HashSet::new();
|
||||||
|
|
||||||
for path in paths {
|
for path in paths {
|
||||||
let yaml = read_profile_name(&path)?;
|
let spec = read_provider_spec(&path)?;
|
||||||
let provider = provider_profiles.get(&yaml).cloned().ok_or_else(|| {
|
// Disabled definitions stay on disk for the management UI but
|
||||||
AgentCatalogError::UnknownProfile {
|
// never enter the active catalog.
|
||||||
agent: path.display().to_string(),
|
if !spec.enabled {
|
||||||
profile: yaml.clone(),
|
disabled_ids.insert(spec.id);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
})?;
|
let provider =
|
||||||
|
resolve_provider(&spec, provider_profiles, providers, models, workspace_dir)?;
|
||||||
let definition = Arc::new(parse_definition(&path, Arc::new(provider))?);
|
let definition = Arc::new(parse_definition(&path, Arc::new(provider))?);
|
||||||
if definitions.contains_key(&definition.id) {
|
if definitions.contains_key(&definition.id) {
|
||||||
return Err(AgentCatalogError::Config(format!(
|
return Err(AgentCatalogError::Config(format!(
|
||||||
@ -148,7 +161,7 @@ impl AgentCatalog {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
for target in &root_delegates {
|
for target in &root_delegates {
|
||||||
if !definitions.contains_key(target) {
|
if !definitions.contains_key(target) && !disabled_ids.contains(target) {
|
||||||
return Err(AgentCatalogError::UnknownDelegate {
|
return Err(AgentCatalogError::UnknownDelegate {
|
||||||
agent: "ROOT".to_string(),
|
agent: "ROOT".to_string(),
|
||||||
target: target.clone(),
|
target: target.clone(),
|
||||||
@ -187,7 +200,7 @@ impl AgentCatalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn root_can_delegate(&self, target: &str) -> bool {
|
pub fn root_can_delegate(&self, target: &str) -> bool {
|
||||||
self.root_delegates.contains(target)
|
self.root_delegates.contains(target) && self.definitions.contains_key(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn can_delegate(&self, caller: &str, target: &str) -> bool {
|
pub fn can_delegate(&self, caller: &str, target: &str) -> bool {
|
||||||
@ -229,7 +242,20 @@ fn definition_paths(directory: &Path) -> Result<Vec<PathBuf>, AgentCatalogError>
|
|||||||
Ok(paths)
|
Ok(paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_profile_name(path: &Path) -> Result<String, AgentCatalogError> {
|
/// Provider/model/enabled fields read from a definition's frontmatter before
|
||||||
|
/// the full definition is parsed, so the catalog can resolve the provider
|
||||||
|
/// config and skip disabled definitions in one pass.
|
||||||
|
struct ProviderSpec {
|
||||||
|
id: String,
|
||||||
|
llm_profile: Option<String>,
|
||||||
|
provider: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
token_limit: Option<usize>,
|
||||||
|
max_tool_iterations: Option<usize>,
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_provider_spec(path: &Path) -> Result<ProviderSpec, AgentCatalogError> {
|
||||||
let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io {
|
let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io {
|
||||||
path: path.display().to_string(),
|
path: path.display().to_string(),
|
||||||
source,
|
source,
|
||||||
@ -267,11 +293,78 @@ fn read_profile_name(path: &Path) -> Result<String, AgentCatalogError> {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct ProfileOnly {
|
struct Spec {
|
||||||
llm_profile: String,
|
id: String,
|
||||||
|
llm_profile: Option<String>,
|
||||||
|
provider: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
token_limit: Option<usize>,
|
||||||
|
max_tool_iterations: Option<usize>,
|
||||||
|
#[serde(default = "crate::agent::definition::default_true")]
|
||||||
|
enabled: bool,
|
||||||
}
|
}
|
||||||
let parsed: ProfileOnly = serde_yaml::from_str(yaml).map_err(AgentDefinitionError::Yaml)?;
|
let parsed: Spec = serde_yaml::from_str(yaml).map_err(AgentDefinitionError::Yaml)?;
|
||||||
Ok(parsed.llm_profile)
|
Ok(ProviderSpec {
|
||||||
|
id: parsed.id,
|
||||||
|
llm_profile: parsed.llm_profile,
|
||||||
|
provider: parsed.provider,
|
||||||
|
model: parsed.model,
|
||||||
|
token_limit: parsed.token_limit,
|
||||||
|
max_tool_iterations: parsed.max_tool_iterations,
|
||||||
|
enabled: parsed.enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_provider(
|
||||||
|
spec: &ProviderSpec,
|
||||||
|
provider_profiles: &HashMap<String, LLMProviderConfig>,
|
||||||
|
providers: &HashMap<String, ProviderConfig>,
|
||||||
|
models: &HashMap<String, ModelConfig>,
|
||||||
|
workspace_dir: &Path,
|
||||||
|
) -> Result<LLMProviderConfig, AgentCatalogError> {
|
||||||
|
let inline = spec.provider.is_some() || spec.model.is_some();
|
||||||
|
if inline {
|
||||||
|
let provider_name = spec.provider.as_deref().unwrap_or_default();
|
||||||
|
let model_name = spec.model.as_deref().unwrap_or_default();
|
||||||
|
let provider =
|
||||||
|
providers
|
||||||
|
.get(provider_name)
|
||||||
|
.ok_or_else(|| AgentCatalogError::UnknownProvider {
|
||||||
|
agent: spec.id.clone(),
|
||||||
|
provider: provider_name.to_string(),
|
||||||
|
})?;
|
||||||
|
let model = models
|
||||||
|
.get(model_name)
|
||||||
|
.ok_or_else(|| AgentCatalogError::UnknownModel {
|
||||||
|
agent: spec.id.clone(),
|
||||||
|
model: model_name.to_string(),
|
||||||
|
})?;
|
||||||
|
return Ok(LLMProviderConfig {
|
||||||
|
provider_type: provider.provider_type.clone(),
|
||||||
|
name: provider_name.to_string(),
|
||||||
|
base_url: provider.base_url.clone(),
|
||||||
|
api_key: provider.api_key.clone(),
|
||||||
|
extra_headers: provider.extra_headers.clone(),
|
||||||
|
model_id: model.model_id.clone(),
|
||||||
|
temperature: model.temperature,
|
||||||
|
max_tokens: model.max_tokens,
|
||||||
|
model_extra: model.extra.clone(),
|
||||||
|
max_tool_iterations: spec.max_tool_iterations.unwrap_or(99),
|
||||||
|
token_limit: spec.token_limit.unwrap_or(128_000),
|
||||||
|
workspace_dir: workspace_dir.to_path_buf(),
|
||||||
|
input_types: model.input_type.clone(),
|
||||||
|
price_input_per_million: None,
|
||||||
|
price_output_per_million: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let profile = spec.llm_profile.as_deref().unwrap_or_default();
|
||||||
|
provider_profiles
|
||||||
|
.get(profile)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| AgentCatalogError::UnknownProfile {
|
||||||
|
agent: spec.id.clone(),
|
||||||
|
profile: profile.to_string(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_definition_tools(
|
fn validate_definition_tools(
|
||||||
@ -286,14 +379,17 @@ fn validate_definition_tools(
|
|||||||
tool: name.clone(),
|
tool: name.clone(),
|
||||||
reason: "tool is not registered in the prepared runtime".to_string(),
|
reason: "tool is not registered in the prepared runtime".to_string(),
|
||||||
})?;
|
})?;
|
||||||
let allowed = tool.delegation_policy() == DelegationPolicy::Delegatable
|
// Which tools a named Agent receives is decided by its definition
|
||||||
|| (name == "get_skill"
|
// file alone. Runtime-injected tools (delegate/emit_signal/
|
||||||
&& tool.delegation_policy() == DelegationPolicy::RuntimeInjected);
|
// get_skill/agent_task) are assembled from dedicated fields
|
||||||
if !allowed {
|
// (delegates/signal/skills) and must never appear in `tools`;
|
||||||
|
// `get_skill` is the one exception: listing it turns on the scoped
|
||||||
|
// skill wrapper, which is injected at resolve time.
|
||||||
|
if tool.runtime_injected() && name != "get_skill" {
|
||||||
return Err(AgentCatalogError::InvalidTool {
|
return Err(AgentCatalogError::InvalidTool {
|
||||||
agent: definition.id.clone(),
|
agent: definition.id.clone(),
|
||||||
tool: name.clone(),
|
tool: name.clone(),
|
||||||
reason: format!("policy is {:?}", tool.delegation_policy()),
|
reason: "runtime-injected tool cannot be declared in a definition".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -303,7 +399,7 @@ fn validate_definition_tools(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::tools::{CalculatorTool, FileWriteTool};
|
use crate::tools::{CalculatorTool, GetSkillTool};
|
||||||
|
|
||||||
fn provider() -> LLMProviderConfig {
|
fn provider() -> LLMProviderConfig {
|
||||||
LLMProviderConfig {
|
LLMProviderConfig {
|
||||||
@ -380,8 +476,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let profiles = HashMap::from([("research".to_string(), provider())]);
|
let profiles = HashMap::from([("research".to_string(), provider())]);
|
||||||
|
|
||||||
let catalog =
|
let catalog = AgentCatalog::load(
|
||||||
AgentCatalog::load(&config(), root.path(), &profiles, &tools, &loader, 7).unwrap();
|
&config(),
|
||||||
|
root.path(),
|
||||||
|
&profiles,
|
||||||
|
&HashMap::new(),
|
||||||
|
&HashMap::new(),
|
||||||
|
root.path(),
|
||||||
|
&tools,
|
||||||
|
&loader,
|
||||||
|
7,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(catalog.root_can_delegate("researcher"));
|
assert!(catalog.root_can_delegate("researcher"));
|
||||||
assert!(catalog.can_delegate("researcher", "reviewer"));
|
assert!(catalog.can_delegate("researcher", "reviewer"));
|
||||||
@ -393,22 +499,61 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn catalog_rejects_root_only_tool() {
|
fn catalog_accepts_any_ordinary_tool_but_rejects_runtime_injected() {
|
||||||
|
// Ordinary tools (including side-effecting ones like file_write) are
|
||||||
|
// now accepted purely by the definition file.
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
std::fs::create_dir(root.path().join("agents")).unwrap();
|
std::fs::create_dir(root.path().join("agents")).unwrap();
|
||||||
write_agent(root.path(), "researcher", &["file_write"], &[]);
|
write_agent(root.path(), "researcher", &["file_write"], &[]);
|
||||||
let tools = ToolRegistry::new();
|
let tools = ToolRegistry::new();
|
||||||
tools.register(FileWriteTool::new());
|
tools.register(crate::tools::FileWriteTool::new());
|
||||||
let loader = SkillsLoader::new_for_testing(
|
let loader = SkillsLoader::new_for_testing(
|
||||||
root.path().join("skills"),
|
root.path().join("skills"),
|
||||||
root.path().join("external-skills"),
|
root.path().join("external-skills"),
|
||||||
);
|
);
|
||||||
let profiles = HashMap::from([("research".to_string(), provider())]);
|
let profiles = HashMap::from([("research".to_string(), provider())]);
|
||||||
|
AgentCatalog::load(
|
||||||
|
&config(),
|
||||||
|
root.path(),
|
||||||
|
&profiles,
|
||||||
|
&HashMap::new(),
|
||||||
|
&HashMap::new(),
|
||||||
|
root.path(),
|
||||||
|
&tools,
|
||||||
|
&loader,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let error =
|
// Runtime-injected tools (e.g. delegate) must not be declared in a
|
||||||
AgentCatalog::load(&config(), root.path(), &profiles, &tools, &loader, 1).unwrap_err();
|
// definition's `tools` list; get_skill remains the one exception.
|
||||||
|
let root2 = tempfile::tempdir().unwrap();
|
||||||
assert!(matches!(error, AgentCatalogError::InvalidTool { .. }));
|
std::fs::create_dir(root2.path().join("agents")).unwrap();
|
||||||
|
write_agent(root2.path(), "researcher", &["get_skill"], &[]);
|
||||||
|
let tools2 = ToolRegistry::new();
|
||||||
|
tools2.register(GetSkillTool::new(Arc::new(
|
||||||
|
crate::skills::SkillsLoader::new_for_testing(
|
||||||
|
root2.path().join("skills"),
|
||||||
|
root2.path().join("external-skills"),
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
let loader2 = SkillsLoader::new_for_testing(
|
||||||
|
root2.path().join("skills"),
|
||||||
|
root2.path().join("external-skills"),
|
||||||
|
);
|
||||||
|
let profiles2 = HashMap::from([("research".to_string(), provider())]);
|
||||||
|
AgentCatalog::load(
|
||||||
|
&config(),
|
||||||
|
root2.path(),
|
||||||
|
&profiles2,
|
||||||
|
&HashMap::new(),
|
||||||
|
&HashMap::new(),
|
||||||
|
root2.path(),
|
||||||
|
&tools2,
|
||||||
|
&loader2,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@ -434,7 +579,18 @@ mod tests {
|
|||||||
let profiles = HashMap::from([("research".to_string(), provider())]);
|
let profiles = HashMap::from([("research".to_string(), provider())]);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
AgentCatalog::load(&config(), root.path(), &profiles, &tools, &loader, 1,).is_err()
|
AgentCatalog::load(
|
||||||
|
&config(),
|
||||||
|
root.path(),
|
||||||
|
&profiles,
|
||||||
|
&HashMap::new(),
|
||||||
|
&HashMap::new(),
|
||||||
|
root.path(),
|
||||||
|
&tools,
|
||||||
|
&loader,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,8 @@ use crate::agent::sub_agent::{
|
|||||||
use crate::storage::Storage;
|
use crate::storage::Storage;
|
||||||
use crate::storage::agent_inbox::AgentEventType;
|
use crate::storage::agent_inbox::AgentEventType;
|
||||||
use crate::storage::agent_run::{
|
use crate::storage::agent_run::{
|
||||||
AcceptAgentRequest, AcceptedAgentRuns, AgentCompletionPolicy, AgentRunMode, AgentRunRecord,
|
AcceptAgentRequest, AcceptedAgentRuns, AgentRunMode, AgentRunRecord, AgentRunStatus,
|
||||||
AgentRunStatus, AgentTerminalOutcome, NewAgentGroup, NewAgentRun,
|
AgentTerminalOutcome, NewAgentRun,
|
||||||
};
|
};
|
||||||
use crate::tools::ToolExecutionContext;
|
use crate::tools::ToolExecutionContext;
|
||||||
use crate::tools::emit_signal::{SignalAccepted, SignalAcceptedStatus, SignalInput};
|
use crate::tools::emit_signal::{SignalAccepted, SignalAcceptedStatus, SignalInput};
|
||||||
@ -26,6 +26,12 @@ use crate::tools::emit_signal::{SignalAccepted, SignalAcceptedStatus, SignalInpu
|
|||||||
/// runs reserve an inbox completion slot at admission; their completion event
|
/// runs reserve an inbox completion slot at admission; their completion event
|
||||||
/// is materialized by the terminal commit and delivered through the Session
|
/// is materialized by the terminal commit and delivered through the Session
|
||||||
/// continuation lane instead of a direct channel notification.
|
/// continuation lane instead of a direct channel notification.
|
||||||
|
/// Admission result for a background batch: the run ids actually spawned.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BackgroundAdmission {
|
||||||
|
pub run_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AgentCoordinator {
|
pub struct AgentCoordinator {
|
||||||
storage: Arc<Storage>,
|
storage: Arc<Storage>,
|
||||||
manager: Arc<SubAgentManager>,
|
manager: Arc<SubAgentManager>,
|
||||||
@ -85,23 +91,34 @@ impl AgentCoordinator {
|
|||||||
/// Admit a named background run for the root caller and spawn its runner.
|
/// Admit a named background run for the root caller and spawn its runner.
|
||||||
/// Completion is guaranteed by the reserved inbox slot; the returned ID
|
/// Completion is guaranteed by the reserved inbox slot; the returned ID
|
||||||
/// is only valid when every durable step succeeded.
|
/// is only valid when every durable step succeeded.
|
||||||
|
/// Admit one or more named background runs and spawn their runners.
|
||||||
|
/// Returns immediately: run quota is acquired inside each runner (queuing
|
||||||
|
/// time counts toward the run timeout), and completion capacity is
|
||||||
|
/// reserved up front so no completion can ever be lost.
|
||||||
pub async fn delegate_background(
|
pub async fn delegate_background(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
caller: &ToolExecutionContext,
|
caller: &ToolExecutionContext,
|
||||||
config: SubAgentConfig,
|
configs: Vec<SubAgentConfig>,
|
||||||
) -> Result<String, CoordinatorError> {
|
) -> Result<BackgroundAdmission, CoordinatorError> {
|
||||||
if caller.agent.is_some() {
|
if caller.agent.is_some() {
|
||||||
return Err(CoordinatorError::Rejected(
|
return Err(CoordinatorError::Rejected(
|
||||||
"nested background runs are not available yet; only the root Agent may delegate background work".to_string(),
|
"nested background runs are not available yet; only the root Agent may delegate background work".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if config.target.is_none() {
|
if configs.is_empty() {
|
||||||
return Err(CoordinatorError::Rejected(
|
return Err(CoordinatorError::Rejected(
|
||||||
"legacy general Agent is not persisted; named background targets only".to_string(),
|
"background delegation requires at least one task".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let run_id = Uuid::new_v4().to_string();
|
// Hard cap: a batch larger than the run quota would never execute
|
||||||
let resolved = self.manager.resolve_agent(&config, caller, &run_id)?;
|
// concurrently, so reject it up front.
|
||||||
|
if configs.len() > self.execution_gate.max_concurrent_runs() {
|
||||||
|
return Err(CoordinatorError::Rejected(format!(
|
||||||
|
"background batch of {} runs exceeds max_concurrent_runs ({})",
|
||||||
|
configs.len(),
|
||||||
|
self.execution_gate.max_concurrent_runs()
|
||||||
|
)));
|
||||||
|
}
|
||||||
let root_session_id = caller
|
let root_session_id = caller
|
||||||
.session_id
|
.session_id
|
||||||
.clone()
|
.clone()
|
||||||
@ -116,28 +133,28 @@ impl AgentCoordinator {
|
|||||||
})?;
|
})?;
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
|
||||||
// 0. Run quota + admission guard before any durable write; any
|
// Resolve every target before any durable write so a bad request
|
||||||
// failure here releases everything without touching SQLite. The
|
// fails closed without leaving orphan rows.
|
||||||
// permit stays with the runner until the terminal commit.
|
let mut run_ids = Vec::with_capacity(configs.len());
|
||||||
let run_permit = self
|
let mut resolved = Vec::with_capacity(configs.len());
|
||||||
.execution_gate
|
for config in &configs {
|
||||||
.acquire_run(&root_session_id, &caller.cancellation)
|
if config.target.is_none() {
|
||||||
.await
|
return Err(CoordinatorError::Rejected(
|
||||||
.map_err(|error| CoordinatorError::Rejected(error.to_string()))?;
|
"named background targets only".to_string(),
|
||||||
let activity = self.admission.try_enter().ok_or_else(|| {
|
));
|
||||||
CoordinatorError::Rejected(
|
}
|
||||||
"gateway is draining for configuration reload and cannot accept background tasks"
|
let run_id = Uuid::new_v4().to_string();
|
||||||
.to_string(),
|
resolved.push(self.manager.resolve_agent(config, caller, &run_id)?);
|
||||||
)
|
run_ids.push(run_id);
|
||||||
})?;
|
}
|
||||||
|
|
||||||
// 1. Reserve the completion slot; failure means the inbox is full and
|
// 1. Reserve one completion slot per run; failure rejects the whole
|
||||||
// nothing is admitted.
|
// batch so nothing is admitted under capacity.
|
||||||
if self
|
if self
|
||||||
.storage
|
.storage
|
||||||
.reserve_completion_slots(
|
.reserve_completion_slots(
|
||||||
&root_session_id,
|
&root_session_id,
|
||||||
1,
|
configs.len() as i64,
|
||||||
self.max_pending_inbox_events_per_session,
|
self.max_pending_inbox_events_per_session,
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
@ -145,28 +162,31 @@ impl AgentCoordinator {
|
|||||||
.is_none()
|
.is_none()
|
||||||
{
|
{
|
||||||
return Err(CoordinatorError::Rejected(
|
return Err(CoordinatorError::Rejected(
|
||||||
"inbox capacity exceeded; cannot accept another background run".to_string(),
|
"inbox capacity exceeded; cannot accept the background batch".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Persist the queued run atomically with the reservation.
|
// 2. Persist the queued runs atomically with the reservation.
|
||||||
let accept = NewAgentRun {
|
let mut runs = Vec::with_capacity(configs.len());
|
||||||
id: run_id.clone(),
|
for (index, config) in configs.iter().enumerate() {
|
||||||
|
let resolution = &resolved[index];
|
||||||
|
runs.push(NewAgentRun {
|
||||||
|
id: run_ids[index].clone(),
|
||||||
root_session_id: root_session_id.clone(),
|
root_session_id: root_session_id.clone(),
|
||||||
root_turn_id: caller.turn_id.clone(),
|
root_turn_id: caller.turn_id.clone(),
|
||||||
parent_run_id: None,
|
parent_run_id: None,
|
||||||
caller_agent_id: "ROOT".to_string(),
|
caller_agent_id: "ROOT".to_string(),
|
||||||
caller_scope_id: "ROOT".to_string(),
|
caller_scope_id: "ROOT".to_string(),
|
||||||
idempotency_key: None,
|
idempotency_key: None,
|
||||||
agent_id: resolved.agent_id.clone().unwrap_or_default(),
|
agent_id: resolution.agent_id.clone().unwrap_or_default(),
|
||||||
definition_hash: resolved.definition_hash.clone().unwrap_or_default(),
|
definition_hash: resolution.definition_hash.clone().unwrap_or_default(),
|
||||||
provider_profile: resolved.llm_profile.clone().unwrap_or_default(),
|
provider_profile: resolution.llm_profile.clone().unwrap_or_default(),
|
||||||
provider_name: resolved.provider_config.name.clone(),
|
provider_name: resolution.provider_config.name.clone(),
|
||||||
model_id: resolved.provider_config.model_id.clone(),
|
model_id: resolution.provider_config.model_id.clone(),
|
||||||
mode: AgentRunMode::Background,
|
mode: AgentRunMode::Background,
|
||||||
depth: 1,
|
depth: 1,
|
||||||
plan_item_id: config.plan_item_id.clone(),
|
plan_item_id: config.plan_item_id.clone(),
|
||||||
execution_id: run_id.clone(),
|
execution_id: run_ids[index].clone(),
|
||||||
task: config.prompt.clone(),
|
task: config.prompt.clone(),
|
||||||
context_json: config.context.clone(),
|
context_json: config.context.clone(),
|
||||||
budget_json: serde_json::json!({
|
budget_json: serde_json::json!({
|
||||||
@ -174,31 +194,28 @@ impl AgentCoordinator {
|
|||||||
"remaining_depth": self.manager.catalog().max_tree_depth(),
|
"remaining_depth": self.manager.catalog().max_tree_depth(),
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
signal_contract_json: resolved
|
signal_contract_json: resolution
|
||||||
.signal_contract
|
.signal_contract
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|contract| serde_json::to_string(contract).unwrap_or_default()),
|
.map(|contract| serde_json::to_string(contract).unwrap_or_default()),
|
||||||
signal_delivery: resolved
|
signal_delivery: resolution
|
||||||
.signal_contract
|
.signal_contract
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|contract| contract.delivery.as_str().to_string()),
|
.map(|contract| contract.delivery.as_str().to_string()),
|
||||||
deadline_at: now + (resolved.timeout_secs * 1000) as i64,
|
deadline_at: now + (resolution.timeout_secs * 1000) as i64,
|
||||||
runtime_generation: self.runtime_generation,
|
runtime_generation: self.runtime_generation,
|
||||||
completion_slot_reserved: true,
|
completion_slot_reserved: true,
|
||||||
};
|
});
|
||||||
|
}
|
||||||
match self
|
match self
|
||||||
.storage
|
.storage
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
.accept_agent_runs(AcceptAgentRequest { runs, now })
|
||||||
group: None,
|
|
||||||
runs: vec![accept],
|
|
||||||
now,
|
|
||||||
})
|
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
AcceptedAgentRuns::Accepted { .. } => {}
|
AcceptedAgentRuns::Accepted { .. } => {}
|
||||||
AcceptedAgentRuns::Existing { .. } => {
|
AcceptedAgentRuns::Existing { .. } => {
|
||||||
self.storage
|
self.storage
|
||||||
.release_completion_slots(&root_session_id, 1, now)
|
.release_completion_slots(&root_session_id, configs.len() as i64, now)
|
||||||
.await?;
|
.await?;
|
||||||
return Err(CoordinatorError::Rejected(
|
return Err(CoordinatorError::Rejected(
|
||||||
"background admission conflicted with an existing run id".to_string(),
|
"background admission conflicted with an existing run id".to_string(),
|
||||||
@ -206,39 +223,47 @@ impl AgentCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Register the cancellation token and spawn the runner. The
|
// 3. Spawn one runner per run. Each runner acquires its own run
|
||||||
// activity guard and run quota are held for the whole run, not
|
// quota permit and admission guard; delegate returns immediately.
|
||||||
// released when `delegate_background` returns.
|
let coordinator = self.clone();
|
||||||
|
let mut spawned_ids = Vec::with_capacity(configs.len());
|
||||||
|
for (index, config) in configs.iter().enumerate() {
|
||||||
|
let run_id = run_ids[index].clone();
|
||||||
let token = CancellationToken::new();
|
let token = CancellationToken::new();
|
||||||
self.active_tokens.insert(run_id.clone(), token.clone());
|
self.active_tokens.insert(run_id.clone(), token.clone());
|
||||||
let coordinator = self.clone();
|
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
|
let resolution = resolved[index].clone();
|
||||||
let spawned = self
|
let spawned = self
|
||||||
.task_supervisor
|
.task_supervisor
|
||||||
.spawn_graceful(format!("agent-run:{run_id}"), {
|
.spawn_graceful(format!("agent-run:{run_id}"), {
|
||||||
|
let coordinator = coordinator.clone();
|
||||||
let run_id = run_id.clone();
|
let run_id = run_id.clone();
|
||||||
async move {
|
async move {
|
||||||
coordinator
|
coordinator
|
||||||
.run_background_runner(
|
.run_background_runner(&run_id, &config, resolution, token)
|
||||||
&run_id, &config, resolved, token, run_permit, activity,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if !spawned {
|
if !spawned {
|
||||||
// Compensation: undo the durable admission before returning.
|
// Compensation: the rejected closure was dropped by the
|
||||||
// The rejected closure was dropped by the supervisor, which
|
// supervisor. Cancel the run and release its slot.
|
||||||
// released the run quota permit and activity guard.
|
|
||||||
self.active_tokens.remove(&run_id);
|
self.active_tokens.remove(&run_id);
|
||||||
let _ = self
|
let _ = self
|
||||||
.storage
|
.storage
|
||||||
.cancel_agent_run_with_completion(&run_id, "gateway shutdown", true, now)
|
.cancel_agent_run_with_completion(&run_id, "gateway shutdown", true, now)
|
||||||
.await;
|
.await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
spawned_ids.push(run_id);
|
||||||
|
}
|
||||||
|
if spawned_ids.is_empty() {
|
||||||
return Err(CoordinatorError::Rejected(
|
return Err(CoordinatorError::Rejected(
|
||||||
"gateway is shutting down and cannot accept background tasks".to_string(),
|
"gateway is shutting down and cannot accept background tasks".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(run_id)
|
Ok(BackgroundAdmission {
|
||||||
|
run_ids: spawned_ids,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_background_runner(
|
async fn run_background_runner(
|
||||||
@ -247,11 +272,46 @@ impl AgentCoordinator {
|
|||||||
config: &SubAgentConfig,
|
config: &SubAgentConfig,
|
||||||
resolved: ResolvedAgentRun,
|
resolved: ResolvedAgentRun,
|
||||||
token: CancellationToken,
|
token: CancellationToken,
|
||||||
_run_permit: crate::agent::gate::RunPermit,
|
|
||||||
_activity: crate::gateway::reload::ActivityGuard,
|
|
||||||
) {
|
) {
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
let execution_id = run_id.to_string();
|
let execution_id = run_id.to_string();
|
||||||
|
let root_session_id = resolved
|
||||||
|
.tool_context
|
||||||
|
.agent
|
||||||
|
.as_ref()
|
||||||
|
.map(|agent| agent.root_session_id.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Run quota + admission guard, acquired inside the runner so
|
||||||
|
// `delegate_background` returns immediately. Queuing time counts
|
||||||
|
// toward the run timeout and cancellation aborts the wait.
|
||||||
|
let run_permit = match self
|
||||||
|
.execution_gate
|
||||||
|
.acquire_run(&root_session_id, &token)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(permit) => permit,
|
||||||
|
Err(_) => {
|
||||||
|
let _ = self
|
||||||
|
.storage
|
||||||
|
.cancel_agent_run_with_completion(run_id, "cancelled before start", true, now)
|
||||||
|
.await;
|
||||||
|
self.active_tokens.remove(run_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(activity) = self.admission.try_enter() else {
|
||||||
|
drop(run_permit);
|
||||||
|
let _ = self
|
||||||
|
.storage
|
||||||
|
.cancel_agent_run_with_completion(run_id, "gateway shutdown", true, now)
|
||||||
|
.await;
|
||||||
|
self.active_tokens.remove(run_id);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _run_permit = run_permit;
|
||||||
|
let _activity = activity;
|
||||||
|
|
||||||
if !self
|
if !self
|
||||||
.storage
|
.storage
|
||||||
.mark_agent_run_running(run_id, &execution_id, now)
|
.mark_agent_run_running(run_id, &execution_id, now)
|
||||||
@ -402,8 +462,8 @@ impl AgentCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Startup/activation recovery: interrupt runs of older generations,
|
/// Startup/activation recovery: interrupt runs of older generations,
|
||||||
/// expire stale leases, converge group counters and reconcile the
|
/// expire stale leases and reconcile the per-session capacity rows.
|
||||||
/// per-session capacity rows. Safe to call once per activation.
|
/// Safe to call once per activation.
|
||||||
pub async fn recover_on_activation(
|
pub async fn recover_on_activation(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<crate::storage::agent_inbox::RecoveryReport, CoordinatorError> {
|
) -> Result<crate::storage::agent_inbox::RecoveryReport, CoordinatorError> {
|
||||||
@ -479,23 +539,6 @@ impl AgentCoordinator {
|
|||||||
.or_else(|| caller.agent.as_ref().map(|agent| agent.run_id.clone()))
|
.or_else(|| caller.agent.as_ref().map(|agent| agent.run_id.clone()))
|
||||||
.unwrap_or_else(|| "root".to_string());
|
.unwrap_or_else(|| "root".to_string());
|
||||||
|
|
||||||
let group = (configs.len() > 1).then(|| NewAgentGroup {
|
|
||||||
id: Uuid::new_v4().to_string(),
|
|
||||||
root_session_id: root_session_id.clone(),
|
|
||||||
caller_run_id: caller.agent.as_ref().map(|agent| agent.run_id.clone()),
|
|
||||||
caller_scope_id: caller_scope_id.clone(),
|
|
||||||
idempotency_key: None,
|
|
||||||
mode: AgentRunMode::Foreground,
|
|
||||||
completion_policy: AgentCompletionPolicy::All,
|
|
||||||
deadline_at: now
|
|
||||||
+ resolved
|
|
||||||
.iter()
|
|
||||||
.map(|run| (run.timeout_secs * 1000) as i64)
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0),
|
|
||||||
runtime_generation: self.runtime_generation,
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut runs = Vec::with_capacity(configs.len());
|
let mut runs = Vec::with_capacity(configs.len());
|
||||||
for (index, config) in configs.iter().enumerate() {
|
for (index, config) in configs.iter().enumerate() {
|
||||||
let resolution = &resolved[index];
|
let resolution = &resolved[index];
|
||||||
@ -547,7 +590,7 @@ impl AgentCoordinator {
|
|||||||
|
|
||||||
match self
|
match self
|
||||||
.storage
|
.storage
|
||||||
.accept_agent_runs(AcceptAgentRequest { group, runs, now })
|
.accept_agent_runs(AcceptAgentRequest { runs, now })
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
AcceptedAgentRuns::Accepted { .. } => {}
|
AcceptedAgentRuns::Accepted { .. } => {}
|
||||||
@ -1113,7 +1156,18 @@ mod tests {
|
|||||||
root_delegates: vec!["researcher".to_string()],
|
root_delegates: vec!["researcher".to_string()],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
AgentCatalog::load(&config, root, &profiles, &tools, &loader, 1).unwrap()
|
AgentCatalog::load(
|
||||||
|
&config,
|
||||||
|
root,
|
||||||
|
&profiles,
|
||||||
|
&HashMap::new(),
|
||||||
|
&HashMap::new(),
|
||||||
|
root,
|
||||||
|
&tools,
|
||||||
|
&loader,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn coordinator() -> (Arc<AgentCoordinator>, tempfile::TempDir) {
|
async fn coordinator() -> (Arc<AgentCoordinator>, tempfile::TempDir) {
|
||||||
@ -1122,6 +1176,13 @@ mod tests {
|
|||||||
|
|
||||||
async fn coordinator_with_inbox_limit(
|
async fn coordinator_with_inbox_limit(
|
||||||
max_pending: usize,
|
max_pending: usize,
|
||||||
|
) -> (Arc<AgentCoordinator>, tempfile::TempDir) {
|
||||||
|
coordinator_with_inbox_and_run_limit(max_pending, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn coordinator_with_inbox_and_run_limit(
|
||||||
|
max_pending: usize,
|
||||||
|
max_concurrent_runs: Option<usize>,
|
||||||
) -> (Arc<AgentCoordinator>, tempfile::TempDir) {
|
) -> (Arc<AgentCoordinator>, tempfile::TempDir) {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let storage = Arc::new(Storage::new(&dir.path().join("coord.db")).await.unwrap());
|
let storage = Arc::new(Storage::new(&dir.path().join("coord.db")).await.unwrap());
|
||||||
@ -1143,6 +1204,17 @@ mod tests {
|
|||||||
max_pending_inbox_events_per_session: max_pending,
|
max_pending_inbox_events_per_session: max_pending,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
let gate = match max_concurrent_runs {
|
||||||
|
Some(limit) => {
|
||||||
|
let config = crate::config::AgentOrchestrationConfig {
|
||||||
|
enabled: true,
|
||||||
|
max_concurrent_runs: limit,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
crate::agent::gate::ExecutionGate::new(&config)
|
||||||
|
}
|
||||||
|
None => crate::agent::gate::ExecutionGate::unbounded(),
|
||||||
|
};
|
||||||
(
|
(
|
||||||
AgentCoordinator::new(
|
AgentCoordinator::new(
|
||||||
storage,
|
storage,
|
||||||
@ -1150,7 +1222,7 @@ mod tests {
|
|||||||
work_manager,
|
work_manager,
|
||||||
notifier,
|
notifier,
|
||||||
Arc::new(crate::agent::AgentProjectionHub::new()),
|
Arc::new(crate::agent::AgentProjectionHub::new()),
|
||||||
crate::agent::gate::ExecutionGate::unbounded(),
|
gate,
|
||||||
crate::gateway::reload::RuntimeAdmission::open(),
|
crate::gateway::reload::RuntimeAdmission::open(),
|
||||||
supervisor,
|
supervisor,
|
||||||
1,
|
1,
|
||||||
@ -1223,18 +1295,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(first.group_id.is_some());
|
assert_ne!(first.id, second.id);
|
||||||
assert_eq!(first.group_id, second.group_id);
|
|
||||||
|
|
||||||
let group = coordinator
|
|
||||||
.storage
|
|
||||||
.get_agent_run_group(first.group_id.as_ref().unwrap())
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(group.expected_runs, 2);
|
|
||||||
assert_eq!(group.terminal_runs, 2);
|
|
||||||
assert!(group.status.is_terminal());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -1280,23 +1341,21 @@ mod tests {
|
|||||||
let (coordinator, _dir) = coordinator().await;
|
let (coordinator, _dir) = coordinator().await;
|
||||||
let caller = ToolExecutionContext::for_session("cli:test:dialog");
|
let caller = ToolExecutionContext::for_session("cli:test:dialog");
|
||||||
|
|
||||||
let run_id = coordinator
|
let admission = coordinator
|
||||||
.delegate_background(&caller, foreground_config("researcher"))
|
.delegate_background(&caller, vec![foreground_config("researcher")])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert_eq!(admission.run_ids.len(), 1);
|
||||||
|
let run_id = &admission.run_ids[0];
|
||||||
|
|
||||||
let run = coordinator
|
let run = coordinator.get_run(&caller, run_id).await.unwrap().unwrap();
|
||||||
.get_run(&caller, &run_id)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(run.mode, AgentRunMode::Background);
|
assert_eq!(run.mode, AgentRunMode::Background);
|
||||||
assert!(run.completion_slot_reserved);
|
assert!(run.completion_slot_reserved);
|
||||||
|
|
||||||
// A second background run while the inbox is at capacity must be
|
// A second background run while the inbox is at capacity must be
|
||||||
// rejected, not admitted silently.
|
// rejected, not admitted silently.
|
||||||
let error = coordinator
|
let error = coordinator
|
||||||
.delegate_background(&caller, foreground_config("researcher"))
|
.delegate_background(&caller, vec![foreground_config("researcher")])
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(error, CoordinatorError::Rejected(_)));
|
assert!(matches!(error, CoordinatorError::Rejected(_)));
|
||||||
@ -1306,11 +1365,7 @@ mod tests {
|
|||||||
// reservation into a durable completion event.
|
// reservation into a durable completion event.
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||||
loop {
|
loop {
|
||||||
let run = coordinator
|
let run = coordinator.get_run(&caller, run_id).await.unwrap().unwrap();
|
||||||
.get_run(&caller, &run_id)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
if run.status.is_terminal() {
|
if run.status.is_terminal() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -1342,6 +1397,44 @@ mod tests {
|
|||||||
assert_eq!(state, (1, 0));
|
assert_eq!(state, (1, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn background_batch_admits_all_runs_and_creates_a_group() {
|
||||||
|
let (coordinator, _dir) = coordinator_with_inbox_limit(8).await;
|
||||||
|
let caller = ToolExecutionContext::for_session("cli:test:dialog");
|
||||||
|
let configs = vec![
|
||||||
|
foreground_config("researcher"),
|
||||||
|
foreground_config("researcher"),
|
||||||
|
foreground_config("researcher"),
|
||||||
|
];
|
||||||
|
let admission = coordinator
|
||||||
|
.delegate_background(&caller, configs)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(admission.run_ids.len(), 3);
|
||||||
|
|
||||||
|
let runs = coordinator.list_runs(&caller, None, 10).await.unwrap();
|
||||||
|
assert_eq!(runs.len(), 3);
|
||||||
|
assert!(runs.iter().all(|run| run.completion_slot_reserved));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn background_batch_rejects_when_exceeding_run_quota() {
|
||||||
|
let (coordinator, _dir) = coordinator_with_inbox_and_run_limit(16, Some(2)).await;
|
||||||
|
let caller = ToolExecutionContext::for_session("cli:test:dialog");
|
||||||
|
let error = coordinator
|
||||||
|
.delegate_background(
|
||||||
|
&caller,
|
||||||
|
vec![
|
||||||
|
foreground_config("researcher"),
|
||||||
|
foreground_config("researcher"),
|
||||||
|
foreground_config("researcher"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, CoordinatorError::Rejected(_)));
|
||||||
|
}
|
||||||
|
|
||||||
async fn storage_accept_run_with_delivery(
|
async fn storage_accept_run_with_delivery(
|
||||||
storage: &Arc<Storage>,
|
storage: &Arc<Storage>,
|
||||||
run_id: &str,
|
run_id: &str,
|
||||||
@ -1382,7 +1475,6 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let _ = storage
|
let _ = storage
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
.accept_agent_runs(AcceptAgentRequest {
|
||||||
group: None,
|
|
||||||
runs: vec![run],
|
runs: vec![run],
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
@ -1418,7 +1510,6 @@ mod tests {
|
|||||||
root_turn_id: None,
|
root_turn_id: None,
|
||||||
run_id: run_id.to_string(),
|
run_id: run_id.to_string(),
|
||||||
execution_id: run_id.to_string(),
|
execution_id: run_id.to_string(),
|
||||||
group_id: None,
|
|
||||||
parent_run_id: None,
|
parent_run_id: None,
|
||||||
caller_agent_id: "ROOT".to_string(),
|
caller_agent_id: "ROOT".to_string(),
|
||||||
current_agent_id: "researcher".to_string(),
|
current_agent_id: "researcher".to_string(),
|
||||||
@ -1483,7 +1574,6 @@ mod tests {
|
|||||||
root_turn_id: None,
|
root_turn_id: None,
|
||||||
run_id: run_id.to_string(),
|
run_id: run_id.to_string(),
|
||||||
execution_id: run_id.to_string(),
|
execution_id: run_id.to_string(),
|
||||||
group_id: None,
|
|
||||||
parent_run_id: None,
|
parent_run_id: None,
|
||||||
caller_agent_id: "ROOT".to_string(),
|
caller_agent_id: "ROOT".to_string(),
|
||||||
current_agent_id: "researcher".to_string(),
|
current_agent_id: "researcher".to_string(),
|
||||||
@ -1573,7 +1663,6 @@ mod tests {
|
|||||||
root_turn_id: None,
|
root_turn_id: None,
|
||||||
run_id: run_id.to_string(),
|
run_id: run_id.to_string(),
|
||||||
execution_id: run_id.to_string(),
|
execution_id: run_id.to_string(),
|
||||||
group_id: None,
|
|
||||||
parent_run_id: None,
|
parent_run_id: None,
|
||||||
caller_agent_id: "ROOT".to_string(),
|
caller_agent_id: "ROOT".to_string(),
|
||||||
current_agent_id: "researcher".to_string(),
|
current_agent_id: "researcher".to_string(),
|
||||||
@ -1660,7 +1749,6 @@ mod tests {
|
|||||||
root_turn_id: None,
|
root_turn_id: None,
|
||||||
run_id: "run-sig-3".to_string(),
|
run_id: "run-sig-3".to_string(),
|
||||||
execution_id: "run-sig-3".to_string(),
|
execution_id: "run-sig-3".to_string(),
|
||||||
group_id: None,
|
|
||||||
parent_run_id: None,
|
parent_run_id: None,
|
||||||
caller_agent_id: "ROOT".to_string(),
|
caller_agent_id: "ROOT".to_string(),
|
||||||
current_agent_id: "researcher".to_string(),
|
current_agent_id: "researcher".to_string(),
|
||||||
@ -1711,7 +1799,6 @@ mod tests {
|
|||||||
root_turn_id: None,
|
root_turn_id: None,
|
||||||
run_id: run_id.to_string(),
|
run_id: run_id.to_string(),
|
||||||
execution_id: run_id.to_string(),
|
execution_id: run_id.to_string(),
|
||||||
group_id: None,
|
|
||||||
parent_run_id: None,
|
parent_run_id: None,
|
||||||
caller_agent_id: "ROOT".to_string(),
|
caller_agent_id: "ROOT".to_string(),
|
||||||
current_agent_id: "researcher".to_string(),
|
current_agent_id: "researcher".to_string(),
|
||||||
|
|||||||
@ -203,28 +203,51 @@ impl AgentLimits {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct AgentFrontmatter {
|
pub struct AgentFrontmatter {
|
||||||
id: String,
|
pub id: String,
|
||||||
description: String,
|
pub description: String,
|
||||||
llm_profile: String,
|
/// Either this (a key in `config.json`'s `agents` map) or the inline
|
||||||
|
/// `provider` + `model` pair must be present.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub llm_profile: Option<String>,
|
||||||
|
/// Inline provider/model selection; the preferred way to author an Agent
|
||||||
|
/// from the WebUI. Overrides `llm_profile` when both are present.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provider: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub token_limit: Option<usize>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_tool_iterations: Option<usize>,
|
||||||
|
/// Disabled definitions stay on disk but never load into the catalog.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub tools: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub delegates: Vec<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub skills: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tools: Vec<String>,
|
pub limits: AgentLimits,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
delegates: Vec<String>,
|
pub signal: Option<SignalContract>,
|
||||||
#[serde(default)]
|
}
|
||||||
skills: Vec<String>,
|
|
||||||
#[serde(default)]
|
pub fn default_true() -> bool {
|
||||||
limits: AgentLimits,
|
true
|
||||||
#[serde(default)]
|
|
||||||
signal: Option<SignalContract>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AgentDefinition {
|
pub struct AgentDefinition {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub llm_profile: String,
|
pub llm_profile: Option<String>,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
pub provider_config: Arc<LLMProviderConfig>,
|
pub provider_config: Arc<LLMProviderConfig>,
|
||||||
|
pub enabled: bool,
|
||||||
pub tools: Vec<String>,
|
pub tools: Vec<String>,
|
||||||
pub delegates: Vec<String>,
|
pub delegates: Vec<String>,
|
||||||
pub skills: Vec<String>,
|
pub skills: Vec<String>,
|
||||||
@ -253,6 +276,68 @@ pub(crate) fn parse_definition(
|
|||||||
path: &Path,
|
path: &Path,
|
||||||
provider_config: Arc<LLMProviderConfig>,
|
provider_config: Arc<LLMProviderConfig>,
|
||||||
) -> Result<AgentDefinition, AgentDefinitionError> {
|
) -> Result<AgentDefinition, AgentDefinitionError> {
|
||||||
|
let (frontmatter, role_prompt) = read_frontmatter(path)?;
|
||||||
|
let canonical_frontmatter = serde_json::to_vec(&frontmatter)
|
||||||
|
.map_err(|error| AgentDefinitionError::Invalid(error.to_string()))?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(canonical_frontmatter);
|
||||||
|
hasher.update(b"\n---\n");
|
||||||
|
hasher.update(role_prompt.as_bytes());
|
||||||
|
let definition_hash = hasher
|
||||||
|
.finalize()
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(AgentDefinition {
|
||||||
|
id: frontmatter.id,
|
||||||
|
description: frontmatter.description.trim().to_string(),
|
||||||
|
llm_profile: frontmatter.llm_profile,
|
||||||
|
provider: frontmatter.provider,
|
||||||
|
model: frontmatter.model,
|
||||||
|
provider_config,
|
||||||
|
enabled: frontmatter.enabled,
|
||||||
|
tools: frontmatter.tools,
|
||||||
|
delegates: frontmatter.delegates,
|
||||||
|
skills: frontmatter.skills,
|
||||||
|
limits: frontmatter.limits,
|
||||||
|
signal_contract: frontmatter.signal,
|
||||||
|
role_prompt,
|
||||||
|
definition_hash,
|
||||||
|
source_path: path.to_path_buf(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider-agnostic view of a definition file, used by the management UI.
|
||||||
|
/// It carries the frontmatter plus the role body but no resolved
|
||||||
|
/// `provider_config`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentDefinitionInfo {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub frontmatter: AgentFrontmatter,
|
||||||
|
pub role_prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and validate a definition file without resolving its provider
|
||||||
|
/// config. Used by the management API to list/validate definitions; the
|
||||||
|
/// catalog performs the full provider/tool/delegate resolution on load.
|
||||||
|
pub fn parse_definition_info(path: &Path) -> Result<AgentDefinitionInfo, AgentDefinitionError> {
|
||||||
|
let (frontmatter, role_prompt) = read_frontmatter(path)?;
|
||||||
|
Ok(AgentDefinitionInfo {
|
||||||
|
frontmatter,
|
||||||
|
role_prompt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize a definition back to the Markdown file format.
|
||||||
|
pub fn serialize_definition(info: &AgentDefinitionInfo) -> String {
|
||||||
|
let yaml = serde_yaml::to_string(&info.frontmatter).unwrap_or_default();
|
||||||
|
format!("---\n{yaml}---\n{}\n", info.role_prompt.trim_end())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read + validate the frontmatter and role body of a definition file,
|
||||||
|
/// shared by `parse_definition` and `parse_definition_info`.
|
||||||
|
fn read_frontmatter(path: &Path) -> Result<(AgentFrontmatter, String), AgentDefinitionError> {
|
||||||
let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io {
|
let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io {
|
||||||
path: path.display().to_string(),
|
path: path.display().to_string(),
|
||||||
source,
|
source,
|
||||||
@ -320,9 +405,20 @@ pub(crate) fn parse_definition(
|
|||||||
frontmatter.id
|
frontmatter.id
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if frontmatter.llm_profile.trim().is_empty() {
|
let has_profile = frontmatter
|
||||||
|
.llm_profile
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|p| !p.trim().is_empty());
|
||||||
|
let has_inline = frontmatter.provider.is_some() || frontmatter.model.is_some();
|
||||||
|
if !has_profile && !has_inline {
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
return Err(AgentDefinitionError::Invalid(format!(
|
||||||
"Agent '{}' has an empty llm_profile",
|
"Agent '{}' must declare either llm_profile or provider+model",
|
||||||
|
frontmatter.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if frontmatter.provider.is_some() != frontmatter.model.is_some() {
|
||||||
|
return Err(AgentDefinitionError::Invalid(format!(
|
||||||
|
"Agent '{}' must declare provider and model together",
|
||||||
frontmatter.id
|
frontmatter.id
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@ -333,7 +429,6 @@ pub(crate) fn parse_definition(
|
|||||||
reject_duplicates("tools", &frontmatter.tools)?;
|
reject_duplicates("tools", &frontmatter.tools)?;
|
||||||
reject_duplicates("delegates", &frontmatter.delegates)?;
|
reject_duplicates("delegates", &frontmatter.delegates)?;
|
||||||
reject_duplicates("skills", &frontmatter.skills)?;
|
reject_duplicates("skills", &frontmatter.skills)?;
|
||||||
|
|
||||||
let file_stem = path.file_stem().and_then(|value| value.to_str());
|
let file_stem = path.file_stem().and_then(|value| value.to_str());
|
||||||
if file_stem != Some(frontmatter.id.as_str()) {
|
if file_stem != Some(frontmatter.id.as_str()) {
|
||||||
return Err(AgentDefinitionError::Invalid(format!(
|
return Err(AgentDefinitionError::Invalid(format!(
|
||||||
@ -342,33 +437,7 @@ pub(crate) fn parse_definition(
|
|||||||
path.display()
|
path.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
Ok((frontmatter, role_prompt))
|
||||||
let canonical_frontmatter = serde_json::to_vec(&frontmatter)
|
|
||||||
.map_err(|error| AgentDefinitionError::Invalid(error.to_string()))?;
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(canonical_frontmatter);
|
|
||||||
hasher.update(b"\n---\n");
|
|
||||||
hasher.update(role_prompt.as_bytes());
|
|
||||||
let definition_hash = hasher
|
|
||||||
.finalize()
|
|
||||||
.iter()
|
|
||||||
.map(|byte| format!("{byte:02x}"))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(AgentDefinition {
|
|
||||||
id: frontmatter.id,
|
|
||||||
description: frontmatter.description.trim().to_string(),
|
|
||||||
llm_profile: frontmatter.llm_profile,
|
|
||||||
provider_config,
|
|
||||||
tools: frontmatter.tools,
|
|
||||||
delegates: frontmatter.delegates,
|
|
||||||
skills: frontmatter.skills,
|
|
||||||
limits: frontmatter.limits,
|
|
||||||
signal_contract: frontmatter.signal,
|
|
||||||
role_prompt,
|
|
||||||
definition_hash,
|
|
||||||
source_path: path.to_path_buf(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_agent_id(id: &str) -> Result<(), AgentDefinitionError> {
|
pub fn validate_agent_id(id: &str) -> Result<(), AgentDefinitionError> {
|
||||||
|
|||||||
@ -67,6 +67,7 @@ impl KeyedSemaphores {
|
|||||||
/// provider/tool step permits have independent lifecycles; acquisition order
|
/// provider/tool step permits have independent lifecycles; acquisition order
|
||||||
/// is always global -> session and release order is reversed.
|
/// is always global -> session and release order is reversed.
|
||||||
pub struct ExecutionGate {
|
pub struct ExecutionGate {
|
||||||
|
max_concurrent_runs: usize,
|
||||||
run_global: Arc<Semaphore>,
|
run_global: Arc<Semaphore>,
|
||||||
run_session: Arc<KeyedSemaphores>,
|
run_session: Arc<KeyedSemaphores>,
|
||||||
provider_global: Arc<Semaphore>,
|
provider_global: Arc<Semaphore>,
|
||||||
@ -91,6 +92,7 @@ impl std::fmt::Debug for ExecutionGate {
|
|||||||
impl ExecutionGate {
|
impl ExecutionGate {
|
||||||
pub fn new(config: &crate::config::AgentOrchestrationConfig) -> Arc<Self> {
|
pub fn new(config: &crate::config::AgentOrchestrationConfig) -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
|
max_concurrent_runs: config.max_concurrent_runs,
|
||||||
run_global: Arc::new(Semaphore::new(config.max_concurrent_runs)),
|
run_global: Arc::new(Semaphore::new(config.max_concurrent_runs)),
|
||||||
run_session: Arc::new(KeyedSemaphores::new(config.max_concurrent_runs_per_session)),
|
run_session: Arc::new(KeyedSemaphores::new(config.max_concurrent_runs_per_session)),
|
||||||
provider_global: Arc::new(Semaphore::new(config.max_concurrent_provider_steps)),
|
provider_global: Arc::new(Semaphore::new(config.max_concurrent_provider_steps)),
|
||||||
@ -108,6 +110,7 @@ impl ExecutionGate {
|
|||||||
/// route through it so the code path stays uniform.
|
/// route through it so the code path stays uniform.
|
||||||
pub fn unbounded() -> Arc<Self> {
|
pub fn unbounded() -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
|
max_concurrent_runs: usize::MAX,
|
||||||
run_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
|
run_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
|
||||||
run_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)),
|
run_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)),
|
||||||
provider_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
|
provider_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
|
||||||
@ -117,6 +120,12 @@ impl ExecutionGate {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Global run quota ceiling; background batches larger than this are
|
||||||
|
/// rejected up front instead of queueing indefinitely.
|
||||||
|
pub fn max_concurrent_runs(&self) -> usize {
|
||||||
|
self.max_concurrent_runs
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn acquire_run(
|
pub async fn acquire_run(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
|
|||||||
@ -25,7 +25,6 @@ pub struct AgentExecutionContext {
|
|||||||
/// Execution attempt identifier owning conditional state transitions in
|
/// Execution attempt identifier owning conditional state transitions in
|
||||||
/// Storage. Equal to `run_id` for the first attempt.
|
/// Storage. Equal to `run_id` for the first attempt.
|
||||||
pub execution_id: String,
|
pub execution_id: String,
|
||||||
pub group_id: Option<String>,
|
|
||||||
pub parent_run_id: Option<String>,
|
pub parent_run_id: Option<String>,
|
||||||
pub caller_agent_id: String,
|
pub caller_agent_id: String,
|
||||||
pub current_agent_id: String,
|
pub current_agent_id: String,
|
||||||
@ -62,7 +61,6 @@ impl AgentExecutionContext {
|
|||||||
root_turn_id: parent.root_turn_id.clone(),
|
root_turn_id: parent.root_turn_id.clone(),
|
||||||
run_id: run_id.clone(),
|
run_id: run_id.clone(),
|
||||||
execution_id: run_id,
|
execution_id: run_id,
|
||||||
group_id: None,
|
|
||||||
parent_run_id: Some(parent.run_id.clone()),
|
parent_run_id: Some(parent.run_id.clone()),
|
||||||
caller_agent_id: parent.current_agent_id.clone(),
|
caller_agent_id: parent.current_agent_id.clone(),
|
||||||
current_agent_id: target,
|
current_agent_id: target,
|
||||||
@ -110,7 +108,6 @@ mod tests {
|
|||||||
root_turn_id: None,
|
root_turn_id: None,
|
||||||
run_id: "run-root".to_string(),
|
run_id: "run-root".to_string(),
|
||||||
execution_id: "run-root".to_string(),
|
execution_id: "run-root".to_string(),
|
||||||
group_id: None,
|
|
||||||
parent_run_id: None,
|
parent_run_id: None,
|
||||||
caller_agent_id: "ROOT".to_string(),
|
caller_agent_id: "ROOT".to_string(),
|
||||||
current_agent_id: "researcher".to_string(),
|
current_agent_id: "researcher".to_string(),
|
||||||
|
|||||||
@ -38,7 +38,6 @@ pub enum TurnInputSource {
|
|||||||
User,
|
User,
|
||||||
AgentSignal { run_id: String, agent_id: String },
|
AgentSignal { run_id: String, agent_id: String },
|
||||||
AgentCompletion { run_id: String, agent_id: String },
|
AgentCompletion { run_id: String, agent_id: String },
|
||||||
AgentGroupCompletion { group_id: String },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TurnInputSource {
|
impl TurnInputSource {
|
||||||
@ -61,11 +60,6 @@ impl From<&TurnInputSource> for WakeupSource {
|
|||||||
agent_id: agent_id.clone(),
|
agent_id: agent_id.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TurnInputSource::AgentGroupCompletion { group_id } => {
|
|
||||||
WakeupSource::AgentGroupCompletion {
|
|
||||||
group_id: group_id.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -78,7 +72,6 @@ pub enum WakeupSource {
|
|||||||
UserQueue,
|
UserQueue,
|
||||||
AgentSignal { run_id: String, agent_id: String },
|
AgentSignal { run_id: String, agent_id: String },
|
||||||
AgentCompletion { run_id: String, agent_id: String },
|
AgentCompletion { run_id: String, agent_id: String },
|
||||||
AgentGroupCompletion { group_id: String },
|
|
||||||
AgentQueue,
|
AgentQueue,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,7 +208,6 @@ impl TurnInput {
|
|||||||
task_id: self.durable_event_id.clone(),
|
task_id: self.durable_event_id.clone(),
|
||||||
from_run_id: Some(run_id.clone()),
|
from_run_id: Some(run_id.clone()),
|
||||||
from_agent_id: Some(agent_id.clone()),
|
from_agent_id: Some(agent_id.clone()),
|
||||||
group_id: None,
|
|
||||||
};
|
};
|
||||||
(crate::bus::ClientVisibility::Hidden, Some(source))
|
(crate::bus::ClientVisibility::Hidden, Some(source))
|
||||||
}
|
}
|
||||||
@ -229,21 +221,6 @@ impl TurnInput {
|
|||||||
task_id: self.durable_event_id.clone(),
|
task_id: self.durable_event_id.clone(),
|
||||||
from_run_id: Some(run_id.clone()),
|
from_run_id: Some(run_id.clone()),
|
||||||
from_agent_id: Some(agent_id.clone()),
|
from_agent_id: Some(agent_id.clone()),
|
||||||
group_id: None,
|
|
||||||
};
|
|
||||||
(crate::bus::ClientVisibility::Hidden, Some(source))
|
|
||||||
}
|
|
||||||
TurnInputSource::AgentGroupCompletion { group_id } => {
|
|
||||||
let source = MessageSource {
|
|
||||||
kind: crate::bus::SourceKind::AgentGroupCompletion,
|
|
||||||
from_channel: None,
|
|
||||||
from_session: None,
|
|
||||||
from_user_id: None,
|
|
||||||
system_name: None,
|
|
||||||
task_id: self.durable_event_id.clone(),
|
|
||||||
from_run_id: None,
|
|
||||||
from_agent_id: None,
|
|
||||||
group_id: Some(group_id.clone()),
|
|
||||||
};
|
};
|
||||||
(crate::bus::ClientVisibility::Hidden, Some(source))
|
(crate::bus::ClientVisibility::Hidden, Some(source))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
|
||||||
use crate::agent::AgentError;
|
use crate::agent::AgentError;
|
||||||
use crate::agent::AgentLoop;
|
use crate::agent::AgentLoop;
|
||||||
use crate::agent::system_prompt::build_sub_agent_system_prompt;
|
use crate::agent::system_prompt::build_sub_agent_system_prompt;
|
||||||
@ -163,7 +162,6 @@ impl SubAgentManager {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub(crate) fn resolve_agent(
|
pub(crate) fn resolve_agent(
|
||||||
&self,
|
&self,
|
||||||
config: &SubAgentConfig,
|
config: &SubAgentConfig,
|
||||||
@ -258,7 +256,6 @@ impl SubAgentManager {
|
|||||||
root_turn_id: caller.turn_id.clone(),
|
root_turn_id: caller.turn_id.clone(),
|
||||||
run_id: task_id.to_string(),
|
run_id: task_id.to_string(),
|
||||||
execution_id: task_id.to_string(),
|
execution_id: task_id.to_string(),
|
||||||
group_id: None,
|
|
||||||
parent_run_id: None,
|
parent_run_id: None,
|
||||||
caller_agent_id: "ROOT".to_string(),
|
caller_agent_id: "ROOT".to_string(),
|
||||||
current_agent_id: target.to_string(),
|
current_agent_id: target.to_string(),
|
||||||
@ -349,7 +346,7 @@ impl SubAgentManager {
|
|||||||
skills_prompt,
|
skills_prompt,
|
||||||
agent_id: Some(target.to_string()),
|
agent_id: Some(target.to_string()),
|
||||||
definition_hash: Some(definition.definition_hash.clone()),
|
definition_hash: Some(definition.definition_hash.clone()),
|
||||||
llm_profile: Some(definition.llm_profile.clone()),
|
llm_profile: definition.llm_profile.clone(),
|
||||||
signal_contract: definition.signal_contract.clone(),
|
signal_contract: definition.signal_contract.clone(),
|
||||||
tool_context: ToolExecutionContext::for_session(format!("agent-run:{task_id}"))
|
tool_context: ToolExecutionContext::for_session(format!("agent-run:{task_id}"))
|
||||||
.with_turn_id(
|
.with_turn_id(
|
||||||
@ -530,7 +527,6 @@ fn terminal_status_from_error(error: AgentError) -> TaskStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn format_duration(seconds: u64) -> String {
|
fn format_duration(seconds: u64) -> String {
|
||||||
if seconds < 60 {
|
if seconds < 60 {
|
||||||
format!("{}s", seconds)
|
format!("{}s", seconds)
|
||||||
@ -602,20 +598,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reload_tool_is_never_delegated_to_sub_agents() {
|
fn runtime_injected_tools_are_marked_but_ordinary_tools_are_not() {
|
||||||
let tool = crate::tools::ReloadConfigTool::new(
|
let reload = crate::tools::ReloadConfigTool::new(
|
||||||
crate::gateway::reload::ReloadHandle::unavailable(),
|
crate::gateway::reload::ReloadHandle::unavailable(),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert!(!crate::tools::Tool::runtime_injected(&reload));
|
||||||
crate::tools::Tool::delegation_policy(&tool),
|
|
||||||
crate::tools::DelegationPolicy::RootOnly
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_agent_rejects_missing_target() {
|
fn resolve_agent_rejects_missing_target() {
|
||||||
let manager = manager();
|
let manager = manager();
|
||||||
let error = match manager.resolve_agent(&config(None), &ToolExecutionContext::default(), "t-1") {
|
let error =
|
||||||
|
match manager.resolve_agent(&config(None), &ToolExecutionContext::default(), "t-1") {
|
||||||
Ok(_) => panic!("expected rejection"),
|
Ok(_) => panic!("expected rejection"),
|
||||||
Err(error) => error,
|
Err(error) => error,
|
||||||
};
|
};
|
||||||
@ -625,10 +619,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn resolve_agent_rejects_unknown_target_without_catalog() {
|
fn resolve_agent_rejects_unknown_target_without_catalog() {
|
||||||
let manager = manager();
|
let manager = manager();
|
||||||
let error = match manager.resolve_agent(&config(Some("ghost")), &ToolExecutionContext::default(), "t-2") {
|
let error = match manager.resolve_agent(
|
||||||
|
&config(Some("ghost")),
|
||||||
|
&ToolExecutionContext::default(),
|
||||||
|
"t-2",
|
||||||
|
) {
|
||||||
Ok(_) => panic!("expected rejection"),
|
Ok(_) => panic!("expected rejection"),
|
||||||
Err(error) => error,
|
Err(error) => error,
|
||||||
};
|
};
|
||||||
assert!(matches!(error, SubAgentError::Other(message) if message.contains("orchestration")));
|
assert!(
|
||||||
|
matches!(error, SubAgentError::Other(message) if message.contains("orchestration"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -347,10 +347,10 @@ impl PromptSection for DelegationSection {
|
|||||||
fn build(&self, _ctx: &PromptContext<'_>) -> String {
|
fn build(&self, _ctx: &PromptContext<'_>) -> String {
|
||||||
"## 子 Agent 委托原则\n\n\
|
"## 子 Agent 委托原则\n\n\
|
||||||
- 只有当任务可以拆成独立子任务时才委托。\n\
|
- 只有当任务可以拆成独立子任务时才委托。\n\
|
||||||
- 子 Agent 只拿完成任务所需的最小工具集。\n\
|
- 子 Agent 的工具集由其定义文件(agents/*.md 的 tools 列表)决定,不要重复说明它已有哪些工具。\n\
|
||||||
- 永远不要把 delegate 工具再分给子 Agent。\n\
|
- 子 Agent 能否继续委托由它的 delegates 白名单决定,你不需要、也无法给它额外授权。\n\
|
||||||
- 子任务 prompt 要直接写清目标、输出格式和限制。\n\
|
- 子任务 prompt 要直接写清目标、输出格式和限制。\n\
|
||||||
- 并行任务彼此不能依赖,长期任务用 background。"
|
- 并行任务彼此不能依赖;后台等待用 background(单任务或 tasks 批量,每个 run 独立返回)。"
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -218,9 +218,6 @@ pub enum SourceKind {
|
|||||||
/// A durable background run completion outcome.
|
/// A durable background run completion outcome.
|
||||||
#[serde(rename = "agent_result")]
|
#[serde(rename = "agent_result")]
|
||||||
AgentCompletion,
|
AgentCompletion,
|
||||||
/// A durable background group completion outcome.
|
|
||||||
#[serde(rename = "agent_group_result")]
|
|
||||||
AgentGroupCompletion,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -237,9 +234,6 @@ pub struct MessageSource {
|
|||||||
/// Agent definition id for `agent_signal`/`agent_result` sources.
|
/// Agent definition id for `agent_signal`/`agent_result` sources.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub from_agent_id: Option<String>,
|
pub from_agent_id: Option<String>,
|
||||||
/// Durable group identity for `agent_group_result` sources.
|
|
||||||
#[serde(default)]
|
|
||||||
pub group_id: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatMessage {
|
impl ChatMessage {
|
||||||
|
|||||||
@ -315,8 +315,6 @@ pub struct GatewayConfig {
|
|||||||
pub cleanup_interval_minutes: Option<u64>,
|
pub cleanup_interval_minutes: Option<u64>,
|
||||||
#[serde(default, rename = "session_db_path")]
|
#[serde(default, rename = "session_db_path")]
|
||||||
pub session_db_path: Option<String>,
|
pub session_db_path: Option<String>,
|
||||||
#[serde(default, rename = "max_concurrent_background_tasks")]
|
|
||||||
pub max_concurrent_background_tasks: usize,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub scheduler: Option<SchedulerConfig>,
|
pub scheduler: Option<SchedulerConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -332,7 +330,6 @@ impl Default for GatewayConfig {
|
|||||||
session_ttl_hours: None,
|
session_ttl_hours: None,
|
||||||
cleanup_interval_minutes: None,
|
cleanup_interval_minutes: None,
|
||||||
session_db_path: None,
|
session_db_path: None,
|
||||||
max_concurrent_background_tasks: 10,
|
|
||||||
scheduler: None,
|
scheduler: None,
|
||||||
file_transfer: FileTransferConfig::default(),
|
file_transfer: FileTransferConfig::default(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -857,6 +857,259 @@ pub async fn get_tools(State(state): State<Arc<GatewayState>>) -> Result<Json<Va
|
|||||||
Ok(Json(json!({ "tools": tools })))
|
Ok(Json(json!({ "tools": tools })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List Agent definition files (enabled and disabled) from the resolved
|
||||||
|
/// definitions directory.
|
||||||
|
pub async fn list_agents(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
|
||||||
|
let mut agents = Vec::new();
|
||||||
|
let entries = match std::fs::read_dir(&state.agents_dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_error) => {
|
||||||
|
return Ok(Json(json!({ "agents": [] })));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match crate::agent::definition::parse_definition_info(&path) {
|
||||||
|
Ok(info) => {
|
||||||
|
let fm = &info.frontmatter;
|
||||||
|
agents.push(json!({
|
||||||
|
"id": fm.id,
|
||||||
|
"description": fm.description,
|
||||||
|
"enabled": fm.enabled,
|
||||||
|
"llm_profile": fm.llm_profile,
|
||||||
|
"provider": fm.provider,
|
||||||
|
"model": fm.model,
|
||||||
|
"token_limit": fm.token_limit,
|
||||||
|
"max_tool_iterations": fm.max_tool_iterations,
|
||||||
|
"tools": fm.tools,
|
||||||
|
"delegates": fm.delegates,
|
||||||
|
"skills": fm.skills,
|
||||||
|
"limits": fm.limits,
|
||||||
|
"signal": fm.signal,
|
||||||
|
"role_prompt": info.role_prompt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(path = %path.display(), error = %error, "Failed to parse Agent definition");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agents.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
|
||||||
|
Ok(Json(json!({ "agents": agents })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create or update a definition file under the definitions directory.
|
||||||
|
pub async fn put_agent(
|
||||||
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let info = agent_info_from_json(&body)?;
|
||||||
|
let fm = &info.frontmatter;
|
||||||
|
|
||||||
|
// Validate referenced provider/model/tools/skills so a broken file is
|
||||||
|
// rejected at the API boundary instead of breaking the next reload.
|
||||||
|
if let Some(provider) = fm.provider.as_deref()
|
||||||
|
&& !state.config.providers.contains_key(provider)
|
||||||
|
{
|
||||||
|
return Err(ApiError::bad_request(format!(
|
||||||
|
"unknown provider '{provider}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(model) = fm.model.as_deref()
|
||||||
|
&& !state.config.models.contains_key(model)
|
||||||
|
{
|
||||||
|
return Err(ApiError::bad_request(format!("unknown model '{model}'")));
|
||||||
|
}
|
||||||
|
let registry = state.session_manager.tools();
|
||||||
|
for tool in &fm.tools {
|
||||||
|
let Some(registered) = registry.get(tool) else {
|
||||||
|
return Err(ApiError::bad_request(format!("unknown tool '{tool}'")));
|
||||||
|
};
|
||||||
|
if registered.runtime_injected() && tool != "get_skill" {
|
||||||
|
return Err(ApiError::bad_request(format!(
|
||||||
|
"tool '{tool}' is runtime-injected and cannot be declared"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let loaded_skills: std::collections::HashSet<String> = state
|
||||||
|
.session_manager
|
||||||
|
.skills_loader()
|
||||||
|
.list_skills()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, _)| name)
|
||||||
|
.collect();
|
||||||
|
for skill in &fm.skills {
|
||||||
|
if !loaded_skills.contains(skill) {
|
||||||
|
return Err(ApiError::bad_request(format!("unknown skill '{skill}'")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !fm.skills.is_empty() && !fm.tools.iter().any(|t| t == "get_skill") {
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"skills require the get_skill tool in the definition",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = crate::agent::definition::serialize_definition(&info);
|
||||||
|
tokio::fs::create_dir_all(&state.agents_dir)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
let path = state.agents_dir.join(format!("{}.md", fm.id));
|
||||||
|
tokio::fs::write(&path, content)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
Ok(Json(json!({ "id": fm.id, "saved": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a definition file.
|
||||||
|
pub async fn delete_agent(
|
||||||
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
crate::agent::definition::validate_agent_id(&id)
|
||||||
|
.map_err(|error| ApiError::bad_request(error.to_string()))?;
|
||||||
|
let path = state.agents_dir.join(format!("{id}.md"));
|
||||||
|
match tokio::fs::remove_file(&path).await {
|
||||||
|
Ok(()) => Ok(Json(json!({ "deleted": id }))),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
Err(ApiError::not_found(format!("agent {id} not found")))
|
||||||
|
}
|
||||||
|
Err(error) => Err(ApiError::internal(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Available providers/models/tools/skills for the editor UI.
|
||||||
|
pub async fn get_agent_options(
|
||||||
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let providers: Vec<String> = state.config.providers.keys().cloned().collect();
|
||||||
|
let models: Vec<Value> = state
|
||||||
|
.config
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.map(|(name, model)| json!({ "name": name, "model_id": model.model_id }))
|
||||||
|
.collect();
|
||||||
|
let registry = state.session_manager.tools();
|
||||||
|
let mut tools: Vec<Value> = registry
|
||||||
|
.iter()
|
||||||
|
.into_iter()
|
||||||
|
// get_skill is the one runtime-injected tool that may be declared in
|
||||||
|
// a definition's `tools` list (it turns on the scoped skill wrapper),
|
||||||
|
// so it must be offered in the editor.
|
||||||
|
.filter(|(name, tool)| {
|
||||||
|
(!tool.runtime_injected() || name == "get_skill") && !name.contains("__")
|
||||||
|
})
|
||||||
|
.map(|(name, tool)| json!({ "name": name, "description": tool.description() }))
|
||||||
|
.collect();
|
||||||
|
tools.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||||
|
let skills: Vec<String> = state
|
||||||
|
.session_manager
|
||||||
|
.skills_loader()
|
||||||
|
.list_skills()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, _)| name)
|
||||||
|
.collect();
|
||||||
|
Ok(Json(json!({
|
||||||
|
"providers": providers,
|
||||||
|
"models": models,
|
||||||
|
"tools": tools,
|
||||||
|
"skills": skills,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_info_from_json(
|
||||||
|
body: &Value,
|
||||||
|
) -> Result<crate::agent::definition::AgentDefinitionInfo, ApiError> {
|
||||||
|
use crate::agent::definition::{AgentDefinitionInfo, AgentFrontmatter, AgentLimits};
|
||||||
|
let id = body
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| ApiError::bad_request("missing required field: id"))?
|
||||||
|
.to_string();
|
||||||
|
crate::agent::definition::validate_agent_id(&id)
|
||||||
|
.map_err(|error| ApiError::bad_request(error.to_string()))?;
|
||||||
|
let description = body
|
||||||
|
.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let role_prompt = body
|
||||||
|
.get("role_prompt")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
if role_prompt.trim().is_empty() {
|
||||||
|
return Err(ApiError::bad_request("role_prompt must not be empty"));
|
||||||
|
}
|
||||||
|
let provider = body
|
||||||
|
.get("provider")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string);
|
||||||
|
let model = body
|
||||||
|
.get("model")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string);
|
||||||
|
let llm_profile = body
|
||||||
|
.get("llm_profile")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string);
|
||||||
|
if provider.is_none() && model.is_none() && llm_profile.as_deref().is_none_or(str::is_empty) {
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"either provider+model or llm_profile is required",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if provider.is_some() != model.is_some() {
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"provider and model must be set together",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let info = AgentDefinitionInfo {
|
||||||
|
frontmatter: AgentFrontmatter {
|
||||||
|
id,
|
||||||
|
description,
|
||||||
|
llm_profile: llm_profile.filter(|v| !v.is_empty()),
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
token_limit: body
|
||||||
|
.get("token_limit")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.map(|v| v as usize),
|
||||||
|
max_tool_iterations: body
|
||||||
|
.get("max_tool_iterations")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.map(|v| v as usize),
|
||||||
|
enabled: body.get("enabled").and_then(Value::as_bool).unwrap_or(true),
|
||||||
|
tools: string_array(body, "tools"),
|
||||||
|
delegates: string_array(body, "delegates"),
|
||||||
|
skills: string_array(body, "skills"),
|
||||||
|
limits: body
|
||||||
|
.get("limits")
|
||||||
|
.and_then(|v| serde_json::from_value::<AgentLimits>(v.clone()).ok())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
signal: body
|
||||||
|
.get("signal")
|
||||||
|
.and_then(|v| serde_json::from_value(v.clone()).ok()),
|
||||||
|
},
|
||||||
|
role_prompt,
|
||||||
|
};
|
||||||
|
Ok(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_array(body: &Value, key: &str) -> Vec<String> {
|
||||||
|
body.get(key)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_skills(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
|
pub async fn get_skills(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
|
||||||
let loader = state.session_manager.skills_loader();
|
let loader = state.session_manager.skills_loader();
|
||||||
let skills: Vec<Value> = loader
|
let skills: Vec<Value> = loader
|
||||||
@ -879,28 +1132,19 @@ pub async fn get_tasks(
|
|||||||
Query(query): Query<LimitQuery>,
|
Query(query): Query<LimitQuery>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let limit = query.limit.unwrap_or(100).clamp(1, 500);
|
let limit = query.limit.unwrap_or(100).clamp(1, 500);
|
||||||
// Unified projection: new durable agent_runs plus the legacy
|
|
||||||
// background_tasks records (read-only). Merged by created_at.
|
|
||||||
let runs = state
|
let runs = state
|
||||||
.storage
|
.storage
|
||||||
.list_all_agent_runs(None, limit as i64)
|
.list_all_agent_runs(None, limit as i64)
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError::internal)?;
|
.map_err(ApiError::internal)?;
|
||||||
let legacy = state
|
let tasks: Vec<Value> = runs
|
||||||
.storage
|
.into_iter()
|
||||||
.list_recent_background_tasks(limit)
|
.map(|run| {
|
||||||
.await
|
json!({
|
||||||
.map_err(ApiError::internal)?;
|
|
||||||
let mut tasks: Vec<Value> = Vec::with_capacity(runs.len() + legacy.len());
|
|
||||||
for run in runs {
|
|
||||||
tasks.push(json!({
|
|
||||||
"source": "agent_run",
|
"source": "agent_run",
|
||||||
"id": run.id,
|
"id": run.id,
|
||||||
"group_id": run.group_id,
|
|
||||||
"parent_run_id": run.parent_run_id,
|
"parent_run_id": run.parent_run_id,
|
||||||
"session_id": run.root_session_id,
|
"session_id": run.root_session_id,
|
||||||
"channel": null,
|
|
||||||
"chat_id": null,
|
|
||||||
"agent_id": run.agent_id,
|
"agent_id": run.agent_id,
|
||||||
"mode": run.mode.as_str(),
|
"mode": run.mode.as_str(),
|
||||||
"depth": run.depth,
|
"depth": run.depth,
|
||||||
@ -913,33 +1157,9 @@ pub async fn get_tasks(
|
|||||||
"started_at": run.started_at,
|
"started_at": run.started_at,
|
||||||
"finished_at": run.finished_at,
|
"finished_at": run.finished_at,
|
||||||
"created_at": run.created_at,
|
"created_at": run.created_at,
|
||||||
}));
|
})
|
||||||
}
|
})
|
||||||
for task in legacy {
|
.collect();
|
||||||
tasks.push(json!({
|
|
||||||
"source": "legacy_background_task",
|
|
||||||
"id": task.id,
|
|
||||||
"session_id": task.session_id,
|
|
||||||
"channel": task.channel,
|
|
||||||
"chat_id": task.chat_id,
|
|
||||||
"prompt": task.prompt,
|
|
||||||
"status": task.status,
|
|
||||||
"result": task.result,
|
|
||||||
"error": task.error,
|
|
||||||
"tool_calls_count": task.tool_calls_count,
|
|
||||||
"iterations": task.iterations,
|
|
||||||
"started_at": task.started_at,
|
|
||||||
"finished_at": task.finished_at,
|
|
||||||
"created_at": task.created_at,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
tasks.sort_by(|left, right| {
|
|
||||||
right
|
|
||||||
.get("created_at")
|
|
||||||
.and_then(Value::as_i64)
|
|
||||||
.cmp(&left.get("created_at").and_then(Value::as_i64))
|
|
||||||
});
|
|
||||||
tasks.truncate(limit);
|
|
||||||
Ok(Json(json!({ "tasks": tasks })))
|
Ok(Json(json!({ "tasks": tasks })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -53,6 +53,8 @@ pub struct GatewayState {
|
|||||||
pub(crate) reload: reload::ReloadHandle,
|
pub(crate) reload: reload::ReloadHandle,
|
||||||
pub(crate) admission: reload::RuntimeAdmission,
|
pub(crate) admission: reload::RuntimeAdmission,
|
||||||
pub agent_catalog: Arc<crate::agent::AgentCatalog>,
|
pub agent_catalog: Arc<crate::agent::AgentCatalog>,
|
||||||
|
/// Directory holding Agent definition files (resolved definitions_dir).
|
||||||
|
pub agents_dir: std::path::PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GatewayState {
|
impl GatewayState {
|
||||||
@ -190,11 +192,26 @@ impl GatewayState {
|
|||||||
.unwrap_or_else(|| std::path::Path::new("."))
|
.unwrap_or_else(|| std::path::Path::new("."))
|
||||||
.to_path_buf();
|
.to_path_buf();
|
||||||
|
|
||||||
|
// Resolve the Agent definitions directory exactly like the catalog
|
||||||
|
// does (relative paths stay inside the trusted config dir).
|
||||||
|
let agents_dir = {
|
||||||
|
let configured =
|
||||||
|
crate::config::expand_path(&config.agent_orchestration.definitions_dir);
|
||||||
|
if configured.is_absolute() {
|
||||||
|
configured
|
||||||
|
} else {
|
||||||
|
config_dir.join(configured)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Create SessionManager with bus injection
|
// Create SessionManager with bus injection
|
||||||
let session_manager = SessionManager::new(
|
let session_manager = SessionManager::new(
|
||||||
provider_config.clone(),
|
provider_config.clone(),
|
||||||
AgentCatalogPreparation {
|
AgentCatalogPreparation {
|
||||||
provider_profiles,
|
provider_profiles,
|
||||||
|
providers: config.providers.clone(),
|
||||||
|
models: config.models.clone(),
|
||||||
|
workspace_dir: crate::config::expand_path(&config.workspace_dir),
|
||||||
config: config.agent_orchestration.clone(),
|
config: config.agent_orchestration.clone(),
|
||||||
config_dir,
|
config_dir,
|
||||||
runtime_generation,
|
runtime_generation,
|
||||||
@ -283,6 +300,7 @@ impl GatewayState {
|
|||||||
reload,
|
reload,
|
||||||
admission,
|
admission,
|
||||||
agent_catalog,
|
agent_catalog,
|
||||||
|
agents_dir,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -299,9 +317,8 @@ impl GatewayState {
|
|||||||
/// Start the message processing loops
|
/// Start the message processing loops
|
||||||
pub async fn start_message_processing(&self) {
|
pub async fn start_message_processing(&self) {
|
||||||
// Recover durable Agent state for this runtime generation: interrupt
|
// Recover durable Agent state for this runtime generation: interrupt
|
||||||
// runs of older generations, expire stale inbox leases, converge
|
// runs of older generations, expire stale inbox leases and reconcile
|
||||||
// group counters and reconcile capacity rows. Runs never recover
|
// capacity rows. Runs never recover while the generation is still a candidate.
|
||||||
// while the generation is still a candidate.
|
|
||||||
if let Some(coordinator) = self.session_manager.agent_coordinator() {
|
if let Some(coordinator) = self.session_manager.agent_coordinator() {
|
||||||
match coordinator.recover_on_activation().await {
|
match coordinator.recover_on_activation().await {
|
||||||
Ok(report) => {
|
Ok(report) => {
|
||||||
@ -312,7 +329,6 @@ impl GatewayState {
|
|||||||
leases_expired = report.leases_expired,
|
leases_expired = report.leases_expired,
|
||||||
dead_lettered = report.dead_lettered,
|
dead_lettered = report.dead_lettered,
|
||||||
sessions_reconciled = report.sessions_reconciled,
|
sessions_reconciled = report.sessions_reconciled,
|
||||||
groups_converged = report.groups_converged,
|
|
||||||
"Agent state recovered on activation"
|
"Agent state recovered on activation"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -673,6 +689,12 @@ fn build_router(state: Arc<GatewayState>) -> Router {
|
|||||||
.route("/api/skills", routing::get(http::get_skills))
|
.route("/api/skills", routing::get(http::get_skills))
|
||||||
.route("/api/jobs", routing::get(http::get_jobs))
|
.route("/api/jobs", routing::get(http::get_jobs))
|
||||||
.route("/api/jobs/{id}/runs", routing::get(http::get_job_runs))
|
.route("/api/jobs/{id}/runs", routing::get(http::get_job_runs))
|
||||||
|
.route(
|
||||||
|
"/api/agents",
|
||||||
|
routing::get(http::list_agents).post(http::put_agent),
|
||||||
|
)
|
||||||
|
.route("/api/agents/options", routing::get(http::get_agent_options))
|
||||||
|
.route("/api/agents/{id}", routing::delete(http::delete_agent))
|
||||||
.route("/api/agent-runs", routing::get(http::get_agent_runs))
|
.route("/api/agent-runs", routing::get(http::get_agent_runs))
|
||||||
.route(
|
.route(
|
||||||
"/api/agent-runs/{id}",
|
"/api/agent-runs/{id}",
|
||||||
|
|||||||
@ -44,8 +44,6 @@ pub struct MessageAttachment {
|
|||||||
pub struct AgentRunView {
|
pub struct AgentRunView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub group_id: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub parent_run_id: Option<String>,
|
pub parent_run_id: Option<String>,
|
||||||
pub agent_id: String,
|
pub agent_id: String,
|
||||||
pub provider_name: String,
|
pub provider_name: String,
|
||||||
@ -80,8 +78,6 @@ pub struct AgentEventView {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub run_id: Option<String>,
|
pub run_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub group_id: Option<String>,
|
|
||||||
pub event_type: String,
|
pub event_type: String,
|
||||||
pub delivery: String,
|
pub delivery: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@ -114,7 +110,6 @@ impl AgentRunView {
|
|||||||
.map(|result| truncate(result, max_result_chars));
|
.map(|result| truncate(result, max_result_chars));
|
||||||
Self {
|
Self {
|
||||||
id: record.id.clone(),
|
id: record.id.clone(),
|
||||||
group_id: record.group_id.clone(),
|
|
||||||
parent_run_id: record.parent_run_id.clone(),
|
parent_run_id: record.parent_run_id.clone(),
|
||||||
agent_id: record.agent_id.clone(),
|
agent_id: record.agent_id.clone(),
|
||||||
provider_name: record.provider_name.clone(),
|
provider_name: record.provider_name.clone(),
|
||||||
@ -142,7 +137,6 @@ impl AgentEventView {
|
|||||||
Self {
|
Self {
|
||||||
id: record.id.clone(),
|
id: record.id.clone(),
|
||||||
run_id: record.run_id.clone(),
|
run_id: record.run_id.clone(),
|
||||||
group_id: record.group_id.clone(),
|
|
||||||
event_type: record.event_type.as_str().to_string(),
|
event_type: record.event_type.as_str().to_string(),
|
||||||
delivery: record.delivery.as_str().to_string(),
|
delivery: record.delivery.as_str().to_string(),
|
||||||
severity: record.severity.clone(),
|
severity: record.severity.clone(),
|
||||||
@ -614,7 +608,6 @@ mod tests {
|
|||||||
fn agent_run_and_event_views_serialize_without_sensitive_fields() {
|
fn agent_run_and_event_views_serialize_without_sensitive_fields() {
|
||||||
let run = crate::storage::agent_run::AgentRunRecord {
|
let run = crate::storage::agent_run::AgentRunRecord {
|
||||||
id: "run-1".to_string(),
|
id: "run-1".to_string(),
|
||||||
group_id: None,
|
|
||||||
root_session_id: "cli:test:d1".to_string(),
|
root_session_id: "cli:test:d1".to_string(),
|
||||||
root_turn_id: None,
|
root_turn_id: None,
|
||||||
parent_run_id: None,
|
parent_run_id: None,
|
||||||
@ -635,8 +628,6 @@ mod tests {
|
|||||||
budget_json: r#"{"remaining_runs":3}"#.to_string(),
|
budget_json: r#"{"remaining_runs":3}"#.to_string(),
|
||||||
signal_contract_json: Some("secret contract".to_string()),
|
signal_contract_json: Some("secret contract".to_string()),
|
||||||
signal_delivery: None,
|
signal_delivery: None,
|
||||||
completion_delivery: None,
|
|
||||||
failure_delivery: None,
|
|
||||||
status: crate::storage::agent_run::AgentRunStatus::Completed,
|
status: crate::storage::agent_run::AgentRunStatus::Completed,
|
||||||
result: Some("r".repeat(10_000)),
|
result: Some("r".repeat(10_000)),
|
||||||
error: None,
|
error: None,
|
||||||
|
|||||||
@ -141,7 +141,6 @@ impl SessionManager {
|
|||||||
task_id: Some(job_id.to_string()),
|
task_id: Some(job_id.to_string()),
|
||||||
from_run_id: None,
|
from_run_id: None,
|
||||||
from_agent_id: None,
|
from_agent_id: None,
|
||||||
group_id: None,
|
|
||||||
},
|
},
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
)
|
)
|
||||||
@ -176,7 +175,6 @@ mod tests {
|
|||||||
task_id: None,
|
task_id: None,
|
||||||
from_run_id: None,
|
from_run_id: None,
|
||||||
from_agent_id: None,
|
from_agent_id: None,
|
||||||
group_id: None,
|
|
||||||
};
|
};
|
||||||
let media = vec![MediaItem::new("/tmp/report.pdf", "file")];
|
let media = vec![MediaItem::new("/tmp/report.pdf", "file")];
|
||||||
|
|
||||||
|
|||||||
@ -752,17 +752,6 @@ fn steer_input_from_event(
|
|||||||
content,
|
content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AgentEventType::GroupCompletion => {
|
|
||||||
let group_id = event.group_id.clone().unwrap_or_default();
|
|
||||||
let content = format!("[后台 Agent 任务组结果] group={group_id}");
|
|
||||||
(
|
|
||||||
TurnInputSource::AgentGroupCompletion {
|
|
||||||
group_id: group_id.clone(),
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let input = TurnInput {
|
let input = TurnInput {
|
||||||
id: format!("steer:{}", event.id),
|
id: format!("steer:{}", event.id),
|
||||||
@ -1744,6 +1733,9 @@ pub struct SessionManagerServices {
|
|||||||
|
|
||||||
pub struct AgentCatalogPreparation {
|
pub struct AgentCatalogPreparation {
|
||||||
pub provider_profiles: HashMap<String, LLMProviderConfig>,
|
pub provider_profiles: HashMap<String, LLMProviderConfig>,
|
||||||
|
pub providers: HashMap<String, crate::config::ProviderConfig>,
|
||||||
|
pub models: HashMap<String, crate::config::ModelConfig>,
|
||||||
|
pub workspace_dir: std::path::PathBuf,
|
||||||
pub config: crate::config::AgentOrchestrationConfig,
|
pub config: crate::config::AgentOrchestrationConfig,
|
||||||
pub config_dir: std::path::PathBuf,
|
pub config_dir: std::path::PathBuf,
|
||||||
pub runtime_generation: u64,
|
pub runtime_generation: u64,
|
||||||
@ -1967,6 +1959,9 @@ impl SessionManager {
|
|||||||
&catalog_preparation.config,
|
&catalog_preparation.config,
|
||||||
&catalog_preparation.config_dir,
|
&catalog_preparation.config_dir,
|
||||||
&catalog_preparation.provider_profiles,
|
&catalog_preparation.provider_profiles,
|
||||||
|
&catalog_preparation.providers,
|
||||||
|
&catalog_preparation.models,
|
||||||
|
&catalog_preparation.workspace_dir,
|
||||||
&tools,
|
&tools,
|
||||||
&skills_loader,
|
&skills_loader,
|
||||||
catalog_preparation.runtime_generation,
|
catalog_preparation.runtime_generation,
|
||||||
@ -2017,25 +2012,6 @@ impl SessionManager {
|
|||||||
tools.register(delegate_tool);
|
tools.register(delegate_tool);
|
||||||
tools.register(crate::tools::ReloadConfigTool::new(reload.clone()));
|
tools.register(crate::tools::ReloadConfigTool::new(reload.clone()));
|
||||||
|
|
||||||
// Start periodic background task cleanup (every hour, TTL 24h)
|
|
||||||
let cleanup_storage = storage.clone();
|
|
||||||
task_supervisor.spawn("background-task-cleanup", async move {
|
|
||||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
|
|
||||||
interval.tick().await; // skip immediate first tick
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
match cleanup_storage.cleanup_old_tasks(86_400_000).await {
|
|
||||||
Ok(count) if count > 0 => {
|
|
||||||
tracing::info!(count, "Cleaned up old background tasks");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "Failed to clean up old background tasks");
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: Arc::new(Mutex::new(SessionManagerInner {
|
inner: Arc::new(Mutex::new(SessionManagerInner {
|
||||||
sessions: HashMap::new(),
|
sessions: HashMap::new(),
|
||||||
@ -3156,7 +3132,6 @@ impl SessionManager {
|
|||||||
task_id: task_id.map(|s| s.to_string()),
|
task_id: task_id.map(|s| s.to_string()),
|
||||||
from_run_id: None,
|
from_run_id: None,
|
||||||
from_agent_id: None,
|
from_agent_id: None,
|
||||||
group_id: None,
|
|
||||||
};
|
};
|
||||||
let msg = ChatMessage::assistant_with_source(content, source);
|
let msg = ChatMessage::assistant_with_source(content, source);
|
||||||
append_persisted_messages(&session, vec![msg])
|
append_persisted_messages(&session, vec![msg])
|
||||||
@ -3286,7 +3261,6 @@ impl SessionManager {
|
|||||||
task_id: None,
|
task_id: None,
|
||||||
from_run_id: None,
|
from_run_id: None,
|
||||||
from_agent_id: None,
|
from_agent_id: None,
|
||||||
group_id: None,
|
|
||||||
};
|
};
|
||||||
let mut message =
|
let mut message =
|
||||||
guard.create_user_message_with_source(content, media_refs, source);
|
guard.create_user_message_with_source(content, media_refs, source);
|
||||||
@ -3570,11 +3544,24 @@ fn spawn_agent_worker(
|
|||||||
};
|
};
|
||||||
let mut consecutive_user_turns = 0usize;
|
let mut consecutive_user_turns = 0usize;
|
||||||
'tasks: loop {
|
'tasks: loop {
|
||||||
// Fairness: a due inbox event must be processed before the
|
// Drain user tasks first so we can tell whether the session
|
||||||
// next user Turn once the user burst budget is exhausted or
|
// is idle. Admission sequence numbers are allocated while
|
||||||
// the oldest pending event has waited too long. The next
|
// holding the Session lock; draining everything currently
|
||||||
// pending due time also arms a timer so a released event is
|
// visible before selecting the smallest sequence keeps a
|
||||||
// re-claimed after its retry backoff without needing a wake.
|
// terminal fallback from overtaking an earlier `/queue` task.
|
||||||
|
while let Ok(task) = task_rx.try_recv() {
|
||||||
|
local_tasks.push_back(task);
|
||||||
|
}
|
||||||
|
let has_user_backlog = !local_tasks.is_empty();
|
||||||
|
|
||||||
|
// Fairness: when user work is queued it runs first, but a due
|
||||||
|
// inbox event must still preempt it once the user burst
|
||||||
|
// budget is exhausted or the oldest pending event has waited
|
||||||
|
// too long. When idle (no user backlog) a due event is
|
||||||
|
// claimed immediately, so a background result is delivered as
|
||||||
|
// soon as its run finishes. The next pending due time also
|
||||||
|
// arms a timer so a released event is re-claimed after its
|
||||||
|
// retry backoff without needing a wake.
|
||||||
let mut next_due_at = None;
|
let mut next_due_at = None;
|
||||||
let storage = {
|
let storage = {
|
||||||
let guard = session.lock().await;
|
let guard = session.lock().await;
|
||||||
@ -3595,8 +3582,11 @@ fn spawn_agent_worker(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
let force = consecutive_user_turns >= inbox_burst
|
let age_exceeded = oldest_due
|
||||||
|| oldest_due.is_some_and(|created_at| now - created_at >= inbox_wait_ms);
|
.is_some_and(|created_at| now - created_at >= inbox_wait_ms);
|
||||||
|
let burst_exceeded = consecutive_user_turns >= inbox_burst;
|
||||||
|
let force = oldest_due.is_some()
|
||||||
|
&& (!has_user_backlog || burst_exceeded || age_exceeded);
|
||||||
if force
|
if force
|
||||||
&& let Ok(Some(lease)) = crate::storage::Storage::claim_inbox_batch(
|
&& let Ok(Some(lease)) = crate::storage::Storage::claim_inbox_batch(
|
||||||
&storage,
|
&storage,
|
||||||
@ -3628,13 +3618,6 @@ fn spawn_agent_worker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admission sequence numbers are allocated while holding the
|
|
||||||
// Session lock. Drain everything currently visible on the
|
|
||||||
// channel before selecting the smallest sequence, so a
|
|
||||||
// terminal fallback cannot overtake an earlier `/queue` task.
|
|
||||||
while let Ok(task) = task_rx.try_recv() {
|
|
||||||
local_tasks.push_back(task);
|
|
||||||
}
|
|
||||||
let task = if let Some(task) = pop_lowest_sequence(&mut local_tasks) {
|
let task = if let Some(task) = pop_lowest_sequence(&mut local_tasks) {
|
||||||
task
|
task
|
||||||
} else {
|
} else {
|
||||||
@ -3688,7 +3671,6 @@ fn spawn_agent_worker(
|
|||||||
task_id: None,
|
task_id: None,
|
||||||
from_run_id: None,
|
from_run_id: None,
|
||||||
from_agent_id: None,
|
from_agent_id: None,
|
||||||
group_id: None,
|
|
||||||
};
|
};
|
||||||
let mut message =
|
let mut message =
|
||||||
guard.create_user_message_with_source(&task.content, media_refs, source);
|
guard.create_user_message_with_source(&task.content, media_refs, source);
|
||||||
|
|||||||
@ -8,7 +8,6 @@ use crate::bus::{ClientVisibility, TurnOrigin};
|
|||||||
pub enum AgentEventType {
|
pub enum AgentEventType {
|
||||||
Signal,
|
Signal,
|
||||||
Completion,
|
Completion,
|
||||||
GroupCompletion,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentEventType {
|
impl AgentEventType {
|
||||||
@ -16,7 +15,6 @@ impl AgentEventType {
|
|||||||
match self {
|
match self {
|
||||||
Self::Signal => "signal",
|
Self::Signal => "signal",
|
||||||
Self::Completion => "completion",
|
Self::Completion => "completion",
|
||||||
Self::GroupCompletion => "group_completion",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,7 +22,6 @@ impl AgentEventType {
|
|||||||
match value {
|
match value {
|
||||||
"signal" => Ok(Self::Signal),
|
"signal" => Ok(Self::Signal),
|
||||||
"completion" => Ok(Self::Completion),
|
"completion" => Ok(Self::Completion),
|
||||||
"group_completion" => Ok(Self::GroupCompletion),
|
|
||||||
other => Err(StorageError::Migration(format!(
|
other => Err(StorageError::Migration(format!(
|
||||||
"corrupt agent event type '{other}'"
|
"corrupt agent event type '{other}'"
|
||||||
))),
|
))),
|
||||||
@ -98,10 +95,7 @@ impl AgentEventStatus {
|
|||||||
pub struct AgentInboxEventRecord {
|
pub struct AgentInboxEventRecord {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub root_session_id: String,
|
pub root_session_id: String,
|
||||||
pub scope_kind: String,
|
|
||||||
pub scope_id: String,
|
|
||||||
pub run_id: Option<String>,
|
pub run_id: Option<String>,
|
||||||
pub group_id: Option<String>,
|
|
||||||
pub event_type: AgentEventType,
|
pub event_type: AgentEventType,
|
||||||
pub event_key: String,
|
pub event_key: String,
|
||||||
pub delivery: AgentEventDelivery,
|
pub delivery: AgentEventDelivery,
|
||||||
@ -128,10 +122,7 @@ pub struct AgentInboxEventRecord {
|
|||||||
pub struct NewInboxEvent {
|
pub struct NewInboxEvent {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub root_session_id: String,
|
pub root_session_id: String,
|
||||||
pub scope_kind: String,
|
|
||||||
pub scope_id: String,
|
|
||||||
pub run_id: Option<String>,
|
pub run_id: Option<String>,
|
||||||
pub group_id: Option<String>,
|
|
||||||
pub event_type: AgentEventType,
|
pub event_type: AgentEventType,
|
||||||
pub event_key: String,
|
pub event_key: String,
|
||||||
pub delivery: AgentEventDelivery,
|
pub delivery: AgentEventDelivery,
|
||||||
@ -163,13 +154,12 @@ pub struct RecoveryReport {
|
|||||||
pub leases_expired: usize,
|
pub leases_expired: usize,
|
||||||
pub dead_lettered: usize,
|
pub dead_lettered: usize,
|
||||||
pub sessions_reconciled: usize,
|
pub sessions_reconciled: usize,
|
||||||
pub groups_converged: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_COLUMNS: &str = "id, root_session_id, scope_kind, scope_id, run_id, group_id, \
|
const EVENT_COLUMNS: &str = "id, root_session_id, run_id, event_type, event_key, delivery, \
|
||||||
event_type, event_key, delivery, requires_continuation, severity, payload_json, status, \
|
requires_continuation, severity, payload_json, status, attempt_count, lease_token, \
|
||||||
attempt_count, lease_token, lease_until, next_attempt_at, admitted_turn_id, last_error, \
|
lease_until, next_attempt_at, admitted_turn_id, last_error, revision, created_at, \
|
||||||
revision, created_at, consumed_at, superseded_at, dead_lettered_at, fallback_notified_at, \
|
consumed_at, superseded_at, dead_lettered_at, fallback_notified_at, \
|
||||||
fallback_suppressed_reason";
|
fallback_suppressed_reason";
|
||||||
|
|
||||||
fn event_record_from_row(
|
fn event_record_from_row(
|
||||||
@ -178,10 +168,7 @@ fn event_record_from_row(
|
|||||||
Ok(AgentInboxEventRecord {
|
Ok(AgentInboxEventRecord {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
root_session_id: row.get("root_session_id"),
|
root_session_id: row.get("root_session_id"),
|
||||||
scope_kind: row.get("scope_kind"),
|
|
||||||
scope_id: row.get("scope_id"),
|
|
||||||
run_id: row.get("run_id"),
|
run_id: row.get("run_id"),
|
||||||
group_id: row.get("group_id"),
|
|
||||||
event_type: AgentEventType::parse(row.get::<&str, _>("event_type"))?,
|
event_type: AgentEventType::parse(row.get::<&str, _>("event_type"))?,
|
||||||
event_key: row.get("event_key"),
|
event_key: row.get("event_key"),
|
||||||
delivery: AgentEventDelivery::parse(row.get::<&str, _>("delivery"))?,
|
delivery: AgentEventDelivery::parse(row.get::<&str, _>("delivery"))?,
|
||||||
@ -295,10 +282,9 @@ impl super::Storage {
|
|||||||
ensure_agent_session_state_tx(&mut tx, &event.root_session_id, now).await?;
|
ensure_agent_session_state_tx(&mut tx, &event.root_session_id, now).await?;
|
||||||
if let Some(existing_id) = sqlx::query_scalar::<_, String>(
|
if let Some(existing_id) = sqlx::query_scalar::<_, String>(
|
||||||
"SELECT id FROM agent_inbox_events \
|
"SELECT id FROM agent_inbox_events \
|
||||||
WHERE scope_kind = ? AND scope_id = ? AND event_type = ? AND event_key = ?",
|
WHERE run_id = ? AND event_type = ? AND event_key = ?",
|
||||||
)
|
)
|
||||||
.bind(&event.scope_kind)
|
.bind(&event.run_id)
|
||||||
.bind(&event.scope_id)
|
|
||||||
.bind(event.event_type.as_str())
|
.bind(event.event_type.as_str())
|
||||||
.bind(&event.event_key)
|
.bind(&event.event_key)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
@ -864,8 +850,7 @@ impl super::Storage {
|
|||||||
/// failure completion event (the reservation is converted).
|
/// failure completion event (the reservation is converted).
|
||||||
/// 2. Expired leases return to `pending` with a backoff; attempts beyond
|
/// 2. Expired leases return to `pending` with a backoff; attempts beyond
|
||||||
/// the maximum become `dead_letter`.
|
/// the maximum become `dead_letter`.
|
||||||
/// 3. Group counters are recomputed from their runs and finalized.
|
/// 3. Per-session capacity counters are reconciled with the rows.
|
||||||
/// 4. Per-session capacity counters are reconciled with the rows.
|
|
||||||
pub async fn recover_agent_state(
|
pub async fn recover_agent_state(
|
||||||
&self,
|
&self,
|
||||||
active_generation: i64,
|
active_generation: i64,
|
||||||
@ -877,14 +862,14 @@ impl super::Storage {
|
|||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
// 1. Interrupt runs of previous generations.
|
// 1. Interrupt runs of previous generations.
|
||||||
let interrupted: Vec<(String, String, i64)> = sqlx::query_as(
|
let interrupted: Vec<(String, String, i64, String, String)> = sqlx::query_as(
|
||||||
"SELECT id, root_session_id, completion_slot_reserved FROM agent_runs \
|
"SELECT id, root_session_id, completion_slot_reserved, agent_id, task FROM agent_runs \
|
||||||
WHERE runtime_generation != ? AND status IN ('queued', 'running', 'waiting_children')",
|
WHERE runtime_generation != ? AND status IN ('queued', 'running', 'waiting_children')",
|
||||||
)
|
)
|
||||||
.bind(active_generation)
|
.bind(active_generation)
|
||||||
.fetch_all(&mut *tx)
|
.fetch_all(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
for (run_id, session_id, reserved) in &interrupted {
|
for (run_id, session_id, reserved, agent_id, task) in &interrupted {
|
||||||
let updated = sqlx::query(
|
let updated = sqlx::query(
|
||||||
"UPDATE agent_runs SET status = 'interrupted', error = ?, finished_at = ?, updated_at = ? \
|
"UPDATE agent_runs SET status = 'interrupted', error = ?, finished_at = ?, updated_at = ? \
|
||||||
WHERE id = ? AND status IN ('queued', 'running', 'waiting_children')",
|
WHERE id = ? AND status IN ('queued', 'running', 'waiting_children')",
|
||||||
@ -906,20 +891,21 @@ impl super::Storage {
|
|||||||
let event = NewInboxEvent {
|
let event = NewInboxEvent {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
root_session_id: session_id.clone(),
|
root_session_id: session_id.clone(),
|
||||||
scope_kind: "run".to_string(),
|
|
||||||
scope_id: run_id.clone(),
|
|
||||||
run_id: Some(run_id.clone()),
|
run_id: Some(run_id.clone()),
|
||||||
group_id: None,
|
|
||||||
event_type: AgentEventType::Completion,
|
event_type: AgentEventType::Completion,
|
||||||
event_key: format!("interrupted:{run_id}"),
|
event_key: format!("interrupted:{run_id}"),
|
||||||
delivery: AgentEventDelivery::Queue,
|
delivery: AgentEventDelivery::Queue,
|
||||||
requires_continuation: true,
|
requires_continuation: true,
|
||||||
severity: Some("error".to_string()),
|
severity: Some("error".to_string()),
|
||||||
payload_json: serde_json::json!({
|
payload_json: completion_payload(
|
||||||
"status": "interrupted",
|
run_id,
|
||||||
"error": "interrupted by runtime generation handover",
|
agent_id,
|
||||||
})
|
task,
|
||||||
.to_string(),
|
None,
|
||||||
|
"interrupted",
|
||||||
|
Some("interrupted by runtime generation handover"),
|
||||||
|
&[],
|
||||||
|
),
|
||||||
};
|
};
|
||||||
insert_event_tx(&mut tx, &event, revision, now).await?;
|
insert_event_tx(&mut tx, &event, revision, now).await?;
|
||||||
report.completion_events_generated += 1;
|
report.completion_events_generated += 1;
|
||||||
@ -978,61 +964,10 @@ impl super::Storage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Converge group counters from their runs.
|
// 3. Reconcile per-session capacity counters.
|
||||||
let group_ids: Vec<String> = sqlx::query_scalar(
|
|
||||||
"SELECT id FROM agent_run_groups WHERE status IN ('queued', 'running')",
|
|
||||||
)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
for group_id in &group_ids {
|
|
||||||
let (terminal, abnormal): (i64, i64) = sqlx::query_as(
|
|
||||||
"SELECT \
|
|
||||||
COUNT(*) FILTER (WHERE status IN ('completed','failed','timed_out','cancelled','interrupted')), \
|
|
||||||
COUNT(*) FILTER (WHERE status IN ('failed','timed_out','cancelled','interrupted')) \
|
|
||||||
FROM agent_runs WHERE group_id = ?",
|
|
||||||
)
|
|
||||||
.bind(group_id)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
let expected: i64 =
|
|
||||||
sqlx::query_scalar("SELECT expected_runs FROM agent_run_groups WHERE id = ?")
|
|
||||||
.bind(group_id)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
let status = if terminal >= expected && terminal > 0 {
|
|
||||||
if abnormal == 0 {
|
|
||||||
"completed"
|
|
||||||
} else if abnormal < expected {
|
|
||||||
"partial"
|
|
||||||
} else {
|
|
||||||
"failed"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"running"
|
|
||||||
};
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE agent_run_groups SET terminal_runs = ?, abnormal_runs = ?, status = ?, \
|
|
||||||
finished_at = CASE WHEN status IN ('completed','partial','failed','timed_out','cancelled','interrupted') THEN ? ELSE NULL END, \
|
|
||||||
updated_at = ? WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(terminal)
|
|
||||||
.bind(abnormal)
|
|
||||||
.bind(status)
|
|
||||||
.bind(now)
|
|
||||||
.bind(now)
|
|
||||||
.bind(group_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
if status != "running" {
|
|
||||||
report.groups_converged += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Reconcile per-session capacity counters.
|
|
||||||
let sessions: Vec<(String, i64, i64)> = sqlx::query_as(
|
let sessions: Vec<(String, i64, i64)> = sqlx::query_as(
|
||||||
"SELECT root_session_id, \
|
"SELECT root_session_id, \
|
||||||
(SELECT COUNT(*) FROM agent_runs r WHERE r.root_session_id = s.root_session_id AND r.completion_slot_reserved = 1) \
|
(SELECT COUNT(*) FROM agent_runs r WHERE r.root_session_id = s.root_session_id AND r.completion_slot_reserved = 1), \
|
||||||
+ (SELECT COUNT(*) FROM agent_run_groups g WHERE g.root_session_id = s.root_session_id AND g.completion_slot_reserved = 1), \
|
|
||||||
(SELECT COUNT(*) FROM agent_inbox_events e WHERE e.root_session_id = s.root_session_id AND e.status IN ('pending','leased','admitted')) \
|
(SELECT COUNT(*) FROM agent_inbox_events e WHERE e.root_session_id = s.root_session_id AND e.status IN ('pending','leased','admitted')) \
|
||||||
FROM agent_session_state s",
|
FROM agent_session_state s",
|
||||||
)
|
)
|
||||||
@ -1116,17 +1051,14 @@ pub(crate) async fn insert_event_tx(
|
|||||||
now: i64,
|
now: i64,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO agent_inbox_events (id, root_session_id, scope_kind, scope_id, run_id, \
|
"INSERT INTO agent_inbox_events (id, root_session_id, run_id, event_type, event_key, \
|
||||||
group_id, event_type, event_key, delivery, requires_continuation, severity, \
|
delivery, requires_continuation, severity, payload_json, status, attempt_count, \
|
||||||
payload_json, status, attempt_count, revision, next_attempt_at, created_at, updated_at) \
|
revision, next_attempt_at, created_at, updated_at) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&event.id)
|
.bind(&event.id)
|
||||||
.bind(&event.root_session_id)
|
.bind(&event.root_session_id)
|
||||||
.bind(&event.scope_kind)
|
|
||||||
.bind(&event.scope_id)
|
|
||||||
.bind(&event.run_id)
|
.bind(&event.run_id)
|
||||||
.bind(&event.group_id)
|
|
||||||
.bind(event.event_type.as_str())
|
.bind(event.event_type.as_str())
|
||||||
.bind(&event.event_key)
|
.bind(&event.event_key)
|
||||||
.bind(event.delivery.as_str())
|
.bind(event.delivery.as_str())
|
||||||
@ -1144,10 +1076,14 @@ pub(crate) async fn insert_event_tx(
|
|||||||
|
|
||||||
/// Helper used by `commit_agent_terminal` to materialize a completion event
|
/// Helper used by `commit_agent_terminal` to materialize a completion event
|
||||||
/// for a background run that reserved a slot.
|
/// for a background run that reserved a slot.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) async fn insert_completion_event_tx(
|
pub(crate) async fn insert_completion_event_tx(
|
||||||
tx: &mut sqlx::SqliteConnection,
|
tx: &mut sqlx::SqliteConnection,
|
||||||
run_id: &str,
|
run_id: &str,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
|
agent_id: &str,
|
||||||
|
task: &str,
|
||||||
|
result: Option<&str>,
|
||||||
status: &str,
|
status: &str,
|
||||||
error: Option<&str>,
|
error: Option<&str>,
|
||||||
signal_ids: &[String],
|
signal_ids: &[String],
|
||||||
@ -1161,10 +1097,7 @@ pub(crate) async fn insert_completion_event_tx(
|
|||||||
let event = NewInboxEvent {
|
let event = NewInboxEvent {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
root_session_id: session_id.to_string(),
|
root_session_id: session_id.to_string(),
|
||||||
scope_kind: "run".to_string(),
|
|
||||||
scope_id: run_id.to_string(),
|
|
||||||
run_id: Some(run_id.to_string()),
|
run_id: Some(run_id.to_string()),
|
||||||
group_id: None,
|
|
||||||
event_type: AgentEventType::Completion,
|
event_type: AgentEventType::Completion,
|
||||||
event_key: format!("completion:{run_id}"),
|
event_key: format!("completion:{run_id}"),
|
||||||
delivery: AgentEventDelivery::Queue,
|
delivery: AgentEventDelivery::Queue,
|
||||||
@ -1174,28 +1107,115 @@ pub(crate) async fn insert_completion_event_tx(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
payload_json: serde_json::json!({
|
payload_json: completion_payload(run_id, agent_id, task, result, status, error, signal_ids),
|
||||||
"status": status,
|
|
||||||
"error": error,
|
|
||||||
"signal_ids": signal_ids,
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
};
|
};
|
||||||
insert_event_tx(tx, &event, revision, now).await
|
insert_event_tx(tx, &event, revision, now).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default trigger content for a continuation Turn.
|
/// Bounded payload for a run completion event. Carries enough task/result
|
||||||
|
/// context for the main Agent to report which run finished and what it
|
||||||
|
/// produced, while the full result stays in `agent_runs.result`.
|
||||||
|
pub(crate) fn completion_payload(
|
||||||
|
run_id: &str,
|
||||||
|
agent_id: &str,
|
||||||
|
task: &str,
|
||||||
|
result: Option<&str>,
|
||||||
|
status: &str,
|
||||||
|
error: Option<&str>,
|
||||||
|
signal_ids: &[String],
|
||||||
|
) -> String {
|
||||||
|
serde_json::json!({
|
||||||
|
"run_id": run_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"task": truncate_utf8(task, 500),
|
||||||
|
"result": result.map(|r| truncate_utf8(r, 2_000)),
|
||||||
|
"status": status,
|
||||||
|
"error": error,
|
||||||
|
"signal_ids": signal_ids,
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate a string to at most `max` UTF-8 characters.
|
||||||
|
pub(crate) fn truncate_utf8(value: &str, max: usize) -> String {
|
||||||
|
if value.chars().count() <= max {
|
||||||
|
value.to_string()
|
||||||
|
} else {
|
||||||
|
let mut truncated: String = value.chars().take(max).collect();
|
||||||
|
truncated.push('…');
|
||||||
|
truncated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default trigger content for a continuation Turn. Events are rendered as
|
||||||
|
/// readable summaries (task, agent, result) rather than raw JSON so the main
|
||||||
|
/// Agent knows exactly which run finished and what it produced.
|
||||||
pub fn build_continuation_trigger(
|
pub fn build_continuation_trigger(
|
||||||
events: &[AgentInboxEventRecord],
|
events: &[AgentInboxEventRecord],
|
||||||
now: i64,
|
now: i64,
|
||||||
) -> crate::bus::ChatMessage {
|
) -> crate::bus::ChatMessage {
|
||||||
let mut content = String::from(
|
let mut content = String::from(
|
||||||
"后台 Agent 任务已经完成。请结合以下结果继续当前对话,向用户呈现最相关的部分;\
|
"后台 Agent 任务已经完成,以下是完成结果。请结合这些结果继续当前对话,向用户呈现最相关的部分;\
|
||||||
不要重复执行已经完成的工作。",
|
不要重复执行已经完成的工作,也不要复述原始任务描述。",
|
||||||
);
|
);
|
||||||
for event in events {
|
for event in events {
|
||||||
content.push_str("\n\n- ");
|
let payload: serde_json::Value =
|
||||||
content.push_str(&event.payload_json);
|
serde_json::from_str(&event.payload_json).unwrap_or(serde_json::Value::Null);
|
||||||
|
let get = |key: &str| {
|
||||||
|
payload
|
||||||
|
.get(key)
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
match event.event_type {
|
||||||
|
AgentEventType::Completion => {
|
||||||
|
let status = get("status");
|
||||||
|
let agent_id = get("agent_id");
|
||||||
|
let run_id = get("run_id");
|
||||||
|
let task = get("task");
|
||||||
|
let result = get("result");
|
||||||
|
let error = get("error");
|
||||||
|
content.push_str("\n\n## 后台任务完成");
|
||||||
|
if !agent_id.is_empty() {
|
||||||
|
content.push_str(&format!("(Agent:{agent_id})"));
|
||||||
|
}
|
||||||
|
content.push_str(&format!(",状态:{status}"));
|
||||||
|
if !run_id.is_empty() {
|
||||||
|
content.push_str(&format!(",Run ID:{run_id}"));
|
||||||
|
}
|
||||||
|
content.push('\n');
|
||||||
|
if !task.is_empty() {
|
||||||
|
content.push_str(&format!("- 任务:{task}\n"));
|
||||||
|
}
|
||||||
|
if !result.is_empty() {
|
||||||
|
content.push_str(&format!("- 结果:{result}\n"));
|
||||||
|
}
|
||||||
|
if !error.is_empty() {
|
||||||
|
content.push_str(&format!("- 错误:{error}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AgentEventType::Signal => {
|
||||||
|
let severity = get("severity");
|
||||||
|
let agent_id = get("agent_id");
|
||||||
|
let run_id = get("run_id");
|
||||||
|
let summary = get("summary");
|
||||||
|
content.push_str("\n\n## 后台信号");
|
||||||
|
if !severity.is_empty() {
|
||||||
|
content.push_str(&format!("(严重级别:{severity})"));
|
||||||
|
}
|
||||||
|
if !agent_id.is_empty() {
|
||||||
|
content.push_str(&format!(",Agent:{agent_id}"));
|
||||||
|
}
|
||||||
|
if !run_id.is_empty() {
|
||||||
|
content.push_str(&format!(",Run ID:{run_id}"));
|
||||||
|
}
|
||||||
|
content.push('\n');
|
||||||
|
if !summary.is_empty() {
|
||||||
|
content.push_str(&format!("- 摘要:{summary}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let mut message = crate::bus::ChatMessage::user(content);
|
let mut message = crate::bus::ChatMessage::user(content);
|
||||||
message.client_visibility = ClientVisibility::Hidden;
|
message.client_visibility = ClientVisibility::Hidden;
|
||||||
@ -1234,10 +1254,7 @@ mod tests {
|
|||||||
NewInboxEvent {
|
NewInboxEvent {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
root_session_id: session.to_string(),
|
root_session_id: session.to_string(),
|
||||||
scope_kind: "run".to_string(),
|
|
||||||
scope_id: run_id.to_string(),
|
|
||||||
run_id: Some(run_id.to_string()),
|
run_id: Some(run_id.to_string()),
|
||||||
group_id: None,
|
|
||||||
event_type: AgentEventType::Completion,
|
event_type: AgentEventType::Completion,
|
||||||
event_key: format!("completion:{run_id}"),
|
event_key: format!("completion:{run_id}"),
|
||||||
delivery: AgentEventDelivery::Queue,
|
delivery: AgentEventDelivery::Queue,
|
||||||
@ -1251,7 +1268,6 @@ mod tests {
|
|||||||
use crate::storage::agent_run::{AcceptAgentRequest, AgentRunMode, NewAgentRun};
|
use crate::storage::agent_run::{AcceptAgentRequest, AgentRunMode, NewAgentRun};
|
||||||
storage
|
storage
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
.accept_agent_runs(AcceptAgentRequest {
|
||||||
group: None,
|
|
||||||
runs: vec![NewAgentRun {
|
runs: vec![NewAgentRun {
|
||||||
id: run_id.to_string(),
|
id: run_id.to_string(),
|
||||||
root_session_id: session.to_string(),
|
root_session_id: session.to_string(),
|
||||||
@ -1697,7 +1713,6 @@ mod tests {
|
|||||||
};
|
};
|
||||||
storage
|
storage
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
.accept_agent_runs(AcceptAgentRequest {
|
||||||
group: None,
|
|
||||||
runs: vec![run],
|
runs: vec![run],
|
||||||
now: 10,
|
now: 10,
|
||||||
})
|
})
|
||||||
@ -1760,10 +1775,7 @@ mod tests {
|
|||||||
NewInboxEvent {
|
NewInboxEvent {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
root_session_id: session.to_string(),
|
root_session_id: session.to_string(),
|
||||||
scope_kind: "run".to_string(),
|
|
||||||
scope_id: run_id.to_string(),
|
|
||||||
run_id: Some(run_id.to_string()),
|
run_id: Some(run_id.to_string()),
|
||||||
group_id: None,
|
|
||||||
event_type: AgentEventType::Signal,
|
event_type: AgentEventType::Signal,
|
||||||
event_key: format!("signal:{dedupe_key}:{window}"),
|
event_key: format!("signal:{dedupe_key}:{window}"),
|
||||||
delivery: AgentEventDelivery::Steer,
|
delivery: AgentEventDelivery::Steer,
|
||||||
@ -2034,4 +2046,54 @@ mod tests {
|
|||||||
assert_eq!(record.status, AgentEventStatus::Pending);
|
assert_eq!(record.status, AgentEventStatus::Pending);
|
||||||
assert_eq!(record.next_attempt_at, Some(120));
|
assert_eq!(record.next_attempt_at, Some(120));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_trigger_renders_task_agent_and_result() {
|
||||||
|
let event = AgentInboxEventRecord {
|
||||||
|
id: "evt-1".to_string(),
|
||||||
|
root_session_id: "cli:test:d1".to_string(),
|
||||||
|
run_id: Some("run-1".to_string()),
|
||||||
|
event_type: AgentEventType::Completion,
|
||||||
|
event_key: "completion:run-1".to_string(),
|
||||||
|
delivery: AgentEventDelivery::Queue,
|
||||||
|
requires_continuation: true,
|
||||||
|
severity: None,
|
||||||
|
payload_json: completion_payload(
|
||||||
|
"run-1",
|
||||||
|
"researcher",
|
||||||
|
"在 bash 中执行 sleep 5 并返回随机数",
|
||||||
|
Some("随机数 19471"),
|
||||||
|
"completed",
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
status: AgentEventStatus::Pending,
|
||||||
|
attempt_count: 0,
|
||||||
|
lease_token: None,
|
||||||
|
lease_until: None,
|
||||||
|
next_attempt_at: None,
|
||||||
|
admitted_turn_id: None,
|
||||||
|
last_error: None,
|
||||||
|
revision: 1,
|
||||||
|
created_at: 1,
|
||||||
|
consumed_at: None,
|
||||||
|
superseded_at: None,
|
||||||
|
dead_lettered_at: None,
|
||||||
|
fallback_notified_at: None,
|
||||||
|
fallback_suppressed_reason: None,
|
||||||
|
};
|
||||||
|
let trigger = build_continuation_trigger(&[event], 1);
|
||||||
|
assert_eq!(
|
||||||
|
trigger.client_visibility,
|
||||||
|
crate::bus::ClientVisibility::Hidden
|
||||||
|
);
|
||||||
|
assert!(trigger.content.contains("researcher"));
|
||||||
|
assert!(trigger.content.contains("run-1"));
|
||||||
|
assert!(trigger.content.contains("在 bash 中执行 sleep 5"));
|
||||||
|
assert!(trigger.content.contains("随机数 19471"));
|
||||||
|
assert!(
|
||||||
|
!trigger.content.contains("\"status\""),
|
||||||
|
"raw JSON must not be injected"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,48 +2,14 @@ use sqlx::{Row, SqliteConnection};
|
|||||||
|
|
||||||
use super::StorageError;
|
use super::StorageError;
|
||||||
|
|
||||||
/// Frozen schema v6 DDL for the Agent orchestration tables. Executed inside
|
/// Frozen DDL for the Agent orchestration tables. Executed inside the single
|
||||||
/// the single migration transaction so table creation, column additions and
|
/// migration transaction so table creation, column additions and `user_version`
|
||||||
/// `user_version` advance atomically. The inbox tables belong to Phase 3
|
/// advance atomically. The inbox table belongs to Phase 3 behavior but its
|
||||||
/// behavior but their shape is frozen together with the run tables.
|
/// shape is frozen together with the run tables.
|
||||||
pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
|
pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
|
||||||
r#"
|
|
||||||
CREATE TABLE IF NOT EXISTS agent_run_groups (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
root_session_id TEXT NOT NULL,
|
|
||||||
caller_run_id TEXT,
|
|
||||||
caller_scope_id TEXT NOT NULL,
|
|
||||||
idempotency_key TEXT,
|
|
||||||
mode TEXT NOT NULL,
|
|
||||||
completion_policy TEXT NOT NULL,
|
|
||||||
expected_runs INTEGER NOT NULL,
|
|
||||||
terminal_runs INTEGER NOT NULL DEFAULT 0,
|
|
||||||
abnormal_runs INTEGER NOT NULL DEFAULT 0,
|
|
||||||
completion_slot_reserved INTEGER NOT NULL DEFAULT 0,
|
|
||||||
completion_delivery TEXT,
|
|
||||||
failure_delivery TEXT,
|
|
||||||
deadline_at INTEGER NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
runtime_generation INTEGER NOT NULL,
|
|
||||||
revision INTEGER NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
finished_at INTEGER,
|
|
||||||
CHECK (mode IN ('foreground', 'background')),
|
|
||||||
CHECK (completion_policy IN ('all', 'each')),
|
|
||||||
CHECK (status IN ('queued', 'running', 'completed', 'partial', 'failed',
|
|
||||||
'timed_out', 'cancelled', 'interrupted')),
|
|
||||||
CHECK (expected_runs > 0),
|
|
||||||
CHECK (terminal_runs >= 0 AND terminal_runs <= expected_runs),
|
|
||||||
CHECK (completion_slot_reserved IN (0, 1))
|
|
||||||
)
|
|
||||||
"#,
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_agent_groups_session_created ON agent_run_groups(root_session_id, created_at DESC)",
|
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_groups_idempotency ON agent_run_groups(root_session_id, caller_scope_id, idempotency_key) WHERE idempotency_key IS NOT NULL",
|
|
||||||
r#"
|
r#"
|
||||||
CREATE TABLE IF NOT EXISTS agent_runs (
|
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
group_id TEXT,
|
|
||||||
root_session_id TEXT NOT NULL,
|
root_session_id TEXT NOT NULL,
|
||||||
root_turn_id TEXT,
|
root_turn_id TEXT,
|
||||||
parent_run_id TEXT,
|
parent_run_id TEXT,
|
||||||
@ -64,8 +30,6 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
|
|||||||
budget_json TEXT NOT NULL,
|
budget_json TEXT NOT NULL,
|
||||||
signal_contract_json TEXT,
|
signal_contract_json TEXT,
|
||||||
signal_delivery TEXT,
|
signal_delivery TEXT,
|
||||||
completion_delivery TEXT,
|
|
||||||
failure_delivery TEXT,
|
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
result TEXT,
|
result TEXT,
|
||||||
error TEXT,
|
error TEXT,
|
||||||
@ -88,7 +52,6 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
|
|||||||
'failed', 'timed_out', 'cancelled', 'interrupted')),
|
'failed', 'timed_out', 'cancelled', 'interrupted')),
|
||||||
CHECK (depth >= 1),
|
CHECK (depth >= 1),
|
||||||
CHECK (completion_slot_reserved IN (0, 1)),
|
CHECK (completion_slot_reserved IN (0, 1)),
|
||||||
FOREIGN KEY (group_id) REFERENCES agent_run_groups(id) ON DELETE RESTRICT,
|
|
||||||
FOREIGN KEY (parent_run_id) REFERENCES agent_runs(id) ON DELETE RESTRICT
|
FOREIGN KEY (parent_run_id) REFERENCES agent_runs(id) ON DELETE RESTRICT
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
@ -113,10 +76,7 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
|
|||||||
CREATE TABLE IF NOT EXISTS agent_inbox_events (
|
CREATE TABLE IF NOT EXISTS agent_inbox_events (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
root_session_id TEXT NOT NULL,
|
root_session_id TEXT NOT NULL,
|
||||||
scope_kind TEXT NOT NULL,
|
run_id TEXT NOT NULL,
|
||||||
scope_id TEXT NOT NULL,
|
|
||||||
run_id TEXT,
|
|
||||||
group_id TEXT,
|
|
||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL,
|
||||||
event_key TEXT NOT NULL,
|
event_key TEXT NOT NULL,
|
||||||
delivery TEXT NOT NULL,
|
delivery TEXT NOT NULL,
|
||||||
@ -138,21 +98,13 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
|
|||||||
fallback_notified_at INTEGER,
|
fallback_notified_at INTEGER,
|
||||||
fallback_suppressed_reason TEXT,
|
fallback_suppressed_reason TEXT,
|
||||||
updated_at INTEGER NOT NULL,
|
updated_at INTEGER NOT NULL,
|
||||||
CHECK (scope_kind IN ('run', 'group')),
|
CHECK (event_type IN ('signal', 'completion')),
|
||||||
CHECK (event_type IN ('signal', 'completion', 'group_completion')),
|
|
||||||
CHECK (delivery IN ('queue', 'steer')),
|
CHECK (delivery IN ('queue', 'steer')),
|
||||||
CHECK (requires_continuation IN (0, 1)),
|
CHECK (requires_continuation IN (0, 1)),
|
||||||
CHECK (status IN ('pending', 'leased', 'admitted', 'consumed',
|
CHECK (status IN ('pending', 'leased', 'admitted', 'consumed',
|
||||||
'superseded', 'dead_letter')),
|
'superseded', 'dead_letter')),
|
||||||
CHECK (
|
UNIQUE(run_id, event_type, event_key),
|
||||||
(scope_kind = 'run' AND run_id IS NOT NULL AND group_id IS NULL
|
FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE RESTRICT
|
||||||
AND scope_id = run_id) OR
|
|
||||||
(scope_kind = 'group' AND group_id IS NOT NULL AND run_id IS NULL
|
|
||||||
AND scope_id = group_id)
|
|
||||||
),
|
|
||||||
UNIQUE(scope_kind, scope_id, event_type, event_key),
|
|
||||||
FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE RESTRICT,
|
|
||||||
FOREIGN KEY (group_id) REFERENCES agent_run_groups(id) ON DELETE RESTRICT
|
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
"CREATE INDEX IF NOT EXISTS idx_agent_inbox_claim ON agent_inbox_events(root_session_id, status, next_attempt_at, created_at)",
|
"CREATE INDEX IF NOT EXISTS idx_agent_inbox_claim ON agent_inbox_events(root_session_id, status, next_attempt_at, created_at)",
|
||||||
@ -185,31 +137,6 @@ impl AgentRunMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum AgentCompletionPolicy {
|
|
||||||
All,
|
|
||||||
Each,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgentCompletionPolicy {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::All => "all",
|
|
||||||
Self::Each => "each",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse(value: &str) -> Result<Self, StorageError> {
|
|
||||||
match value {
|
|
||||||
"all" => Ok(Self::All),
|
|
||||||
"each" => Ok(Self::Each),
|
|
||||||
other => Err(StorageError::Migration(format!(
|
|
||||||
"corrupt agent completion policy '{other}'"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AgentRunStatus {
|
pub enum AgentRunStatus {
|
||||||
Queued,
|
Queued,
|
||||||
@ -257,81 +184,9 @@ impl AgentRunStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum AgentGroupStatus {
|
|
||||||
Queued,
|
|
||||||
Running,
|
|
||||||
Completed,
|
|
||||||
Partial,
|
|
||||||
Failed,
|
|
||||||
TimedOut,
|
|
||||||
Cancelled,
|
|
||||||
Interrupted,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgentGroupStatus {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Queued => "queued",
|
|
||||||
Self::Running => "running",
|
|
||||||
Self::Completed => "completed",
|
|
||||||
Self::Partial => "partial",
|
|
||||||
Self::Failed => "failed",
|
|
||||||
Self::TimedOut => "timed_out",
|
|
||||||
Self::Cancelled => "cancelled",
|
|
||||||
Self::Interrupted => "interrupted",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse(value: &str) -> Result<Self, StorageError> {
|
|
||||||
match value {
|
|
||||||
"queued" => Ok(Self::Queued),
|
|
||||||
"running" => Ok(Self::Running),
|
|
||||||
"completed" => Ok(Self::Completed),
|
|
||||||
"partial" => Ok(Self::Partial),
|
|
||||||
"failed" => Ok(Self::Failed),
|
|
||||||
"timed_out" => Ok(Self::TimedOut),
|
|
||||||
"cancelled" => Ok(Self::Cancelled),
|
|
||||||
"interrupted" => Ok(Self::Interrupted),
|
|
||||||
other => Err(StorageError::Migration(format!(
|
|
||||||
"corrupt agent group status '{other}'"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_terminal(self) -> bool {
|
|
||||||
!matches!(self, Self::Queued | Self::Running)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AgentRunGroupRecord {
|
|
||||||
pub id: String,
|
|
||||||
pub root_session_id: String,
|
|
||||||
pub caller_run_id: Option<String>,
|
|
||||||
pub caller_scope_id: String,
|
|
||||||
pub idempotency_key: Option<String>,
|
|
||||||
pub mode: AgentRunMode,
|
|
||||||
pub completion_policy: AgentCompletionPolicy,
|
|
||||||
pub expected_runs: i64,
|
|
||||||
pub terminal_runs: i64,
|
|
||||||
pub abnormal_runs: i64,
|
|
||||||
pub completion_slot_reserved: bool,
|
|
||||||
pub completion_delivery: Option<String>,
|
|
||||||
pub failure_delivery: Option<String>,
|
|
||||||
pub deadline_at: i64,
|
|
||||||
pub status: AgentGroupStatus,
|
|
||||||
pub runtime_generation: i64,
|
|
||||||
pub revision: i64,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
pub finished_at: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AgentRunRecord {
|
pub struct AgentRunRecord {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub group_id: Option<String>,
|
|
||||||
pub root_session_id: String,
|
pub root_session_id: String,
|
||||||
pub root_turn_id: Option<String>,
|
pub root_turn_id: Option<String>,
|
||||||
pub parent_run_id: Option<String>,
|
pub parent_run_id: Option<String>,
|
||||||
@ -352,8 +207,6 @@ pub struct AgentRunRecord {
|
|||||||
pub budget_json: String,
|
pub budget_json: String,
|
||||||
pub signal_contract_json: Option<String>,
|
pub signal_contract_json: Option<String>,
|
||||||
pub signal_delivery: Option<String>,
|
pub signal_delivery: Option<String>,
|
||||||
pub completion_delivery: Option<String>,
|
|
||||||
pub failure_delivery: Option<String>,
|
|
||||||
pub status: AgentRunStatus,
|
pub status: AgentRunStatus,
|
||||||
pub result: Option<String>,
|
pub result: Option<String>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
@ -404,39 +257,19 @@ pub struct NewAgentRun {
|
|||||||
pub completion_slot_reserved: bool,
|
pub completion_slot_reserved: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Group header for batch admission. Single-task requests must not create a
|
/// Batch admission request. Each run carries its own idempotency key; a
|
||||||
/// group; their idempotency key lives on the run row instead.
|
/// single-task request is just a one-element batch.
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct NewAgentGroup {
|
|
||||||
pub id: String,
|
|
||||||
pub root_session_id: String,
|
|
||||||
pub caller_run_id: Option<String>,
|
|
||||||
pub caller_scope_id: String,
|
|
||||||
pub idempotency_key: Option<String>,
|
|
||||||
pub mode: AgentRunMode,
|
|
||||||
pub completion_policy: AgentCompletionPolicy,
|
|
||||||
pub deadline_at: i64,
|
|
||||||
pub runtime_generation: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AcceptAgentRequest {
|
pub struct AcceptAgentRequest {
|
||||||
pub group: Option<NewAgentGroup>,
|
|
||||||
pub runs: Vec<NewAgentRun>,
|
pub runs: Vec<NewAgentRun>,
|
||||||
pub now: i64,
|
pub now: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AcceptedAgentRuns {
|
pub enum AcceptedAgentRuns {
|
||||||
Accepted {
|
Accepted { runs: Vec<AgentRunRecord> },
|
||||||
group: Option<AgentRunGroupRecord>,
|
/// Idempotent retry: the run already existed for this key.
|
||||||
runs: Vec<AgentRunRecord>,
|
Existing { runs: Vec<AgentRunRecord> },
|
||||||
},
|
|
||||||
/// Idempotent retry: the group/run already existed for this key.
|
|
||||||
Existing {
|
|
||||||
group: Option<AgentRunGroupRecord>,
|
|
||||||
runs: Vec<AgentRunRecord>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Terminal outcome produced by a runner. The Coordinator persists it; the
|
/// Terminal outcome produced by a runner. The Coordinator persists it; the
|
||||||
@ -486,36 +319,24 @@ impl AgentTerminalOutcome {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_abnormal(&self) -> bool {
|
|
||||||
!matches!(self, Self::Completed { .. })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TerminalCommit {
|
pub struct TerminalCommit {
|
||||||
pub run: AgentRunRecord,
|
pub run: AgentRunRecord,
|
||||||
pub group: Option<AgentRunGroupRecord>,
|
|
||||||
pub group_finished: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RUN_COLUMNS: &str = "id, group_id, root_session_id, root_turn_id, parent_run_id, \
|
const RUN_COLUMNS: &str = "id, root_session_id, root_turn_id, parent_run_id, \
|
||||||
caller_agent_id, caller_scope_id, idempotency_key, agent_id, definition_hash, \
|
caller_agent_id, caller_scope_id, idempotency_key, agent_id, definition_hash, \
|
||||||
provider_profile, provider_name, model_id, mode, depth, plan_item_id, execution_id, \
|
provider_profile, provider_name, model_id, mode, depth, plan_item_id, execution_id, \
|
||||||
task, context_json, budget_json, signal_contract_json, signal_delivery, \
|
task, context_json, budget_json, signal_contract_json, signal_delivery, \
|
||||||
completion_delivery, failure_delivery, status, result, error, prompt_tokens, \
|
status, result, error, prompt_tokens, completion_tokens, cost, tool_calls_count, \
|
||||||
completion_tokens, cost, tool_calls_count, iterations, runtime_generation, attempt, \
|
iterations, runtime_generation, attempt, completion_slot_reserved, deadline_at, \
|
||||||
completion_slot_reserved, deadline_at, revision, started_at, finished_at, \
|
revision, started_at, finished_at, created_at, updated_at";
|
||||||
created_at, updated_at";
|
|
||||||
|
|
||||||
const GROUP_COLUMNS: &str = "id, root_session_id, caller_run_id, caller_scope_id, \
|
|
||||||
idempotency_key, mode, completion_policy, expected_runs, terminal_runs, \
|
|
||||||
abnormal_runs, completion_slot_reserved, completion_delivery, failure_delivery, \
|
|
||||||
deadline_at, status, runtime_generation, revision, created_at, updated_at, finished_at";
|
|
||||||
|
|
||||||
fn run_record_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<AgentRunRecord, StorageError> {
|
fn run_record_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<AgentRunRecord, StorageError> {
|
||||||
Ok(AgentRunRecord {
|
Ok(AgentRunRecord {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
group_id: row.get("group_id"),
|
|
||||||
root_session_id: row.get("root_session_id"),
|
root_session_id: row.get("root_session_id"),
|
||||||
root_turn_id: row.get("root_turn_id"),
|
root_turn_id: row.get("root_turn_id"),
|
||||||
parent_run_id: row.get("parent_run_id"),
|
parent_run_id: row.get("parent_run_id"),
|
||||||
@ -536,8 +357,6 @@ fn run_record_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<AgentRunRecord,
|
|||||||
budget_json: row.get("budget_json"),
|
budget_json: row.get("budget_json"),
|
||||||
signal_contract_json: row.get("signal_contract_json"),
|
signal_contract_json: row.get("signal_contract_json"),
|
||||||
signal_delivery: row.get("signal_delivery"),
|
signal_delivery: row.get("signal_delivery"),
|
||||||
completion_delivery: row.get("completion_delivery"),
|
|
||||||
failure_delivery: row.get("failure_delivery"),
|
|
||||||
status: AgentRunStatus::parse(row.get::<&str, _>("status"))?,
|
status: AgentRunStatus::parse(row.get::<&str, _>("status"))?,
|
||||||
result: row.get("result"),
|
result: row.get("result"),
|
||||||
error: row.get("error"),
|
error: row.get("error"),
|
||||||
@ -558,38 +377,11 @@ fn run_record_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<AgentRunRecord,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn group_record_from_row(
|
|
||||||
row: &sqlx::sqlite::SqliteRow,
|
|
||||||
) -> Result<AgentRunGroupRecord, StorageError> {
|
|
||||||
Ok(AgentRunGroupRecord {
|
|
||||||
id: row.get("id"),
|
|
||||||
root_session_id: row.get("root_session_id"),
|
|
||||||
caller_run_id: row.get("caller_run_id"),
|
|
||||||
caller_scope_id: row.get("caller_scope_id"),
|
|
||||||
idempotency_key: row.get("idempotency_key"),
|
|
||||||
mode: AgentRunMode::parse(row.get::<&str, _>("mode"))?,
|
|
||||||
completion_policy: AgentCompletionPolicy::parse(row.get::<&str, _>("completion_policy"))?,
|
|
||||||
expected_runs: row.get("expected_runs"),
|
|
||||||
terminal_runs: row.get("terminal_runs"),
|
|
||||||
abnormal_runs: row.get("abnormal_runs"),
|
|
||||||
completion_slot_reserved: row.get::<i64, _>("completion_slot_reserved") != 0,
|
|
||||||
completion_delivery: row.get("completion_delivery"),
|
|
||||||
failure_delivery: row.get("failure_delivery"),
|
|
||||||
deadline_at: row.get("deadline_at"),
|
|
||||||
status: AgentGroupStatus::parse(row.get::<&str, _>("status"))?,
|
|
||||||
runtime_generation: row.get("runtime_generation"),
|
|
||||||
revision: row.get("revision"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
updated_at: row.get("updated_at"),
|
|
||||||
finished_at: row.get("finished_at"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
impl super::Storage {
|
impl super::Storage {
|
||||||
/// Admit a group (optional) and its runs in one transaction, claiming any
|
/// Admit a batch of runs in one transaction, claiming any referenced
|
||||||
/// referenced plan items atomically. If any plan item was already taken
|
/// plan items atomically. If any plan item was already taken the whole
|
||||||
/// the whole admission rolls back so a run can never diverge from the
|
/// admission rolls back so a run can never diverge from the plan it
|
||||||
/// plan it claims to execute.
|
/// claims to execute.
|
||||||
pub async fn accept_agent_runs(
|
pub async fn accept_agent_runs(
|
||||||
&self,
|
&self,
|
||||||
request: AcceptAgentRequest,
|
request: AcceptAgentRequest,
|
||||||
@ -601,39 +393,9 @@ impl super::Storage {
|
|||||||
}
|
}
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
if let Some(group) = request.group.as_ref() {
|
|
||||||
let inserted = sqlx::query(
|
|
||||||
"INSERT INTO agent_run_groups (id, root_session_id, caller_run_id, \
|
|
||||||
caller_scope_id, idempotency_key, mode, completion_policy, \
|
|
||||||
expected_runs, terminal_runs, abnormal_runs, completion_slot_reserved, \
|
|
||||||
deadline_at, status, runtime_generation, revision, created_at, updated_at) \
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, 'queued', ?, 0, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&group.id)
|
|
||||||
.bind(&group.root_session_id)
|
|
||||||
.bind(&group.caller_run_id)
|
|
||||||
.bind(&group.caller_scope_id)
|
|
||||||
.bind(&group.idempotency_key)
|
|
||||||
.bind(group.mode.as_str())
|
|
||||||
.bind(group.completion_policy.as_str())
|
|
||||||
.bind(request.runs.len() as i64)
|
|
||||||
.bind(group.deadline_at)
|
|
||||||
.bind(group.runtime_generation)
|
|
||||||
.bind(request.now)
|
|
||||||
.bind(request.now)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?
|
|
||||||
.rows_affected()
|
|
||||||
== 1;
|
|
||||||
if !inserted {
|
|
||||||
drop(tx);
|
|
||||||
return self.existing_agent_admission(request).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for run in &request.runs {
|
for run in &request.runs {
|
||||||
let inserted = sqlx::query(
|
let inserted = sqlx::query(
|
||||||
"INSERT INTO agent_runs (id, group_id, root_session_id, root_turn_id, \
|
"INSERT INTO agent_runs (id, root_session_id, root_turn_id, \
|
||||||
parent_run_id, caller_agent_id, caller_scope_id, idempotency_key, \
|
parent_run_id, caller_agent_id, caller_scope_id, idempotency_key, \
|
||||||
agent_id, definition_hash, provider_profile, provider_name, model_id, \
|
agent_id, definition_hash, provider_profile, provider_name, model_id, \
|
||||||
mode, depth, plan_item_id, execution_id, task, context_json, budget_json, \
|
mode, depth, plan_item_id, execution_id, task, context_json, budget_json, \
|
||||||
@ -641,10 +403,9 @@ impl super::Storage {
|
|||||||
status, runtime_generation, attempt, completion_slot_reserved, deadline_at, \
|
status, runtime_generation, attempt, completion_slot_reserved, deadline_at, \
|
||||||
revision, created_at, updated_at) \
|
revision, created_at, updated_at) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
|
||||||
?, ?, 'queued', ?, 1, ?, ?, 0, ?, ?)",
|
?, 'queued', ?, 1, ?, ?, 0, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&run.id)
|
.bind(&run.id)
|
||||||
.bind(request.group.as_ref().map(|group| group.id.clone()))
|
|
||||||
.bind(&run.root_session_id)
|
.bind(&run.root_session_id)
|
||||||
.bind(&run.root_turn_id)
|
.bind(&run.root_turn_id)
|
||||||
.bind(&run.parent_run_id)
|
.bind(&run.parent_run_id)
|
||||||
@ -699,13 +460,7 @@ impl super::Storage {
|
|||||||
StorageError::NotFound(format!("agent run {} vanished after admission", run.id))
|
StorageError::NotFound(format!("agent run {} vanished after admission", run.id))
|
||||||
})?);
|
})?);
|
||||||
}
|
}
|
||||||
let group = match request.group.as_ref() {
|
Ok(AcceptedAgentRuns::Accepted { runs })
|
||||||
Some(group) => Some(self.get_agent_run_group(&group.id).await?.ok_or_else(|| {
|
|
||||||
StorageError::NotFound(format!("agent group {} vanished after admission", group.id))
|
|
||||||
})?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
Ok(AcceptedAgentRuns::Accepted { group, runs })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn existing_agent_admission(
|
async fn existing_agent_admission(
|
||||||
@ -718,16 +473,12 @@ impl super::Storage {
|
|||||||
runs.push(record);
|
runs.push(record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let group = match request.group.as_ref() {
|
if runs.is_empty() {
|
||||||
Some(group) => self.get_agent_run_group(&group.id).await?,
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
if runs.is_empty() && group.is_none() {
|
|
||||||
return Err(StorageError::Conflict(
|
return Err(StorageError::Conflict(
|
||||||
"agent admission conflicted but no existing rows were found".to_string(),
|
"agent admission conflicted but no existing rows were found".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(AcceptedAgentRuns::Existing { group, runs })
|
Ok(AcceptedAgentRuns::Existing { runs })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_agent_run(
|
pub async fn get_agent_run(
|
||||||
@ -746,22 +497,6 @@ impl super::Storage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_agent_run_group(
|
|
||||||
&self,
|
|
||||||
group_id: &str,
|
|
||||||
) -> Result<Option<AgentRunGroupRecord>, StorageError> {
|
|
||||||
let row = sqlx::query(sqlx::AssertSqlSafe(format!(
|
|
||||||
"SELECT {GROUP_COLUMNS} FROM agent_run_groups WHERE id = ?"
|
|
||||||
)))
|
|
||||||
.bind(group_id)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await?;
|
|
||||||
match row {
|
|
||||||
Some(row) => Ok(Some(group_record_from_row(&row)?)),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List runs for a session ordered by `(created_at DESC, id DESC)`.
|
/// List runs for a session ordered by `(created_at DESC, id DESC)`.
|
||||||
/// The cursor is the pair of the last row the client has seen.
|
/// The cursor is the pair of the last row the client has seen.
|
||||||
pub async fn list_agent_runs(
|
pub async fn list_agent_runs(
|
||||||
@ -834,18 +569,6 @@ impl super::Storage {
|
|||||||
rows.iter().map(run_record_from_row).collect()
|
rows.iter().map(run_record_from_row).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_agent_group_runs(
|
|
||||||
&self,
|
|
||||||
group_id: &str,
|
|
||||||
) -> Result<Vec<AgentRunRecord>, StorageError> {
|
|
||||||
let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
|
|
||||||
"SELECT {RUN_COLUMNS} FROM agent_runs WHERE group_id = ? ORDER BY created_at ASC, id ASC"
|
|
||||||
)))
|
|
||||||
.bind(group_id)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
rows.iter().map(run_record_from_row).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Conditional `queued -> running` transition owned by this execution.
|
/// Conditional `queued -> running` transition owned by this execution.
|
||||||
pub async fn mark_agent_run_running(
|
pub async fn mark_agent_run_running(
|
||||||
@ -976,8 +699,9 @@ impl super::Storage {
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
if reserved {
|
if reserved {
|
||||||
let session: String =
|
let (session, agent_id, task): (String, String, String) = sqlx::query_as(
|
||||||
sqlx::query_scalar("SELECT root_session_id FROM agent_runs WHERE id = ?")
|
"SELECT root_session_id, agent_id, task FROM agent_runs WHERE id = ?",
|
||||||
|
)
|
||||||
.bind(run_id)
|
.bind(run_id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
@ -994,33 +718,34 @@ impl super::Storage {
|
|||||||
let event = super::agent_inbox::NewInboxEvent {
|
let event = super::agent_inbox::NewInboxEvent {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
root_session_id: session,
|
root_session_id: session,
|
||||||
scope_kind: "run".to_string(),
|
|
||||||
scope_id: run_id.to_string(),
|
|
||||||
run_id: Some(run_id.to_string()),
|
run_id: Some(run_id.to_string()),
|
||||||
group_id: None,
|
|
||||||
event_type: super::agent_inbox::AgentEventType::Completion,
|
event_type: super::agent_inbox::AgentEventType::Completion,
|
||||||
event_key: format!("completion:{run_id}"),
|
event_key: format!("completion:{run_id}"),
|
||||||
delivery: super::agent_inbox::AgentEventDelivery::Queue,
|
delivery: super::agent_inbox::AgentEventDelivery::Queue,
|
||||||
requires_continuation: !suppress_continuation,
|
requires_continuation: !suppress_continuation,
|
||||||
severity: Some("warning".to_string()),
|
severity: Some("warning".to_string()),
|
||||||
payload_json: serde_json::json!({ "status": "cancelled", "error": reason })
|
payload_json: super::agent_inbox::completion_payload(
|
||||||
.to_string(),
|
run_id,
|
||||||
|
&agent_id,
|
||||||
|
&task,
|
||||||
|
None,
|
||||||
|
"cancelled",
|
||||||
|
Some(reason),
|
||||||
|
&[],
|
||||||
|
),
|
||||||
};
|
};
|
||||||
if suppress_continuation {
|
if suppress_continuation {
|
||||||
// Directly consumed: pending count never grows.
|
// Directly consumed: pending count never grows.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO agent_inbox_events (id, root_session_id, scope_kind, scope_id, \
|
"INSERT INTO agent_inbox_events (id, root_session_id, run_id, event_type, \
|
||||||
run_id, group_id, event_type, event_key, delivery, requires_continuation, \
|
event_key, delivery, requires_continuation, severity, payload_json, \
|
||||||
severity, payload_json, status, attempt_count, revision, next_attempt_at, \
|
status, attempt_count, revision, next_attempt_at, created_at, \
|
||||||
created_at, updated_at, consumed_at) \
|
updated_at, consumed_at) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'consumed', 0, ?, NULL, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'consumed', 0, ?, NULL, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&event.id)
|
.bind(&event.id)
|
||||||
.bind(&event.root_session_id)
|
.bind(&event.root_session_id)
|
||||||
.bind(&event.scope_kind)
|
|
||||||
.bind(&event.scope_id)
|
|
||||||
.bind(&event.run_id)
|
.bind(&event.run_id)
|
||||||
.bind(&event.group_id)
|
|
||||||
.bind(event.event_type.as_str())
|
.bind(event.event_type.as_str())
|
||||||
.bind(&event.event_key)
|
.bind(&event.event_key)
|
||||||
.bind(event.delivery.as_str())
|
.bind(event.delivery.as_str())
|
||||||
@ -1135,50 +860,6 @@ impl super::Storage {
|
|||||||
.await?;
|
.await?;
|
||||||
let run = run_record_from_row(&run_row)?;
|
let run = run_record_from_row(&run_row)?;
|
||||||
|
|
||||||
let mut group = None;
|
|
||||||
let mut group_finished = false;
|
|
||||||
if let Some(group_id) = run.group_id.as_deref() {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE agent_run_groups SET terminal_runs = terminal_runs + 1, \
|
|
||||||
abnormal_runs = abnormal_runs + ?, updated_at = ? WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(i64::from(outcome.is_abnormal()))
|
|
||||||
.bind(now)
|
|
||||||
.bind(group_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
let group_row = sqlx::query(sqlx::AssertSqlSafe(format!(
|
|
||||||
"SELECT {GROUP_COLUMNS} FROM agent_run_groups WHERE id = ?"
|
|
||||||
)))
|
|
||||||
.bind(group_id)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
let mut record = group_record_from_row(&group_row)?;
|
|
||||||
if record.terminal_runs >= record.expected_runs && !record.status.is_terminal() {
|
|
||||||
let final_status = if record.abnormal_runs == 0 {
|
|
||||||
AgentGroupStatus::Completed
|
|
||||||
} else if record.abnormal_runs < record.expected_runs {
|
|
||||||
AgentGroupStatus::Partial
|
|
||||||
} else {
|
|
||||||
AgentGroupStatus::Failed
|
|
||||||
};
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE agent_run_groups SET status = ?, finished_at = ?, updated_at = ? \
|
|
||||||
WHERE id = ? AND status IN ('queued', 'running')",
|
|
||||||
)
|
|
||||||
.bind(final_status.as_str())
|
|
||||||
.bind(now)
|
|
||||||
.bind(now)
|
|
||||||
.bind(group_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
record.status = final_status;
|
|
||||||
record.finished_at = Some(now);
|
|
||||||
group_finished = true;
|
|
||||||
}
|
|
||||||
group = Some(record);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(item_id) = run.plan_item_id.as_deref() {
|
if let Some(item_id) = run.plan_item_id.as_deref() {
|
||||||
finish_plan_item(
|
finish_plan_item(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
@ -1196,29 +877,44 @@ impl super::Storage {
|
|||||||
// reservation into a durable completion event in the same commit.
|
// reservation into a durable completion event in the same commit.
|
||||||
// The event survives restarts, queue-full conditions and lost wakes.
|
// The event survives restarts, queue-full conditions and lost wakes.
|
||||||
if run.completion_slot_reserved {
|
if run.completion_slot_reserved {
|
||||||
let (status, error, signal_ids) = match outcome {
|
let (status, error, signal_ids, result) = match outcome {
|
||||||
AgentTerminalOutcome::Completed { signal_ids, .. } => {
|
AgentTerminalOutcome::Completed {
|
||||||
("completed", None, signal_ids.as_slice())
|
result, signal_ids, ..
|
||||||
}
|
} => (
|
||||||
|
"completed",
|
||||||
|
None,
|
||||||
|
signal_ids.as_slice(),
|
||||||
|
Some(result.as_str()),
|
||||||
|
),
|
||||||
AgentTerminalOutcome::Failed {
|
AgentTerminalOutcome::Failed {
|
||||||
error, signal_ids, ..
|
error, signal_ids, ..
|
||||||
} => ("failed", Some(error.as_str()), signal_ids.as_slice()),
|
} => ("failed", Some(error.as_str()), signal_ids.as_slice(), None),
|
||||||
AgentTerminalOutcome::TimedOut { signal_ids, .. } => (
|
AgentTerminalOutcome::TimedOut { signal_ids, .. } => (
|
||||||
"timed_out",
|
"timed_out",
|
||||||
Some("deadline exceeded"),
|
Some("deadline exceeded"),
|
||||||
signal_ids.as_slice(),
|
signal_ids.as_slice(),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
AgentTerminalOutcome::Cancelled { reason, signal_ids } => (
|
||||||
|
"cancelled",
|
||||||
|
Some(reason.as_str()),
|
||||||
|
signal_ids.as_slice(),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
AgentTerminalOutcome::Interrupted { reason, signal_ids } => (
|
||||||
|
"interrupted",
|
||||||
|
Some(reason.as_str()),
|
||||||
|
signal_ids.as_slice(),
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
AgentTerminalOutcome::Cancelled { reason, signal_ids } => {
|
|
||||||
("cancelled", Some(reason.as_str()), signal_ids.as_slice())
|
|
||||||
}
|
|
||||||
AgentTerminalOutcome::Interrupted { reason, signal_ids } => {
|
|
||||||
("interrupted", Some(reason.as_str()), signal_ids.as_slice())
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
super::agent_inbox::insert_completion_event_tx(
|
super::agent_inbox::insert_completion_event_tx(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
&run.id,
|
&run.id,
|
||||||
&run.root_session_id,
|
&run.root_session_id,
|
||||||
|
&run.agent_id,
|
||||||
|
&run.task,
|
||||||
|
result,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
signal_ids,
|
signal_ids,
|
||||||
@ -1228,11 +924,7 @@ impl super::Storage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(Some(TerminalCommit {
|
Ok(Some(TerminalCommit { run }))
|
||||||
run,
|
|
||||||
group,
|
|
||||||
group_finished,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1369,15 +1061,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fresh_database_creates_schema_v6_agent_tables() {
|
async fn fresh_database_creates_schema_v8_agent_tables() {
|
||||||
let (storage, _dir) = create_test_storage().await;
|
let (storage, _dir) = create_test_storage().await;
|
||||||
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
|
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
|
||||||
.fetch_one(storage.pool())
|
.fetch_one(storage.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(version, 6);
|
assert_eq!(version, 8);
|
||||||
for table in [
|
for table in [
|
||||||
"agent_run_groups",
|
|
||||||
"agent_runs",
|
"agent_runs",
|
||||||
"agent_session_state",
|
"agent_session_state",
|
||||||
"agent_inbox_events",
|
"agent_inbox_events",
|
||||||
@ -1398,7 +1089,6 @@ mod tests {
|
|||||||
let (storage, _dir) = create_test_storage().await;
|
let (storage, _dir) = create_test_storage().await;
|
||||||
let accepted = storage
|
let accepted = storage
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
.accept_agent_runs(AcceptAgentRequest {
|
||||||
group: None,
|
|
||||||
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
|
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
|
||||||
now: 100,
|
now: 100,
|
||||||
})
|
})
|
||||||
@ -1408,98 +1098,14 @@ mod tests {
|
|||||||
|
|
||||||
let run = storage.get_agent_run("run-1").await.unwrap().unwrap();
|
let run = storage.get_agent_run("run-1").await.unwrap().unwrap();
|
||||||
assert_eq!(run.status, AgentRunStatus::Queued);
|
assert_eq!(run.status, AgentRunStatus::Queued);
|
||||||
assert!(run.group_id.is_none());
|
|
||||||
assert_eq!(run.execution_id, "exec-1");
|
assert_eq!(run.execution_id, "exec-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn batch_admission_tracks_group_terminal_counters() {
|
|
||||||
let (storage, _dir) = create_test_storage().await;
|
|
||||||
let group = NewAgentGroup {
|
|
||||||
id: "group-1".to_string(),
|
|
||||||
root_session_id: "cli:test:d1".to_string(),
|
|
||||||
caller_run_id: None,
|
|
||||||
caller_scope_id: "turn-1".to_string(),
|
|
||||||
idempotency_key: Some("batch-key".to_string()),
|
|
||||||
mode: AgentRunMode::Foreground,
|
|
||||||
completion_policy: AgentCompletionPolicy::All,
|
|
||||||
deadline_at: 2_000,
|
|
||||||
runtime_generation: 1,
|
|
||||||
};
|
|
||||||
storage
|
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
|
||||||
group: Some(group),
|
|
||||||
runs: vec![
|
|
||||||
new_run("run-a", "exec-a", "cli:test:d1"),
|
|
||||||
new_run("run-b", "exec-b", "cli:test:d1"),
|
|
||||||
],
|
|
||||||
now: 100,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
storage
|
|
||||||
.mark_agent_run_running("run-a", "exec-a", 110)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
let first = storage
|
|
||||||
.commit_agent_terminal(
|
|
||||||
"run-a",
|
|
||||||
"exec-a",
|
|
||||||
1,
|
|
||||||
&AgentTerminalOutcome::Completed {
|
|
||||||
result: "done".to_string(),
|
|
||||||
prompt_tokens: Some(2),
|
|
||||||
completion_tokens: Some(3),
|
|
||||||
cost: None,
|
|
||||||
tool_calls: 1,
|
|
||||||
iterations: 2,
|
|
||||||
signal_ids: Vec::new(),
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
120,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert!(!first.group_finished);
|
|
||||||
assert_eq!(first.group.as_ref().unwrap().terminal_runs, 1);
|
|
||||||
|
|
||||||
let second = storage
|
|
||||||
.commit_agent_terminal(
|
|
||||||
"run-b",
|
|
||||||
"exec-b",
|
|
||||||
1,
|
|
||||||
&AgentTerminalOutcome::Failed {
|
|
||||||
error: "boom".to_string(),
|
|
||||||
prompt_tokens: None,
|
|
||||||
completion_tokens: None,
|
|
||||||
cost: None,
|
|
||||||
signal_ids: Vec::new(),
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
130,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert!(second.group_finished);
|
|
||||||
let group = second.group.unwrap();
|
|
||||||
assert_eq!(group.status, AgentGroupStatus::Partial);
|
|
||||||
assert_eq!(group.finished_at, Some(130));
|
|
||||||
|
|
||||||
let runs = storage.list_agent_group_runs("group-1").await.unwrap();
|
|
||||||
assert_eq!(runs.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stale_execution_cannot_commit_terminal() {
|
async fn stale_execution_cannot_commit_terminal() {
|
||||||
let (storage, _dir) = create_test_storage().await;
|
let (storage, _dir) = create_test_storage().await;
|
||||||
storage
|
storage
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
.accept_agent_runs(AcceptAgentRequest {
|
||||||
group: None,
|
|
||||||
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
|
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
|
||||||
now: 100,
|
now: 100,
|
||||||
})
|
})
|
||||||
@ -1537,7 +1143,6 @@ mod tests {
|
|||||||
let (storage, _dir) = create_test_storage().await;
|
let (storage, _dir) = create_test_storage().await;
|
||||||
storage
|
storage
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
.accept_agent_runs(AcceptAgentRequest {
|
||||||
group: None,
|
|
||||||
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
|
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
|
||||||
now: 100,
|
now: 100,
|
||||||
})
|
})
|
||||||
@ -1593,7 +1198,6 @@ mod tests {
|
|||||||
run.plan_item_id = Some("T1".to_string());
|
run.plan_item_id = Some("T1".to_string());
|
||||||
storage
|
storage
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
.accept_agent_runs(AcceptAgentRequest {
|
||||||
group: None,
|
|
||||||
runs: vec![run.clone()],
|
runs: vec![run.clone()],
|
||||||
now: 100,
|
now: 100,
|
||||||
})
|
})
|
||||||
@ -1610,7 +1214,6 @@ mod tests {
|
|||||||
run.execution_id = "exec-2".to_string();
|
run.execution_id = "exec-2".to_string();
|
||||||
let error = storage
|
let error = storage
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
.accept_agent_runs(AcceptAgentRequest {
|
||||||
group: None,
|
|
||||||
runs: vec![run],
|
runs: vec![run],
|
||||||
now: 110,
|
now: 110,
|
||||||
})
|
})
|
||||||
@ -1661,7 +1264,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
storage
|
storage
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
.accept_agent_runs(AcceptAgentRequest {
|
||||||
group: None,
|
|
||||||
runs,
|
runs,
|
||||||
now: 100,
|
now: 100,
|
||||||
})
|
})
|
||||||
@ -1692,7 +1294,6 @@ mod tests {
|
|||||||
let (storage, _dir) = create_test_storage().await;
|
let (storage, _dir) = create_test_storage().await;
|
||||||
storage
|
storage
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
.accept_agent_runs(AcceptAgentRequest {
|
||||||
group: None,
|
|
||||||
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
|
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
|
||||||
now: 100,
|
now: 100,
|
||||||
})
|
})
|
||||||
@ -1723,7 +1324,6 @@ mod tests {
|
|||||||
run.completion_slot_reserved = true;
|
run.completion_slot_reserved = true;
|
||||||
storage
|
storage
|
||||||
.accept_agent_runs(AcceptAgentRequest {
|
.accept_agent_runs(AcceptAgentRequest {
|
||||||
group: None,
|
|
||||||
runs: vec![run],
|
runs: vec![run],
|
||||||
now: 100,
|
now: 100,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct BackgroundTask {
|
|
||||||
pub id: String,
|
|
||||||
pub session_id: String,
|
|
||||||
pub channel: String,
|
|
||||||
pub chat_id: String,
|
|
||||||
pub prompt: String,
|
|
||||||
pub allowed_tools: Option<String>,
|
|
||||||
pub status: String,
|
|
||||||
pub result: Option<String>,
|
|
||||||
pub error: Option<String>,
|
|
||||||
pub tool_calls_count: i64,
|
|
||||||
pub iterations: i64,
|
|
||||||
pub started_at: Option<i64>,
|
|
||||||
pub finished_at: Option<i64>,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
pub mod agent_inbox;
|
pub mod agent_inbox;
|
||||||
pub mod agent_run;
|
pub mod agent_run;
|
||||||
pub mod background_task;
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod message;
|
pub mod message;
|
||||||
@ -8,7 +7,6 @@ pub mod scheduler;
|
|||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|
||||||
pub use background_task::BackgroundTask;
|
|
||||||
pub use error::StorageError;
|
pub use error::StorageError;
|
||||||
pub use scheduler::{DeliveryPolicy, JobKind, JobRun, ScheduledJob};
|
pub use scheduler::{DeliveryPolicy, JobKind, JobRun, ScheduledJob};
|
||||||
pub use usage::{SessionUsageTotals, TurnUsageRecord};
|
pub use usage::{SessionUsageTotals, TurnUsageRecord};
|
||||||
@ -20,7 +18,7 @@ use sqlx::{Pool, Row, Sqlite};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
const SCHEMA_VERSION: i64 = 6;
|
const SCHEMA_VERSION: i64 = 8;
|
||||||
const INSERT_MESSAGE_SQL: &str = r#"
|
const INSERT_MESSAGE_SQL: &str = r#"
|
||||||
INSERT INTO messages (
|
INSERT INTO messages (
|
||||||
id, session_id, seq, role, content, reasoning_content, provider_state,
|
id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||||
@ -192,48 +190,6 @@ impl Storage {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Background tasks table — for async sub-agent tasks.
|
|
||||||
// Note: No FOREIGN KEY on session_id because sessions use soft delete (deleted_at IS NULL).
|
|
||||||
// Session and task association is maintained at the application level.
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
CREATE TABLE IF NOT EXISTS background_tasks (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
session_id TEXT NOT NULL,
|
|
||||||
channel TEXT NOT NULL,
|
|
||||||
chat_id TEXT NOT NULL,
|
|
||||||
prompt TEXT NOT NULL,
|
|
||||||
allowed_tools TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
result TEXT,
|
|
||||||
error TEXT,
|
|
||||||
tool_calls_count INTEGER DEFAULT 0,
|
|
||||||
iterations INTEGER DEFAULT 0,
|
|
||||||
started_at INTEGER,
|
|
||||||
finished_at INTEGER,
|
|
||||||
created_at INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bg_tasks_session ON background_tasks(session_id)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bg_tasks_status ON background_tasks(status)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Session-scoped task plans. A session may have at most one active plan,
|
// Session-scoped task plans. A session may have at most one active plan,
|
||||||
// while independent items can be executed concurrently.
|
// while independent items can be executed concurrently.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@ -439,6 +395,25 @@ impl Storage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
|
// Legacy table removed in schema v7; drop it so old databases do not
|
||||||
|
// keep dead rows around.
|
||||||
|
sqlx::query("DROP TABLE IF EXISTS background_tasks")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
// Schema v8 removes the batch "group" concept entirely: the
|
||||||
|
// `agent_run_groups` table is gone, and the run/inbox tables are
|
||||||
|
// rebuilt without their `group_id`/`scope_kind`/`scope_id` columns.
|
||||||
|
// Drop in dependency order (inbox -> runs -> groups) so foreign-key
|
||||||
|
// enforcement never blocks the implicit row delete.
|
||||||
|
sqlx::query("DROP TABLE IF EXISTS agent_inbox_events")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DROP TABLE IF EXISTS agent_runs")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DROP TABLE IF EXISTS agent_run_groups")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
for (table, column, definition) in [
|
for (table, column, definition) in [
|
||||||
("messages", "source", "source TEXT"),
|
("messages", "source", "source TEXT"),
|
||||||
("messages", "reasoning_content", "reasoning_content TEXT"),
|
("messages", "reasoning_content", "reasoning_content TEXT"),
|
||||||
@ -1459,149 +1434,6 @@ impl Storage {
|
|||||||
unreachable!()
|
unreachable!()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Background Task CRUD ──
|
|
||||||
|
|
||||||
pub async fn create_background_task(
|
|
||||||
&self,
|
|
||||||
task: &crate::storage::background_task::BackgroundTask,
|
|
||||||
) -> Result<(), StorageError> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO background_tasks (id, session_id, channel, chat_id, prompt, allowed_tools, status, result, error, tool_calls_count, iterations, started_at, finished_at, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(&task.id)
|
|
||||||
.bind(&task.session_id)
|
|
||||||
.bind(&task.channel)
|
|
||||||
.bind(&task.chat_id)
|
|
||||||
.bind(&task.prompt)
|
|
||||||
.bind(&task.allowed_tools)
|
|
||||||
.bind(&task.status)
|
|
||||||
.bind(&task.result)
|
|
||||||
.bind(&task.error)
|
|
||||||
.bind(task.tool_calls_count)
|
|
||||||
.bind(task.iterations)
|
|
||||||
.bind(task.started_at)
|
|
||||||
.bind(task.finished_at)
|
|
||||||
.bind(task.created_at)
|
|
||||||
.execute(self.pool())
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
pub async fn get_background_task(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
) -> Result<crate::storage::background_task::BackgroundTask, StorageError> {
|
|
||||||
let row = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT id, session_id, channel, chat_id, prompt, allowed_tools, status, result, error,
|
|
||||||
tool_calls_count, iterations, started_at, finished_at, created_at
|
|
||||||
FROM background_tasks WHERE id = ?
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(self.pool())
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| StorageError::NotFound(id.to_string()))?;
|
|
||||||
|
|
||||||
Ok(crate::storage::background_task::BackgroundTask {
|
|
||||||
id: row.get("id"),
|
|
||||||
session_id: row.get("session_id"),
|
|
||||||
channel: row.get("channel"),
|
|
||||||
chat_id: row.get("chat_id"),
|
|
||||||
prompt: row.get("prompt"),
|
|
||||||
allowed_tools: row.get("allowed_tools"),
|
|
||||||
status: row.get("status"),
|
|
||||||
result: row.get("result"),
|
|
||||||
error: row.get("error"),
|
|
||||||
tool_calls_count: row.get("tool_calls_count"),
|
|
||||||
iterations: row.get("iterations"),
|
|
||||||
started_at: row.get("started_at"),
|
|
||||||
finished_at: row.get("finished_at"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_background_tasks(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
) -> Result<Vec<crate::storage::background_task::BackgroundTask>, StorageError> {
|
|
||||||
let rows = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT id, session_id, channel, chat_id, prompt, allowed_tools, status, result, error,
|
|
||||||
tool_calls_count, iterations, started_at, finished_at, created_at
|
|
||||||
FROM background_tasks
|
|
||||||
WHERE session_id = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(session_id)
|
|
||||||
.fetch_all(self.pool())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| crate::storage::background_task::BackgroundTask {
|
|
||||||
id: row.get("id"),
|
|
||||||
session_id: row.get("session_id"),
|
|
||||||
channel: row.get("channel"),
|
|
||||||
chat_id: row.get("chat_id"),
|
|
||||||
prompt: row.get("prompt"),
|
|
||||||
allowed_tools: row.get("allowed_tools"),
|
|
||||||
status: row.get("status"),
|
|
||||||
result: row.get("result"),
|
|
||||||
error: row.get("error"),
|
|
||||||
tool_calls_count: row.get("tool_calls_count"),
|
|
||||||
iterations: row.get("iterations"),
|
|
||||||
started_at: row.get("started_at"),
|
|
||||||
finished_at: row.get("finished_at"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List recent background tasks across sessions for the management UI.
|
|
||||||
pub async fn list_recent_background_tasks(
|
|
||||||
&self,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<crate::storage::background_task::BackgroundTask>, StorageError> {
|
|
||||||
let rows = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT id, session_id, channel, chat_id, prompt, allowed_tools, status, result, error,
|
|
||||||
tool_calls_count, iterations, started_at, finished_at, created_at
|
|
||||||
FROM background_tasks
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(limit as i64)
|
|
||||||
.fetch_all(self.pool())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| crate::storage::background_task::BackgroundTask {
|
|
||||||
id: row.get("id"),
|
|
||||||
session_id: row.get("session_id"),
|
|
||||||
channel: row.get("channel"),
|
|
||||||
chat_id: row.get("chat_id"),
|
|
||||||
prompt: row.get("prompt"),
|
|
||||||
allowed_tools: row.get("allowed_tools"),
|
|
||||||
status: row.get("status"),
|
|
||||||
result: row.get("result"),
|
|
||||||
error: row.get("error"),
|
|
||||||
tool_calls_count: row.get("tool_calls_count"),
|
|
||||||
iterations: row.get("iterations"),
|
|
||||||
started_at: row.get("started_at"),
|
|
||||||
finished_at: row.get("finished_at"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist the channel's durable delivery context for a session. Only
|
/// Persist the channel's durable delivery context for a session. Only
|
||||||
/// channel-declared reusable values (thread/root identity) ever reach
|
/// channel-declared reusable values (thread/root identity) ever reach
|
||||||
/// this column; one-shot reply/reaction ids never do.
|
/// this column; one-shot reply/reaction ids never do.
|
||||||
@ -1637,17 +1469,6 @@ impl Storage {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(context)
|
Ok(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cleanup_old_tasks(&self, ttl_ms: i64) -> Result<usize, StorageError> {
|
|
||||||
let cutoff = chrono::Utc::now().timestamp_millis() - ttl_ms;
|
|
||||||
let result = sqlx::query(
|
|
||||||
"DELETE FROM background_tasks WHERE status IN ('completed', 'failed', 'cancelled') AND finished_at IS NOT NULL AND finished_at < ?",
|
|
||||||
)
|
|
||||||
.bind(cutoff)
|
|
||||||
.execute(self.pool())
|
|
||||||
.await?;
|
|
||||||
Ok(result.rows_affected() as usize)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -1796,42 +1617,6 @@ mod tests {
|
|||||||
assert_eq!(sentinel_count, 1);
|
assert_eq!(sentinel_count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn webui_lists_recent_tasks_across_sessions() {
|
|
||||||
let (storage, _dir) = create_test_storage().await;
|
|
||||||
for (id, session_id, created_at) in [("old", "cli:a:d1", 1), ("new", "cli:b:d2", 2)] {
|
|
||||||
storage
|
|
||||||
.create_background_task(&crate::storage::BackgroundTask {
|
|
||||||
id: id.into(),
|
|
||||||
session_id: session_id.into(),
|
|
||||||
channel: "cli".into(),
|
|
||||||
chat_id: "chat".into(),
|
|
||||||
prompt: id.into(),
|
|
||||||
allowed_tools: None,
|
|
||||||
status: "pending".into(),
|
|
||||||
result: None,
|
|
||||||
error: None,
|
|
||||||
tool_calls_count: 0,
|
|
||||||
iterations: 0,
|
|
||||||
started_at: None,
|
|
||||||
finished_at: None,
|
|
||||||
created_at,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let tasks = storage.list_recent_background_tasks(10).await.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
tasks
|
|
||||||
.iter()
|
|
||||||
.map(|task| task.id.as_str())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec!["new", "old"]
|
|
||||||
);
|
|
||||||
assert_eq!(tasks[0].session_id, "cli:b:d2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn webui_lists_and_filters_memories_without_search_text() {
|
async fn webui_lists_and_filters_memories_without_search_text() {
|
||||||
let (storage, _dir) = create_test_storage().await;
|
let (storage, _dir) = create_test_storage().await;
|
||||||
@ -1997,7 +1782,6 @@ mod tests {
|
|||||||
"task_plans",
|
"task_plans",
|
||||||
"task_items",
|
"task_items",
|
||||||
"session_turn_usage",
|
"session_turn_usage",
|
||||||
"agent_run_groups",
|
|
||||||
"agent_runs",
|
"agent_runs",
|
||||||
"agent_session_state",
|
"agent_session_state",
|
||||||
"agent_inbox_events",
|
"agent_inbox_events",
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use serde_json::{Value, json};
|
|||||||
|
|
||||||
use crate::agent::AgentCoordinator;
|
use crate::agent::AgentCoordinator;
|
||||||
use crate::storage::agent_run::AgentRunRecord;
|
use crate::storage::agent_run::AgentRunRecord;
|
||||||
use crate::tools::traits::{DelegationPolicy, Tool, ToolExecutionContext, ToolOutput, ToolResult};
|
use crate::tools::traits::{Tool, ToolExecutionContext, ToolOutput, ToolResult};
|
||||||
|
|
||||||
const RESULT_PREVIEW_CHARS: usize = 2_000;
|
const RESULT_PREVIEW_CHARS: usize = 2_000;
|
||||||
|
|
||||||
@ -32,8 +32,8 @@ impl Tool for AgentTaskTool {
|
|||||||
"Inspect or control delegated Agent runs: get reads one run, list shows the session's runs, get_result returns the full terminal result, cancel stops a non-terminal run."
|
"Inspect or control delegated Agent runs: get reads one run, list shows the session's runs, get_result returns the full terminal result, cancel stops a non-terminal run."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delegation_policy(&self) -> DelegationPolicy {
|
fn runtime_injected(&self) -> bool {
|
||||||
DelegationPolicy::RuntimeInjected
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> Value {
|
fn parameters_schema(&self) -> Value {
|
||||||
@ -199,7 +199,6 @@ impl AgentTaskTool {
|
|||||||
fn run_projection(run: &AgentRunRecord, include_result_preview: bool) -> Value {
|
fn run_projection(run: &AgentRunRecord, include_result_preview: bool) -> Value {
|
||||||
let mut value = json!({
|
let mut value = json!({
|
||||||
"run_id": run.id,
|
"run_id": run.id,
|
||||||
"group_id": run.group_id,
|
|
||||||
"agent_id": run.agent_id,
|
"agent_id": run.agent_id,
|
||||||
"status": run.status.as_str(),
|
"status": run.status.as_str(),
|
||||||
"mode": run.mode.as_str(),
|
"mode": run.mode.as_str(),
|
||||||
|
|||||||
@ -86,10 +86,6 @@ impl Tool for BashTool {
|
|||||||
"bash"
|
"bash"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
|
||||||
crate::tools::DelegationPolicy::Delegatable
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Execute a bash shell command and return its output. Use with caution."
|
"Execute a bash shell command and return its output. Use with caution."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,10 +51,6 @@ impl BrowserTool {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for BrowserTool {
|
impl Tool for BrowserTool {
|
||||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
|
||||||
crate::tools::DelegationPolicy::Delegatable
|
|
||||||
}
|
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"browser"
|
"browser"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,10 +18,6 @@ impl Default for CalculatorTool {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for CalculatorTool {
|
impl Tool for CalculatorTool {
|
||||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
|
||||||
crate::tools::DelegationPolicy::Delegatable
|
|
||||||
}
|
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"calculator"
|
"calculator"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,10 +51,6 @@ impl Default for ContentSearchTool {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for ContentSearchTool {
|
impl Tool for ContentSearchTool {
|
||||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
|
||||||
crate::tools::DelegationPolicy::Delegatable
|
|
||||||
}
|
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"content_search"
|
"content_search"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,8 +47,8 @@ impl Tool for ScopedDelegateTool {
|
|||||||
self.inner.read_only()
|
self.inner.read_only()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
fn runtime_injected(&self) -> bool {
|
||||||
crate::tools::DelegationPolicy::RuntimeInjected
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||||
@ -155,7 +155,11 @@ impl DelegateTool {
|
|||||||
args: &Value,
|
args: &Value,
|
||||||
context: &ToolExecutionContext,
|
context: &ToolExecutionContext,
|
||||||
) -> anyhow::Result<ToolResult> {
|
) -> anyhow::Result<ToolResult> {
|
||||||
let mode = match args.get("mode").and_then(Value::as_str).unwrap_or("foreground") {
|
let mode = match args
|
||||||
|
.get("mode")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("foreground")
|
||||||
|
{
|
||||||
"foreground" => ExecutionMode::Foreground,
|
"foreground" => ExecutionMode::Foreground,
|
||||||
"background" => ExecutionMode::Background,
|
"background" => ExecutionMode::Background,
|
||||||
other => {
|
other => {
|
||||||
@ -250,11 +254,6 @@ impl DelegateTool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
ExecutionMode::Background => {
|
ExecutionMode::Background => {
|
||||||
if configs.len() != 1 {
|
|
||||||
return Ok(failure(
|
|
||||||
"background batches require durable group admission and are not available yet",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if context.agent.is_some() {
|
if context.agent.is_some() {
|
||||||
return Ok(failure(
|
return Ok(failure(
|
||||||
"child Agents cannot create background runs in the current implementation",
|
"child Agents cannot create background runs in the current implementation",
|
||||||
@ -265,18 +264,23 @@ impl DelegateTool {
|
|||||||
"delegate requires agent_orchestration to be enabled (named Agents only)",
|
"delegate requires agent_orchestration to be enabled (named Agents only)",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let config = configs.into_iter().next().expect("checked non-empty");
|
match coordinator.delegate_background(context, configs).await {
|
||||||
match coordinator.delegate_background(context, config).await {
|
Ok(admission) => {
|
||||||
Ok(run_id) => Ok(success(json!({
|
let runs: Vec<_> = admission
|
||||||
|
.run_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|run_id| json!({ "run_id": run_id, "status": "queued" }))
|
||||||
|
.collect();
|
||||||
|
Ok(success(json!({
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"runs": vec![json!({ "run_id": run_id, "status": "queued" })]
|
"runs": runs
|
||||||
}))),
|
})))
|
||||||
|
}
|
||||||
Err(error) => Ok(failure(error.to_string())),
|
Err(error) => Ok(failure(error.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -327,8 +331,8 @@ impl Tool for DelegateTool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
fn runtime_injected(&self) -> bool {
|
||||||
crate::tools::DelegationPolicy::RuntimeInjected
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ use crate::agent::coordinator::AgentCoordinator;
|
|||||||
use crate::agent::definition::SignalContract;
|
use crate::agent::definition::SignalContract;
|
||||||
use crate::agent::run::{AgentExecutionContext, EmittedSignal};
|
use crate::agent::run::{AgentExecutionContext, EmittedSignal};
|
||||||
use crate::storage::agent_inbox::{AgentEventDelivery, AgentEventType, NewInboxEvent};
|
use crate::storage::agent_inbox::{AgentEventDelivery, AgentEventType, NewInboxEvent};
|
||||||
use crate::tools::{DelegationPolicy, Tool, ToolExecutionContext, ToolOutput, ToolResult};
|
use crate::tools::{Tool, ToolExecutionContext, ToolOutput, ToolResult};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SignalInput {
|
pub struct SignalInput {
|
||||||
@ -117,8 +117,8 @@ impl Tool for EmitSignalTool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delegation_policy(&self) -> DelegationPolicy {
|
fn runtime_injected(&self) -> bool {
|
||||||
DelegationPolicy::RuntimeInjected
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_with_context(
|
async fn execute_with_context(
|
||||||
@ -302,10 +302,7 @@ pub fn build_signal_event(
|
|||||||
NewInboxEvent {
|
NewInboxEvent {
|
||||||
id: event_id,
|
id: event_id,
|
||||||
root_session_id: context.root_session_id.clone(),
|
root_session_id: context.root_session_id.clone(),
|
||||||
scope_kind: "run".to_string(),
|
|
||||||
scope_id: context.run_id.clone(),
|
|
||||||
run_id: Some(context.run_id.clone()),
|
run_id: Some(context.run_id.clone()),
|
||||||
group_id: context.group_id.clone(),
|
|
||||||
event_type: AgentEventType::Signal,
|
event_type: AgentEventType::Signal,
|
||||||
event_key: input.event_key.clone(),
|
event_key: input.event_key.clone(),
|
||||||
delivery,
|
delivery,
|
||||||
|
|||||||
@ -36,10 +36,6 @@ impl Default for FileReadTool {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for FileReadTool {
|
impl Tool for FileReadTool {
|
||||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
|
||||||
crate::tools::DelegationPolicy::Delegatable
|
|
||||||
}
|
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"file_read"
|
"file_read"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,10 +51,6 @@ impl Default for FileSearchTool {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for FileSearchTool {
|
impl Tool for FileSearchTool {
|
||||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
|
||||||
crate::tools::DelegationPolicy::Delegatable
|
|
||||||
}
|
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"file_search"
|
"file_search"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,8 +44,8 @@ impl GetSkillTool {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for GetSkillTool {
|
impl Tool for GetSkillTool {
|
||||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
fn runtime_injected(&self) -> bool {
|
||||||
crate::tools::DelegationPolicy::RuntimeInjected
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
|
|||||||
@ -52,9 +52,9 @@ pub use send_message::SendMessageTool;
|
|||||||
pub use sleep::SleepTool;
|
pub use sleep::SleepTool;
|
||||||
pub use todo::TodoTool;
|
pub use todo::TodoTool;
|
||||||
pub use traits::{
|
pub use traits::{
|
||||||
DelegationPolicy, InputInterruptPolicy, OutboundDelivery, OutboundMessenger,
|
InputInterruptPolicy, OutboundDelivery, OutboundMessenger, ProcessedToolOutput, Tool,
|
||||||
ProcessedToolOutput, Tool, ToolArtifact, ToolArtifactAudience, ToolExecutionContext,
|
ToolArtifact, ToolArtifactAudience, ToolExecutionContext, ToolOutput, ToolOutputProcessor,
|
||||||
ToolOutput, ToolOutputProcessor, ToolResult,
|
ToolResult,
|
||||||
};
|
};
|
||||||
pub use web_fetch::WebFetchTool;
|
pub use web_fetch::WebFetchTool;
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use crate::providers::{Tool, ToolFunction};
|
use crate::providers::{Tool, ToolFunction};
|
||||||
|
|
||||||
use super::traits::DelegationPolicy;
|
|
||||||
use super::traits::Tool as ToolTrait;
|
use super::traits::Tool as ToolTrait;
|
||||||
|
|
||||||
pub struct ToolRegistry {
|
pub struct ToolRegistry {
|
||||||
@ -101,15 +100,12 @@ impl ToolRegistry {
|
|||||||
let tool = self
|
let tool = self
|
||||||
.get(name)
|
.get(name)
|
||||||
.ok_or_else(|| format!("tool '{name}' is not registered"))?;
|
.ok_or_else(|| format!("tool '{name}' is not registered"))?;
|
||||||
if tool.delegation_policy() != DelegationPolicy::Delegatable {
|
|
||||||
return Err(format!("tool '{name}' is not delegatable"));
|
|
||||||
}
|
|
||||||
scoped.register_raw(name.clone(), tool);
|
scoped.register_raw(name.clone(), tool);
|
||||||
}
|
}
|
||||||
for tool in runtime_tools {
|
for tool in runtime_tools {
|
||||||
if tool.delegation_policy() != DelegationPolicy::RuntimeInjected {
|
if !tool.runtime_injected() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"runtime tool '{}' is missing RuntimeInjected policy",
|
"runtime tool '{}' is missing the runtime-injected marker",
|
||||||
tool.name()
|
tool.name()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -135,7 +135,6 @@ target_chat_id 支持两种格式:<channel>:<chat_id>(发送到该聊天下
|
|||||||
task_id: None,
|
task_id: None,
|
||||||
from_run_id: None,
|
from_run_id: None,
|
||||||
from_agent_id: None,
|
from_agent_id: None,
|
||||||
group_id: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Parse files into MediaItems
|
// 3. Parse files into MediaItems
|
||||||
|
|||||||
@ -36,10 +36,6 @@ fn parse_seconds(args: &serde_json::Value) -> Result<u64, String> {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for SleepTool {
|
impl Tool for SleepTool {
|
||||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
|
||||||
crate::tools::DelegationPolicy::Delegatable
|
|
||||||
}
|
|
||||||
|
|
||||||
fn input_interrupt_policy(&self) -> crate::tools::InputInterruptPolicy {
|
fn input_interrupt_policy(&self) -> crate::tools::InputInterruptPolicy {
|
||||||
crate::tools::InputInterruptPolicy::WakeOnly
|
crate::tools::InputInterruptPolicy::WakeOnly
|
||||||
}
|
}
|
||||||
@ -196,11 +192,6 @@ fn wake_message(state: &TurnWakeupState, waited: std::time::Duration, planned: u
|
|||||||
" 收到一条 steer AgentCompletion(run_id={run_id}, agent={agent_id}),将在当前 Turn 的下一个安全边界注入。"
|
" 收到一条 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 => {
|
Some(WakeupSource::AgentQueue) | None => {
|
||||||
message.push_str(&format!(
|
message.push_str(&format!(
|
||||||
" 收到 {} 条排队输入。内容不会进入当前 Turn,将在当前工作结束后的下一 Turn处理。",
|
" 收到 {} 条排队输入。内容不会进入当前 Turn,将在当前工作结束后的下一 Turn处理。",
|
||||||
|
|||||||
@ -73,13 +73,6 @@ impl ToolExecutionContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum DelegationPolicy {
|
|
||||||
RootOnly,
|
|
||||||
Delegatable,
|
|
||||||
RuntimeInjected,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum InputInterruptPolicy {
|
pub enum InputInterruptPolicy {
|
||||||
Never,
|
Never,
|
||||||
@ -218,10 +211,14 @@ pub trait Tool: Send + Sync + 'static {
|
|||||||
fn parameters_schema(&self) -> serde_json::Value;
|
fn parameters_schema(&self) -> serde_json::Value;
|
||||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
|
||||||
|
|
||||||
/// Whether a named Agent definition may receive this tool. New tools fail
|
/// Whether this tool is injected at runtime from the caller context
|
||||||
/// closed until their delegated behavior has been reviewed explicitly.
|
/// (delegate targets, signal contract, skill allowlist) and therefore
|
||||||
fn delegation_policy(&self) -> DelegationPolicy {
|
/// must never be declared directly in an Agent definition's `tools`
|
||||||
DelegationPolicy::RootOnly
|
/// list. Every ordinary tool returns false: which tools a named Agent
|
||||||
|
/// receives is decided solely by its definition file, not by tool-side
|
||||||
|
/// delegation flags.
|
||||||
|
fn runtime_injected(&self) -> bool {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether new Turn input may interrupt an in-flight invocation.
|
/// Whether new Turn input may interrupt an in-flight invocation.
|
||||||
|
|||||||
@ -331,10 +331,6 @@ fn is_private_ip(ip: &std::net::IpAddr) -> bool {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for WebFetchTool {
|
impl Tool for WebFetchTool {
|
||||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
|
||||||
crate::tools::DelegationPolicy::Delegatable
|
|
||||||
}
|
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"web_fetch"
|
"web_fetch"
|
||||||
}
|
}
|
||||||
|
|||||||
8
webui/package-lock.json
generated
8
webui/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"version": "1.8.0",
|
"version": "1.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"version": "1.8.0",
|
"version": "1.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bits-ui": "^2.0.0",
|
"bits-ui": "^2.0.0",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.4.12",
|
||||||
@ -469,7 +469,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.8.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@floating-ui/core/-/core-1.8.0.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@floating-ui/core/-/core-1.8.0.tgz",
|
||||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -478,7 +478,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/dom": {
|
"node_modules/@floating-ui/dom": {
|
||||||
"version": "1.8.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@floating-ui/dom/-/dom-1.8.0.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.0",
|
"version": "1.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
import ActivitySpine from "./lib/components/ActivitySpine.svelte";
|
import ActivitySpine from "./lib/components/ActivitySpine.svelte";
|
||||||
import ChatPage from "./pages/ChatPage.svelte";
|
import ChatPage from "./pages/ChatPage.svelte";
|
||||||
import TasksPage from "./pages/TasksPage.svelte";
|
import TasksPage from "./pages/TasksPage.svelte";
|
||||||
|
import AgentsPage from "./pages/AgentsPage.svelte";
|
||||||
import MemoryPage from "./pages/MemoryPage.svelte";
|
import MemoryPage from "./pages/MemoryPage.svelte";
|
||||||
import LogsPage from "./pages/LogsPage.svelte";
|
import LogsPage from "./pages/LogsPage.svelte";
|
||||||
import SettingsPage from "./pages/SettingsPage.svelte";
|
import SettingsPage from "./pages/SettingsPage.svelte";
|
||||||
@ -20,6 +21,7 @@
|
|||||||
{ name: "chat", label: "聊天", description: "与 PicoBot 协作并管理会话" },
|
{ name: "chat", label: "聊天", description: "与 PicoBot 协作并管理会话" },
|
||||||
{ name: "overview", label: "概览", description: "查看运行状态与系统容量" },
|
{ name: "overview", label: "概览", description: "查看运行状态与系统容量" },
|
||||||
{ name: "tools", label: "工具", description: "浏览工具、Skills 与 MCP 连接" },
|
{ name: "tools", label: "工具", description: "浏览工具、Skills 与 MCP 连接" },
|
||||||
|
{ name: "agents", label: "子代理", description: "管理具名子代理定义" },
|
||||||
{ name: "logs", label: "日志", description: "检查实时事件与运行记录" },
|
{ name: "logs", label: "日志", description: "检查实时事件与运行记录" },
|
||||||
{ name: "memory", label: "记忆", description: "查找和维护长期记忆" },
|
{ name: "memory", label: "记忆", description: "查找和维护长期记忆" },
|
||||||
{ name: "tasks", label: "任务", description: "跟踪定时任务与后台工作" },
|
{ name: "tasks", label: "任务", description: "跟踪定时任务与后台工作" },
|
||||||
@ -29,6 +31,7 @@
|
|||||||
chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||||
overview: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
overview: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
||||||
tools: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
|
tools: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
|
||||||
|
agents: '<rect x="4" y="7" width="16" height="12" rx="2"/><path d="M12 7V4"/><path d="M8 4h8"/><circle cx="9" cy="13" r="1"/><circle cx="15" cy="13" r="1"/>',
|
||||||
logs: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
|
logs: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
|
||||||
memory: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>',
|
memory: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>',
|
||||||
tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||||
@ -150,6 +153,7 @@
|
|||||||
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
|
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
|
||||||
{:else if current === "overview"}<OverviewPage />
|
{:else if current === "overview"}<OverviewPage />
|
||||||
{:else if current === "tools"}<ToolsPage />
|
{:else if current === "tools"}<ToolsPage />
|
||||||
|
{:else if current === "agents"}<AgentsPage notify={(text, error) => toast.show(text, error)} />
|
||||||
{:else}<div class="empty-card">即将上线</div>{/if}
|
{:else}<div class="empty-card">即将上线</div>{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
306
webui/src/pages/AgentsPage.svelte
Normal file
306
webui/src/pages/AgentsPage.svelte
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { api } from "../lib/api.js";
|
||||||
|
import Icon from "../lib/Icon.svelte";
|
||||||
|
import StatusBadge from "../lib/StatusBadge.svelte";
|
||||||
|
|
||||||
|
let agents = $state([]);
|
||||||
|
let options = $state({ providers: [], models: [], tools: [], skills: [] });
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state("");
|
||||||
|
let editing = $state(null);
|
||||||
|
let saving = $state(false);
|
||||||
|
let { notify } = $props();
|
||||||
|
|
||||||
|
const blank = () => ({
|
||||||
|
id: "",
|
||||||
|
description: "",
|
||||||
|
provider: "",
|
||||||
|
model: "",
|
||||||
|
token_limit: null,
|
||||||
|
max_tool_iterations: null,
|
||||||
|
tools: [],
|
||||||
|
skills: [],
|
||||||
|
delegates: [],
|
||||||
|
role_prompt: "",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const [a, o] = await Promise.all([
|
||||||
|
api("/api/agents"),
|
||||||
|
api("/api/agents/options"),
|
||||||
|
]);
|
||||||
|
agents = a.agents || [];
|
||||||
|
options = o;
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTool(list, name) {
|
||||||
|
const i = list.indexOf(name);
|
||||||
|
if (i >= 0) list.splice(i, 1);
|
||||||
|
else list.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNew() {
|
||||||
|
editing = blank();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editAgent(agent) {
|
||||||
|
editing = {
|
||||||
|
id: agent.id,
|
||||||
|
description: agent.description || "",
|
||||||
|
provider: agent.provider || "",
|
||||||
|
model: agent.model || "",
|
||||||
|
token_limit: agent.token_limit ?? null,
|
||||||
|
max_tool_iterations: agent.max_tool_iterations ?? null,
|
||||||
|
tools: [...(agent.tools || [])],
|
||||||
|
skills: [...(agent.skills || [])],
|
||||||
|
delegates: [...(agent.delegates || [])],
|
||||||
|
role_prompt: agent.role_prompt || "",
|
||||||
|
enabled: agent.enabled !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!editing.id.trim()) {
|
||||||
|
notify("请填写 Agent ID", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editing.description.trim()) {
|
||||||
|
notify("请填写描述", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editing.role_prompt.trim()) {
|
||||||
|
notify("请填写角色正文(role)", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editing.provider || !editing.model) {
|
||||||
|
notify("请选择 provider 和 model", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id: editing.id,
|
||||||
|
description: editing.description,
|
||||||
|
provider: editing.provider || null,
|
||||||
|
model: editing.model || null,
|
||||||
|
token_limit: editing.token_limit,
|
||||||
|
max_tool_iterations: editing.max_tool_iterations,
|
||||||
|
tools: editing.tools,
|
||||||
|
skills: editing.skills,
|
||||||
|
delegates: editing.delegates,
|
||||||
|
role_prompt: editing.role_prompt,
|
||||||
|
enabled: editing.enabled,
|
||||||
|
};
|
||||||
|
await api("/api/agents", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
editing = null;
|
||||||
|
notify("子代理已保存(需重载配置生效)");
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
notify(caught.message, true);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(agent) {
|
||||||
|
try {
|
||||||
|
await api("/api/agents", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: agent.id,
|
||||||
|
description: agent.description || "",
|
||||||
|
provider: agent.provider || null,
|
||||||
|
model: agent.model || null,
|
||||||
|
token_limit: agent.token_limit ?? null,
|
||||||
|
max_tool_iterations: agent.max_tool_iterations ?? null,
|
||||||
|
tools: agent.tools || [],
|
||||||
|
skills: agent.skills || [],
|
||||||
|
delegates: agent.delegates || [],
|
||||||
|
role_prompt: agent.role_prompt || "",
|
||||||
|
enabled: !agent.enabled,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
agent.enabled = !agent.enabled;
|
||||||
|
notify(agent.enabled ? "已启用" : "已禁用");
|
||||||
|
} catch (caught) {
|
||||||
|
notify(caught.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(agent) {
|
||||||
|
if (!confirm(`确定删除子代理「${agent.id}」吗?`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/agents/${encodeURIComponent(agent.id)}`, { method: "DELETE" });
|
||||||
|
notify("已删除");
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
notify(caught.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolDesc(name) {
|
||||||
|
const tool = options.tools.find((t) => t.name === name);
|
||||||
|
return tool?.description || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="page active content-page">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0">具名子代理</h2>
|
||||||
|
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">
|
||||||
|
子代理由 <code>~/.picobot/agents/*.md</code> 定义;工具、Skill、Provider 与模型在此直接指定。改动需热重载后生效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="primary" onclick={startNew}><Icon name="add" size={16} />新增子代理</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">加载中…</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="empty-card error-text">{error}</div>
|
||||||
|
{:else if agents.length === 0}
|
||||||
|
<div class="empty-card">暂无子代理定义</div>
|
||||||
|
{:else}
|
||||||
|
<div class="cards">
|
||||||
|
{#each agents as agent (agent.id)}
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-row">
|
||||||
|
<div>
|
||||||
|
<h3>{agent.id} <StatusBadge status={agent.enabled ? "enabled" : "disabled"} /></h3>
|
||||||
|
<p>{agent.description}</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span>provider: {agent.provider || agent.llm_profile || "—"}</span>
|
||||||
|
<span>model: {agent.model || "—"}</span>
|
||||||
|
{#if agent.tools?.length}<span>{agent.tools.length} 个工具</span>{/if}
|
||||||
|
{#if agent.skills?.length}<span>{agent.skills.length} 个 Skill</span>{/if}
|
||||||
|
{#if agent.delegates?.length}<span>委托: {agent.delegates.join(", ")}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{#if agent.tools?.length}
|
||||||
|
<div class="tag-row">
|
||||||
|
{#each agent.tools as tool (tool)}<span class="tag" title={toolDesc(tool)}>{tool}</span>{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="secondary" onclick={() => editAgent(agent)}><Icon name="more" size={15} />编辑</button>
|
||||||
|
<button class="switch" data-state={agent.enabled ? "checked" : "unchecked"} aria-label="启用/禁用" onclick={() => toggleEnabled(agent)}>
|
||||||
|
<span class="switch-thumb" data-state={agent.enabled ? "checked" : "unchecked"}></span>
|
||||||
|
</button>
|
||||||
|
<button class="icon-button danger" aria-label="删除" onclick={() => remove(agent)}><Icon name="dismiss" size={16} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editing}
|
||||||
|
<button type="button" class="modal-scrim" onclick={cancelEdit} aria-label="关闭" tabindex="-1"></button>
|
||||||
|
<div class="modal" role="dialog" aria-label="编辑子代理">
|
||||||
|
<div class="editor-head">
|
||||||
|
<div><strong>{editing.id ? `编辑 ${editing.id}` : "新增子代理"}</strong><small>保存后需热重载配置生效</small></div>
|
||||||
|
<button class="icon-button" aria-label="关闭" onclick={cancelEdit}><Icon name="dismiss" size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<div class="agent-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<label>ID
|
||||||
|
<input bind:value={editing.id} placeholder="general-purpose" disabled={!!agents.find((a) => a.id === editing.id)} spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label>描述
|
||||||
|
<input bind:value={editing.description} placeholder="通用目的子代理…" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Provider
|
||||||
|
<select bind:value={editing.provider}>
|
||||||
|
<option value="">(选择)</option>
|
||||||
|
{#each options.providers as p (p)}<option value={p}>{p}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Model
|
||||||
|
<select bind:value={editing.model}>
|
||||||
|
<option value="">(选择)</option>
|
||||||
|
{#each options.models as m (m.name)}<option value={m.name}>{m.name}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>token_limit
|
||||||
|
<input type="number" bind:value={editing.token_limit} placeholder="128000" />
|
||||||
|
</label>
|
||||||
|
<label>max_tool_iterations
|
||||||
|
<input type="number" bind:value={editing.max_tool_iterations} placeholder="99" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-label">工具 <small>(普通工具可直接启用;delegate / emit_signal / agent_task 由运行上下文注入)</small></div>
|
||||||
|
<div class="tag-row selectable">
|
||||||
|
{#each options.tools as tool (tool.name)}
|
||||||
|
<button class="tag pick" class:picked={editing.tools.includes(tool.name)} title={tool.description} onclick={() => toggleTool(editing.tools, tool.name)}>{tool.name}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-label">Skills <small>(需要工具集中包含 get_skill)</small></div>
|
||||||
|
<div class="tag-row selectable">
|
||||||
|
{#each options.skills as skill (skill)}
|
||||||
|
<button class="tag pick" class:picked={editing.skills.includes(skill)} onclick={() => toggleTool(editing.skills, skill)}>{skill}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-label">可委托的目标代理 <small>(子代理可继续委托给这些代理)</small></div>
|
||||||
|
<div class="tag-row selectable">
|
||||||
|
{#each agents.filter((a) => a.id !== editing.id) as agent (agent.id)}
|
||||||
|
<button class="tag pick" class:picked={editing.delegates.includes(agent.id)} onclick={() => toggleTool(editing.delegates, agent.id)}>{agent.id}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-label">角色正文</div>
|
||||||
|
<textarea bind:value={editing.role_prompt} placeholder="# Role 你是一名…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button class="secondary" onclick={cancelEdit} disabled={saving}>取消</button>
|
||||||
|
<button class="primary" onclick={save} disabled={saving}>{saving ? "保存中…" : "保存"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||||
|
.tag { padding: 2px 8px; border: 1px solid var(--line); border-radius: 4px; color: var(--text-soft); background: var(--code-bg); font-size: 11px; font-family: var(--font-mono); }
|
||||||
|
.tag-row.selectable .tag { cursor: pointer; user-select: none; }
|
||||||
|
.tag-row.selectable .tag.picked { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.card-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.icon-button.danger:hover { color: var(--danger); border-color: var(--danger-border); }
|
||||||
|
.modal-scrim { position: fixed; inset: 0; z-index: 40; border: 0; padding: 0; cursor: default; background: rgba(0,0,0,.45); }
|
||||||
|
.modal { position: fixed; z-index: 41; top: 6vh; left: 50%; transform: translateX(-50%); width: min(720px, 94vw); max-height: 88vh; overflow: auto; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); box-shadow: var(--shadow-16, var(--shadow-8)); }
|
||||||
|
.editor-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--line); }
|
||||||
|
.editor-head strong { display: block; font-size: 15px; }
|
||||||
|
.editor-head small { color: var(--muted); font-size: 11px; }
|
||||||
|
.agent-form { display: grid; gap: 14px; padding: 18px; }
|
||||||
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; }
|
||||||
|
input, select, textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--text); background: var(--panel-2); font-size: 13px; }
|
||||||
|
input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
|
||||||
|
textarea { min-height: 140px; resize: vertical; font-family: var(--font-mono); line-height: 1.6; }
|
||||||
|
.form-label { color: var(--muted); font-size: 12px; font-weight: 600; }
|
||||||
|
.form-label small { font-weight: 400; color: var(--muted); }
|
||||||
|
.editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 18px; border-top: 1px solid var(--line); }
|
||||||
|
</style>
|
||||||
@ -70,32 +70,6 @@
|
|||||||
return "var(--danger)";
|
return "var(--danger)";
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupTasks(all) {
|
|
||||||
const groups = [];
|
|
||||||
const byGroup = new Map();
|
|
||||||
const roots = [];
|
|
||||||
for (const task of all) {
|
|
||||||
if (task.source === "legacy_background_task") {
|
|
||||||
groups.push({ key: `legacy-${task.id}`, title: task.prompt.slice(0, 100), status: task.status, runs: [task] });
|
|
||||||
} else if (task.group_id) {
|
|
||||||
if (!byGroup.has(task.group_id)) {
|
|
||||||
byGroup.set(task.group_id, { key: `group-${task.group_id}`, title: `任务组 ${task.group_id.slice(0, 8)}`, status: task.status, runs: [] });
|
|
||||||
groups.push(byGroup.get(task.group_id));
|
|
||||||
}
|
|
||||||
byGroup.get(task.group_id).runs.push(task);
|
|
||||||
} else {
|
|
||||||
roots.push({ key: `run-${task.id}`, title: task.prompt.slice(0, 100), status: task.status, runs: [task] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const group of byGroup.values()) {
|
|
||||||
group.runs.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
|
|
||||||
group.status = group.runs.find((task) => task.status === "running")?.status || group.runs[0]?.status || "pending";
|
|
||||||
}
|
|
||||||
groups.push(...roots);
|
|
||||||
groups.sort((a, b) => (b.runs[0]?.created_at || 0) - (a.runs[0]?.created_at || 0));
|
|
||||||
return groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
load();
|
load();
|
||||||
const timer = setInterval(() => { tick += 1; }, 30000);
|
const timer = setInterval(() => { tick += 1; }, 30000);
|
||||||
@ -117,40 +91,28 @@
|
|||||||
{#if loading}<div class="loading">加载中…</div>
|
{#if loading}<div class="loading">加载中…</div>
|
||||||
{:else if error}<div class="empty-card error-text">{error}</div>
|
{:else if error}<div class="empty-card error-text">{error}</div>
|
||||||
{:else if tab === "background"}
|
{:else if tab === "background"}
|
||||||
{#each groupTasks(tasks) as group (group.key)}
|
{#each tasks as task (task.id)}
|
||||||
{#if group.runs.length === 0}
|
|
||||||
<div class="empty-card">暂无后台任务</div>
|
|
||||||
{:else}
|
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-row">
|
<div class="card-row">
|
||||||
<div>
|
<div class="task-main">
|
||||||
<h3>{group.title}</h3>
|
|
||||||
<div class="meta">
|
|
||||||
<span>{group.runs[0].session_id}</span>
|
|
||||||
<span>{formatTime(group.runs[0].created_at)}</span>
|
|
||||||
{#if group.runs[0].status === "running"}<span class="elapsed">{elapsed(group.runs[0].created_at)}</span>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={group.status} />
|
|
||||||
</div>
|
|
||||||
<div class="run-tree">
|
|
||||||
{#each group.runs as task}
|
|
||||||
<div class="run-node" style="--depth:{Math.min(task.depth ?? 1, 6)}">
|
|
||||||
<div class="run-row">
|
<div class="run-row">
|
||||||
<span class="pulse" class:visible={task.status === "running"}></span>
|
<span class="pulse" class:visible={task.status === "running"}></span>
|
||||||
<span class="agent-tag">{task.agent_id || "general"}</span>
|
<span class="agent-tag">{task.agent_id || "general"}</span>
|
||||||
<span class="run-prompt">{task.prompt.slice(0, 80)}</span>
|
<span class="run-prompt">{task.prompt?.slice(0, 120) || ""}</span>
|
||||||
<StatusBadge status={task.status} />
|
<StatusBadge status={task.status} />
|
||||||
<span class="meta">{task.tool_calls_count} 次工具调用 · {task.iterations} 轮</span>
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span>{task.session_id}</span>
|
||||||
|
<span>{formatTime(task.created_at)}</span>
|
||||||
|
{#if task.status === "running"}<span class="elapsed">{elapsed(task.created_at)}</span>{/if}
|
||||||
|
<span>{task.tool_calls_count} 次工具调用 · {task.iterations} 轮</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if task.result}<div class="details"><p>{task.result.slice(0, 300)}</p></div>{/if}
|
{#if task.result}<div class="details"><p>{task.result.slice(0, 300)}</p></div>{/if}
|
||||||
{#if task.error}<p class="error-text">{task.error.slice(0, 200)}</p>{/if}
|
{#if task.error}<p class="error-text">{task.error.slice(0, 200)}</p>{/if}
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
{/if}
|
{:else}<div class="empty-card">暂无后台任务</div>{/each}
|
||||||
{/each}
|
|
||||||
{:else}
|
{:else}
|
||||||
{#each jobs as job (job.id)}
|
{#each jobs as job (job.id)}
|
||||||
<article class="card">
|
<article class="card">
|
||||||
@ -186,8 +148,7 @@
|
|||||||
.cron { font-size: 12px; color: var(--text-soft); background: var(--code-bg); padding: 1px 6px; border-radius: 4px; border: 1px solid var(--line); font-variant-numeric: tabular-nums; }
|
.cron { font-size: 12px; color: var(--text-soft); background: var(--code-bg); padding: 1px 6px; border-radius: 4px; border: 1px solid var(--line); font-variant-numeric: tabular-nums; }
|
||||||
.status-dots { display: flex; gap: 4px; margin-top: 6px; align-items: center; }
|
.status-dots { display: flex; gap: 4px; margin-top: 6px; align-items: center; }
|
||||||
.elapsed { color: var(--info); font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
|
.elapsed { color: var(--info); font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||||
.run-tree { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
|
.task-main { flex: 1; min-width: 0; }
|
||||||
.run-node { margin-left: calc(var(--depth) * 18px); border-left: 1px solid var(--line); padding-left: 10px; }
|
|
||||||
.run-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
.run-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
.agent-tag { font-size: 11px; color: var(--accent); background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; padding: 0 6px; font-family: var(--font-mono); }
|
.agent-tag { font-size: 11px; color: var(--accent); background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; padding: 0 6px; font-family: var(--font-mono); }
|
||||||
.run-prompt { flex: 1 1 200px; min-width: 120px; }
|
.run-prompt { flex: 1 1 200px; min-width: 120px; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user