feat: durable agent orchestration with run persistence, inbox continuation, and signal/steer
- AgentCatalog/definitions with strict Markdown frontmatter, delegation graph, fail-closed tool scoping, and signal contracts - structured cancellation (AgentError::Cancelled/TimedOut) across provider streams, tool batches, and sleep; /stop drives the same terminal state - schema v6 run/group/inbox persistence with execution-ID conditional transitions and completion-slot reservations - ExecutionGate separating run quota from provider/tool step permits - background completion inbox with hidden-trigger continuation turns, fairness scheduling, lease release, dead-lettering, and activation recovery - typed TurnMailbox with two-phase steer admission and atomic consumption at turn commit; /stop releases admitted steer events back to pending - emit_signal tool with contract-enforced rate/dedupe/severity/size limits - WS run/event projection (GetAgentRuns, AgentRunUpdated, AgentEventUpdated), /api/agent-runs* management endpoints, /api/tasks union, WebUI run tree and signal cards - ChannelContext.durable_private persisted for continuation delivery reuse Version 1.7.0
This commit is contained in:
parent
1acab7f890
commit
ac201a3949
@ -93,6 +93,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
||||
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context
|
||||
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
|
||||
- **AgentLoop** is stateless across turns; it receives prepared history, drains same-Turn steering only at safe model boundaries, calls LLM providers, executes tools, and returns one result
|
||||
- **AgentCatalog** is immutable per runtime generation; when orchestration is enabled, candidate preparation strictly validates trusted Markdown definitions, Provider profiles, delegated tools, Skill allowlists, and delegation edges before activation. Named Agents currently support foreground execution; durable named background delivery is not available until the run/inbox phase lands
|
||||
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
|
||||
- **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts
|
||||
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
|
||||
@ -105,6 +106,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
||||
- **Providers** are pure HTTP clients; no bus/session/channel awareness
|
||||
- **Provider reasoning state** is private replay data: persist it, replay it only to the matching provider, and never expose it to clients, channels, or logs
|
||||
- **Tools** are executed by `AgentLoop`; every invocation is normalized to `ToolOutput` and passes through `ToolOutputProcessor`. Plain `ToolResult` implementations use the default conversion, while artifact-producing tools declare model/user audience explicitly; model capability checks, final-reply attachment, channel delivery, and Provider serialization stay outside tools
|
||||
- **Delegated tool access** fails closed: new tools default to `RootOnly`; only code-reviewed `Delegatable` tools may appear in Agent Markdown, while `delegate` and scoped `get_skill` are runtime-injected. Call parameters cannot expand a named Agent's tool set
|
||||
- **Sleep tool** is a cancellable foreground wait bounded to 24 hours; longer or durable delays belong to Scheduler/background work, and cancelling a Turn must normalize active tool blocks to `Cancelled`
|
||||
- **Stateful tools** receive `ToolExecutionContext`; browser calls without `persistent_id` map each PicoBot dialog to an opaque transient agent-browser session. For long-running work an Agent may autonomously create a persistent identity and must pass its validated ID on every related action; the same ID shares one agent-browser session and serialization gate across dialogs, while different IDs have independent sessions/gates. `browser_profiles` may create IDs, persist bounded semantic labels, list, or delete only validated IDs beneath the configured profile root. Do not introduce a global persistence mode switch, a default persistent ID, automatic per-dialog persistent Profiles, Fantoccini, ChromeDriver, WebDriver, model-controlled raw agent-browser session IDs, or arbitrary Profile paths
|
||||
- **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, and `/health`; checks must not install/fix dependencies, call Provider APIs, or expose secrets
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "1.5.1"
|
||||
version = "1.7.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@ -8,6 +8,7 @@ reqwest = { version = "0.13.4", default-features = false, features = ["json", "r
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
regex = "1.13"
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
async-trait = "0.1"
|
||||
thiserror = "2.0.19"
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
|
||||
29
README.md
29
README.md
@ -343,7 +343,7 @@ PicoBot 有两类记忆:
|
||||
| `get_skill` | 列出或读取本地 Skill |
|
||||
| `memory_store` / `memory_recall` / `timeline_recall` / `memory_forget` | 长期记忆操作 |
|
||||
| `reload_config` | 在用户明确要求时校验并重新加载 Gateway 配置 |
|
||||
| `delegate` | 启动 inline、background 或 parallel 子 Agent |
|
||||
| `delegate` | 向具名 Agent 委托单个或批量任务;`foreground` 等待结果,`background` 异步执行。批量 foreground 会并发运行并按请求顺序聚合 |
|
||||
| `todo` | 为复杂、多轮任务创建并更新当前 session 的持久化计划 |
|
||||
| `send_message` | 向指定渠道或当前会话发送消息,可附带文件/截图;WebUI/TUI 当前 Turn 的附件并入最终回复 |
|
||||
| `chat_manager` | 查看渠道、会话和历史消息 |
|
||||
@ -373,6 +373,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
||||
| `providers` | LLM Provider 配置 |
|
||||
| `models` | 模型参数与输入能力 |
|
||||
| `agents` | Agent 使用哪个 provider/model |
|
||||
| `agent_orchestration` | 具名子 Agent 定义目录、Root 委托白名单和编排上限;默认关闭 |
|
||||
| `gateway` | HTTP/WebSocket、数据库、调度器、后台任务限制 |
|
||||
| `client` | CLI 客户端默认 Gateway URL |
|
||||
| `channels` | 渠道配置,目前主要是飞书/Lark |
|
||||
@ -403,6 +404,32 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
||||
| `channels.feishu.media_dir_max_bytes` | `536870912` |
|
||||
| `channels.feishu.request_timeout_secs` | `30` |
|
||||
|
||||
### 具名子 Agent(Phase 1)
|
||||
|
||||
启用 `agent_orchestration.enabled` 后,PicoBot 会在 Gateway 候选运行代构造时从配置目录下的 `definitions_dir` 加载 `*.md`。相对路径按 `config.json` 所在目录解析,且 canonical path 不得逃逸该目录;角色文件、Provider profile、工具、Skill 和委托边任一无效都会拒绝启动或热重载。第一阶段只开放具名 `foreground` 执行;具名 `background` 和 background 批量接纳会明确拒绝,直到 durable run/inbox 阶段完成。未启用时,旧 general 单任务 background 仍作为兼容路径存在。
|
||||
|
||||
```md
|
||||
---
|
||||
id: researcher
|
||||
description: 搜索、阅读并整理技术资料
|
||||
llm_profile: research
|
||||
tools:
|
||||
- file_read
|
||||
- file_search
|
||||
- content_search
|
||||
delegates:
|
||||
- reviewer
|
||||
limits:
|
||||
timeout_secs: 900
|
||||
max_iterations: 24
|
||||
---
|
||||
# Role
|
||||
|
||||
你是一名严谨的研究 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。
|
||||
|
||||
更完整的配置字段说明见 [resources/skills/about-picobot/references/config.md](resources/skills/about-picobot/references/config.md)。
|
||||
|
||||
## agent-browser 安装与使用
|
||||
|
||||
@ -206,7 +206,7 @@ Session ID 格式为:
|
||||
|
||||
SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。`session::turn_input` 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑,也不得丢失已经从 mailbox 取出的 steering。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态,但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。
|
||||
|
||||
当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`,不能另建字符串 notification 通道重复投递。后台子 Agent 的 `TaskNotification` 表达跨 Turn 的任务完成,仍由独立的受监督消费者投递。自动标题属于非关键派生工作:Turn 持久化完成后由 `TaskSupervisor` 调度,Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
|
||||
当前 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 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
|
||||
|
||||
每个 session 最多有一个 active plan,但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 不获得 `todo` 或 `delegate`。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
# PicoBot 子 Agent 编排与信号投递架构升级设计
|
||||
|
||||
> 状态:提案(2026-08)。
|
||||
> 状态:分阶段实施中(2026-08)。Phase 1 的具名 Definition/Catalog、Provider profile、工具与 Skill 裁剪、显式执行上下文、委托图校验和批量 foreground 已落地;durable run/inbox、signal/steer 与可唤醒 sleep 仍按本文后续阶段实施。
|
||||
>
|
||||
> 本文定义具名子 Agent、委托图、多 Provider、`delegate`、`emit_signal`、后台结果收件箱、`queue`/`steer` 投递以及可唤醒 `sleep` 的目标架构。本文描述待实现设计;在实现完成并通过测试前,当前行为仍以代码和 `docs/ARCHITECTURE.md` 为准。
|
||||
> 本文定义具名子 Agent、委托图、多 Provider、`delegate`、`emit_signal`、后台结果收件箱、`queue`/`steer` 投递以及可唤醒 `sleep` 的目标架构。各阶段是否已经实现以代码、测试和 `docs/ARCHITECTURE.md` 为准;未落地章节仍是目标设计。
|
||||
>
|
||||
> 2026-08 评审提出的 A1–A5、B1–B6、C1–C4 已纳入本文;逐项决策与理由见 [`SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md`](SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md),代码级落地方案与验收门槛见 [`SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md`](SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md)。
|
||||
|
||||
## 1. 背景与现状
|
||||
|
||||
@ -71,7 +73,7 @@ PicoBot 已经具备一版子 Agent 能力:根交互 Agent 通过 `delegate`
|
||||
| Queue | 输入属于后续 Turn;不改变当前 Turn 的模型上下文 |
|
||||
| Steer | 输入尝试进入当前 Turn,并在最近安全边界注入;失败时可靠退化为 queue |
|
||||
| Agent Signal | Background Agent 在运行中主动发出的非终态重要事件 |
|
||||
| Agent Completion | Agent Run 进入 completed/failed/timed_out/cancelled/interrupted 时由运行时自动产生的终态事件 |
|
||||
| Agent Completion | Agent Run 进入 completed/failed/timed_out/cancelled/interrupted 时由运行时自动产生的终态事实;background 按 group policy 投影为 run/group inbox event |
|
||||
| Agent Inbox | 持久化的主 Agent 内部收件箱,是 Background 结果与信号的权威来源 |
|
||||
| Turn Mailbox | 当前 Turn 接受 steer 输入的有界内存邮箱,保留来源、顺序和 durable event ID |
|
||||
|
||||
@ -132,11 +134,17 @@ tools:
|
||||
delegates:
|
||||
- reviewer
|
||||
|
||||
skills:
|
||||
- technical-research
|
||||
|
||||
limits:
|
||||
timeout_secs: 900
|
||||
max_iterations: 24
|
||||
max_children: 4
|
||||
max_depth: 3
|
||||
max_concurrent_runs: 2
|
||||
max_concurrent_provider_steps: 1
|
||||
max_concurrent_tool_steps: 4
|
||||
max_result_chars: 16000
|
||||
---
|
||||
|
||||
@ -152,28 +160,39 @@ limits:
|
||||
|
||||
Frontmatter 只保存非秘密引用和限制;API key、base URL、headers 继续保存在 `config.json`/`.env`。`llm_profile` 引用现有 `config.agents` key,由 `Config::get_provider_config()` 解析 Provider 与 Model。
|
||||
|
||||
`skills` 是可选的受信任 allowlist;只有 Definition 工具集包含 `get_skill` 时才向子 Agent注入这些 Skill。子 Agent不继承主会话临时启用的 Skill、完整历史或 memory recall。调用方需要传递的事实必须进入显式 task/context;未来若支持 memory,只能通过管理员配置的只读 scope 开放。
|
||||
|
||||
### 5.3 配置扩展
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_orchestration": {
|
||||
"definitions_dir": "~/.picobot/agents",
|
||||
"definitions_dir": "agents",
|
||||
"root_delegates": ["researcher", "coder", "reviewer"],
|
||||
"max_tree_depth": 4,
|
||||
"max_runs_per_tree": 16,
|
||||
"max_concurrent_runs": 6,
|
||||
"max_concurrent_runs_per_session": 4,
|
||||
"max_concurrent_provider_steps": 8,
|
||||
"max_concurrent_provider_steps_per_session": 4,
|
||||
"max_concurrent_tool_steps": 16,
|
||||
"max_concurrent_tool_steps_per_session": 8,
|
||||
"max_pending_inbox_events_per_session": 128,
|
||||
"inbox_event_ttl_hours": 168
|
||||
"inbox_event_ttl_hours": 168,
|
||||
"max_inbox_delivery_attempts": 8,
|
||||
"max_user_turn_burst_before_inbox": 4,
|
||||
"max_inbox_wait_secs": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`root_delegates` 是 Root Agent 的出边白名单。Root 不需要也不允许出现在 definitions 目录中。
|
||||
|
||||
`definitions_dir` 的相对路径按实际加载的 `config.json` 所在目录解析;第一版要求 canonical path 保持在该受信任配置目录内。默认值 `agents` 对应 `~/.picobot/config.json` 旁的 `~/.picobot/agents/`,也能让仓库内 fallback `./config.json` 使用同仓库配置目录而不跨越信任边界。
|
||||
|
||||
### 5.4 加载与校验
|
||||
|
||||
AgentCatalog 在 Gateway 候选运行代准备阶段完成全部校验:
|
||||
AgentCatalog 在 Gateway 候选运行代准备阶段完成全部校验,但候选代不得在此时扫描或修改 inbox/run 恢复状态;恢复扫描、旧 run 收敛和 Router 启动只能在候选代成为活动运行代后的 activation 阶段执行:
|
||||
|
||||
- 文件大小、UTF-8、frontmatter 格式和必填字段。
|
||||
- ID 格式、重复 ID、保留 ID(`ROOT`、`main` 等)。
|
||||
@ -182,10 +201,13 @@ AgentCatalog 在 Gateway 候选运行代准备阶段完成全部校验:
|
||||
- 每个 delegates 目标存在且不是 Root。
|
||||
- 限制值在系统硬上限内。
|
||||
- 角色正文和描述长度有界。
|
||||
- Skill allowlist 中的每个 ID 均存在,且只有允许 `get_skill` 的角色可以声明。
|
||||
- canonical path 位于允许目录,拒绝越界 symlink。
|
||||
|
||||
任一引用错误应拒绝候选运行代激活,而不是静默删除工具或委托边。AgentCatalog 以 `Arc` 固定在运行代中,已启动任务不读取修改后的文件。
|
||||
|
||||
第一版 Definition 只允许引用候选代准备阶段已经注册的 built-in 工具。MCP 连接按现有架构只能在 activation 阶段发生,无法在不产生外部副作用的候选代准备阶段完成严格校验,因此 MCP 工具委托暂不开放;未来需要先增加可离线校验的 MCP tool manifest,再扩展 Catalog。
|
||||
|
||||
## 6. 总体组件设计
|
||||
|
||||
```mermaid
|
||||
@ -203,7 +225,7 @@ flowchart LR
|
||||
ES --> DB[(agent_runs / agent_inbox_events)]
|
||||
DB --> RR[AgentResultRouter]
|
||||
RR --> SM[SessionManager]
|
||||
SM --> TM[TurnMailbox / Session Queue]
|
||||
SM --> TM[TurnMailbox / Inbox Wake + Worker Claim]
|
||||
TM --> Root
|
||||
SM --> DC[DeliveryCoordinator]
|
||||
```
|
||||
@ -220,8 +242,8 @@ flowchart LR
|
||||
- 创建 run/group ID、父子关系和预算。
|
||||
- 持久化接纳状态后启动 AgentRunner。
|
||||
- 管理 foreground await、background spawn、取消和超时。
|
||||
- 控制全局、session、Agent 与任务树并发。
|
||||
- 生成自动 completion event。
|
||||
- 控制全局、session、Agent 与任务树的 run admission quota,以及 Provider/普通工具步骤的 execution permit;两类配额不共用生命周期。
|
||||
- 生成自动 completion terminal outcome,并按 group policy 物化 inbox event。
|
||||
- 向 WorkManager 条件提交计划子项结果。
|
||||
|
||||
### 6.3 AgentRunner
|
||||
@ -237,11 +259,11 @@ flowchart LR
|
||||
|
||||
### 6.4 AgentEventSink / AgentResultRouter
|
||||
|
||||
`AgentEventSink` 负责持久化 signal/completion;`AgentResultRouter` 负责把 pending inbox event 送到原 root session。Router 的内存 wakeup 是加速器,SQLite inbox 才是权威来源。
|
||||
`AgentEventSink` 负责持久化 signal 和按 policy 生成的 run/group completion event;`AgentResultRouter` 负责把 pending inbox event 送到原 root session。Router 的内存 wakeup 是加速器,SQLite inbox 才是权威来源。
|
||||
|
||||
### 6.5 ProviderFactory
|
||||
|
||||
根据 `llm_profile` 创建 Provider,注入 Storage/Observer,并复用当前运行代的 workspace、input types、token limit 和价格信息。Provider 仍是纯 HTTP client,不感知 Session、Channel 或 Agent 图。
|
||||
根据 `llm_profile` 创建 Provider,注入 Storage/Observer,并复用当前运行代的 workspace、input types 和 token limit。Provider 仍是纯 HTTP client,不感知 Session、Channel 或 Agent 图。`cost` 只有在 profile 明确配置 input/output/cache 价格后才计算;当前没有价格来源时持久化为 `NULL`,不能根据模型名猜测价格。
|
||||
|
||||
## 7. 委托图与权限模型
|
||||
|
||||
@ -252,7 +274,8 @@ flowchart LR
|
||||
```text
|
||||
target != ROOT
|
||||
target in allowed_targets(caller)
|
||||
depth < effective_max_depth
|
||||
next_depth <= global max_tree_depth
|
||||
remaining_delegation_depth > 0
|
||||
tree_run_count < max_runs_per_tree
|
||||
target not in current_agent_ancestry
|
||||
remaining budget > 0
|
||||
@ -263,6 +286,8 @@ Root 的 allowed targets 来自 `root_delegates`;子 Agent 来自自身 Markdo
|
||||
|
||||
配置可以出现 A→B 和 B→A,允许两者在不同任务树中互相委托,但一个执行链默认禁止再次出现同一 Agent ID,从而拒绝 A→B→A 的递归乒乓。未来若需要受控返工循环,应设计显式 iteration workflow,而不是放开隐式递归。
|
||||
|
||||
全局 `max_tree_depth` 是 root-relative 硬上限;Definition 的 `limits.max_depth` 表示该 Agent可继续创建的最大相对后代深度。child context 的 remaining depth 取 `min(parent_remaining - 1, target_definition.max_depth)`,任一限制耗尽即拒绝继续委托。
|
||||
|
||||
### 7.2 工具权限
|
||||
|
||||
调用参数不再提供 `allowed_tools` 扩权。有效工具集为:
|
||||
@ -312,7 +337,7 @@ pub struct AgentExecutionContext {
|
||||
}
|
||||
```
|
||||
|
||||
`ToolExecutionContext` 扩展为包含可选 `AgentExecutionContext`、Turn wakeup handle 和资源 scope。`DelegateTool`、`EmitSignalTool` 必须实现 `execute_with_context`;权限判断不能依赖模型参数或仅依赖 Tokio task-local。
|
||||
`ToolExecutionContext` 扩展为包含可选 `AgentExecutionContext`、CancellationToken、execution gate、Turn wakeup handle 和资源 scope。Root interactive Agent 没有伪造的 run ID,其 `agent` 字段为 `None`,由 session/turn context 明确识别为 `ROOT`;sub-run 的 `agent` 字段必须为 `Some` 且 run ID 非空。Turn wakeup handle 只为 root interactive Turn 提供,sub-run 中为 `None`。`DelegateTool`、`EmitSignalTool` 必须实现 `execute_with_context`;权限判断不能依赖模型参数或仅依赖 Tokio task-local。
|
||||
|
||||
task-local 可以继续作为同一调用栈的便利桥接,但不是授权事实来源。Background spawn 必须显式复制所需上下文,不能假设 task-local 跨 `tokio::spawn` 传播。
|
||||
|
||||
@ -329,6 +354,8 @@ agent_task → get / list / cancel / get_result
|
||||
|
||||
较小、单一的 schema 能减少模型错误调用,也便于分别授权。
|
||||
|
||||
`agent_task` 的每次操作都必须同时校验 `root_session_id` 和调用者在任务树中的位置。Root 只能操作当前 session 的任务树;子 Agent只能读取自身及后代、取消未终态后代,不能访问祖先、sibling 或其他 session。run ID 不是授权凭证。
|
||||
|
||||
### 9.2 单任务请求
|
||||
|
||||
```json
|
||||
@ -409,7 +436,7 @@ Foreground 请求直接把 completion 作为 tool result 返回,因此不接
|
||||
|
||||
### 9.6 幂等与接纳
|
||||
|
||||
Background delegate 只有在 run/group 记录持久化成功、运行代 admission 成功且执行任务已经被 TaskSupervisor 接纳后才返回成功。可选 `idempotency_key` 在 `(root_session_id, caller_run_id, key)` 范围唯一,用于 Provider 重试时避免重复创建任务。
|
||||
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` 破坏去重。
|
||||
|
||||
## 10. Prompt 与上下文隔离
|
||||
|
||||
@ -434,7 +461,9 @@ PicoBot 基础运行规则
|
||||
resource_scope_id = root_session_id + run_id
|
||||
```
|
||||
|
||||
并行子 Agent 不默认共享 browser/session 等有状态外部资源;确需共享必须由工具定义显式支持。
|
||||
并行子 Agent 不默认共享 browser/session 等有状态外部资源。共享 browser 登录态的唯一正式路径是:Root 通过 `browser_profiles` 创建/选择经过校验的 persistent ID,把它作为显式 task/artifact reference 交给获准使用 browser 的子 Agent,子 Agent在每次相关调用中显式传入该 ID;瞬时父 session browser scope 不能隐式继承。
|
||||
|
||||
无 target 的旧委托映射出的内置 `general` 兼容 Agent可在弃用期保留父 session transient browser scope,并给出迁移提示;具名 Agent不继承这一兼容例外。
|
||||
|
||||
## 11. Agent Run 状态机
|
||||
|
||||
@ -475,9 +504,9 @@ stateDiagram-v2
|
||||
| 事件 | 产生者 | 是否终止 run | 用途 |
|
||||
|------|--------|--------------|------|
|
||||
| Signal | 子 Agent 主动调用 `emit_signal` | 否 | 重要中间状态、监控告警 |
|
||||
| Completion | AgentCoordinator 自动生成 | 是 | completed/failed/timed_out/cancelled/interrupted |
|
||||
| Completion outcome | AgentCoordinator 自动生成 | 是 | completed/failed/timed_out/cancelled/interrupted |
|
||||
|
||||
最终结果不能依赖模型记得调用工具。即使 Provider 异常、超时或任务被取消,Coordinator 也必须产生终态事件。
|
||||
最终结果不能依赖模型记得调用工具。即使 Provider 异常、超时或任务被取消,Coordinator 也必须持久化 run 的终态 outcome。Foreground 将其返回为 tool result;background `each` 将每个 outcome 物化为 run completion inbox event,`all` 只在 group 终态时物化一个 group completion inbox event。
|
||||
|
||||
### 12.2 EmitSignalTool
|
||||
|
||||
@ -515,11 +544,13 @@ Coordinator 强制执行:
|
||||
- summary/details 大小与 JSON 深度限制。
|
||||
- 只能投递到创建该 run 的 root session。
|
||||
|
||||
未提供 `dedupe_key` 时,每次调用使用 `signal:<event_uuid>` 作为非空 event key;提供 key 时使用 `signal:<normalized-key>:<cooldown-window-id>`,只在冷却窗口内去重,不能因数据库唯一约束永久压制同类告警。
|
||||
|
||||
普通进度不应滥用 signal。工具调用进度继续通过内部 Observer/TurnEvent 投影到 UI;只有需要主 Agent采取行动的事件才使用 `emit_signal`。
|
||||
|
||||
### 12.3 Completion 去重
|
||||
|
||||
Completion 包含本 run 已发出的 signal IDs。若最终总结重复某个信号,主 Agent可以识别并避免再次报告。正常 completion 可以配置 queue;关键 failure 可以配置 steer。禁止完全静默丢弃失败,`silent` 若未来开放也只能用于正常 completion。
|
||||
run completion payload 包含本 run 已发出的 signal IDs;group completion 则按 run 分组携带这些 IDs。若最终总结重复某个信号,主 Agent可以识别并避免再次报告。正常 completion 可以配置 queue;关键 failure 可以配置 steer。禁止完全静默丢弃失败,`silent` 若未来开放也只能用于正常 completion。
|
||||
|
||||
## 13. SendMessage、EmitSignal 与附件职责
|
||||
|
||||
@ -545,7 +576,7 @@ attach_artifact 工具产物 → 当前 Turn → DeliveryCoordinator(当前回
|
||||
|
||||
### 13.4 自动 completion
|
||||
|
||||
Completion 不是工具。AgentRunner 的终结路径统一保存结果并创建 event,避免模型遗漏。
|
||||
Completion 不是工具。AgentRunner 的终结路径统一返回 terminal outcome,Coordinator 保存结果并按 foreground/background 与 group policy 创建相应投递 event,避免模型遗漏或重复。
|
||||
|
||||
## 14. 持久化模型
|
||||
|
||||
@ -560,6 +591,8 @@ root_session_id TEXT NOT NULL
|
||||
root_turn_id TEXT
|
||||
parent_run_id TEXT
|
||||
caller_agent_id TEXT NOT NULL
|
||||
caller_scope_id TEXT NOT NULL
|
||||
idempotency_key TEXT
|
||||
agent_id TEXT NOT NULL
|
||||
definition_hash TEXT NOT NULL
|
||||
provider_profile TEXT NOT NULL
|
||||
@ -568,8 +601,15 @@ model_id TEXT NOT NULL
|
||||
mode TEXT NOT NULL
|
||||
depth INTEGER NOT NULL
|
||||
plan_item_id TEXT
|
||||
execution_id TEXT NOT NULL
|
||||
task TEXT NOT NULL
|
||||
context_json TEXT
|
||||
budget_json TEXT NOT NULL
|
||||
signal_contract_json TEXT
|
||||
signal_delivery TEXT
|
||||
completion_delivery TEXT
|
||||
failure_delivery TEXT
|
||||
deadline_at INTEGER NOT NULL
|
||||
status TEXT NOT NULL
|
||||
result TEXT
|
||||
error TEXT
|
||||
@ -578,14 +618,21 @@ completion_tokens INTEGER
|
||||
cost REAL
|
||||
tool_calls_count INTEGER NOT NULL DEFAULT 0
|
||||
iterations INTEGER NOT NULL DEFAULT 0
|
||||
runtime_generation TEXT NOT NULL
|
||||
runtime_generation INTEGER NOT NULL
|
||||
attempt INTEGER NOT NULL DEFAULT 1
|
||||
completion_slot_reserved INTEGER NOT NULL DEFAULT 0
|
||||
started_at INTEGER
|
||||
finished_at INTEGER
|
||||
created_at INTEGER NOT NULL
|
||||
```
|
||||
|
||||
不保存 API key、Authorization header、Provider 私有 reasoning state 或完整 connection URL。
|
||||
```sql
|
||||
CREATE UNIQUE INDEX agent_runs_idempotency
|
||||
ON agent_runs(root_session_id, caller_scope_id, idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
```
|
||||
|
||||
不保存 API key、Authorization header、Provider 私有 reasoning state 或完整 connection URL。`cost` 是 nullable projection:Provider profile 未配置价格时必须为 `NULL`,usage token 不受影响。
|
||||
|
||||
### 14.2 agent_run_groups
|
||||
|
||||
@ -595,17 +642,26 @@ 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
|
||||
```
|
||||
|
||||
批量 background 默认 `completion_policy=all`,等全部 run 进入终态或 group deadline 后只唤醒主 Agent一次。独立 run 可通过 300–500ms debounce 合并,避免连续启动多个内部 Turn。
|
||||
批量请求的 `idempotency_key` 绑定 group;其 child run 的 key 为 `NULL`。单任务请求没有 group 时,key 绑定 run。两者分别使用 `(root_session_id, caller_scope_id, idempotency_key)` partial unique index,避免批量 children 互相冲突。
|
||||
|
||||
批量 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/group 时按 policy 在 session inbox 配额中预留 completion slot:`each` 在每个 run 的 `completion_slot_reserved` 记一个,`all` 在 group 字段记一个。Storage 用同一写事务统计该 session 的 `pending/leased/admitted` 事件和有效 reservation,避免并发接纳越过上限;`consumed/dead_letter` 受 TTL 清理但不占 pending 配额。容量不足在创建 run 前拒绝;signal 只能使用未预留容量。预留在 completion 事务落库或接纳回滚时释放。
|
||||
|
||||
容量判断不能在每次接纳时通过无锁 `COUNT(*)` 推断。新增每 root session 一行的 `agent_session_state`,在同一 SQLite 写事务中以条件 `UPDATE` 维护 `pending_event_count`、`reserved_completion_slots` 和单调 `revision`。background 接纳先增加 reservation;signal 只有在 `pending + reserved < limit` 时增加 pending;completion 将 reservation 原子转换为 pending;consume/dead-letter 减少 pending。启动恢复会以事件与 run/group 事实重算计数,发现差异时修复并记录告警。
|
||||
|
||||
### 14.3 agent_inbox_events
|
||||
|
||||
@ -614,26 +670,40 @@ agent_inbox_events
|
||||
------------------
|
||||
id TEXT PRIMARY KEY
|
||||
root_session_id TEXT NOT NULL
|
||||
run_id TEXT NOT NULL
|
||||
scope_kind run | group
|
||||
scope_id TEXT NOT NULL
|
||||
run_id TEXT
|
||||
group_id TEXT
|
||||
event_type signal | completion
|
||||
event_type signal | completion | group_completion
|
||||
event_key TEXT NOT NULL
|
||||
delivery queue | steer
|
||||
requires_continuation BOOLEAN NOT NULL DEFAULT TRUE
|
||||
severity TEXT
|
||||
payload_json TEXT NOT NULL
|
||||
status pending | leased | admitted | consumed | dead_letter
|
||||
status pending | leased | admitted | consumed | superseded | dead_letter
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0
|
||||
lease_token TEXT
|
||||
lease_until INTEGER
|
||||
next_attempt_at INTEGER
|
||||
admitted_turn_id TEXT
|
||||
last_error TEXT
|
||||
created_at INTEGER NOT NULL
|
||||
consumed_at INTEGER
|
||||
dead_lettered_at INTEGER
|
||||
fallback_notified_at INTEGER
|
||||
revision INTEGER NOT NULL
|
||||
|
||||
UNIQUE(run_id, event_type, event_key)
|
||||
UNIQUE(scope_kind, scope_id, event_type, event_key)
|
||||
CHECK(
|
||||
(scope_kind = 'run' AND run_id IS NOT NULL AND group_id IS NULL AND scope_id = run_id) OR
|
||||
(scope_kind = 'group' AND group_id IS NOT NULL AND run_id IS NULL AND scope_id = group_id)
|
||||
)
|
||||
```
|
||||
|
||||
完整结果保存在 `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,但仍保留事件审计和客户端投影。
|
||||
|
||||
### 14.4 原子事务
|
||||
|
||||
Agent completion 必须在一个 Storage 事务中:
|
||||
@ -641,13 +711,31 @@ Agent completion 必须在一个 Storage 事务中:
|
||||
```text
|
||||
UPDATE agent_runs terminal state/result/usage
|
||||
UPDATE agent_run_groups terminal count/status
|
||||
INSERT agent_inbox_events ... ON CONFLICT DO NOTHING
|
||||
CONSUME reserved completion capacity
|
||||
INSERT run completion OR group completion ... ON CONFLICT DO NOTHING
|
||||
UPDATE bound task item by execution_id
|
||||
COMMIT
|
||||
```
|
||||
|
||||
事务失败时不能对外宣称任务完成。内存 wakeup 只有在 commit 成功后发送。
|
||||
|
||||
`completion_policy=all` 只有把 group 从 non-terminal 条件更新为 terminal 的事务赢家可以插入 group completion;其他 sibling 的迟到终态只完成自己的 run 条件更新,不能重复生成 event。
|
||||
|
||||
### 14.5 continuation 消息与投递绑定
|
||||
|
||||
现有 message 持久化需要增加两个可向后兼容字段:
|
||||
|
||||
```text
|
||||
client_visibility visible | hidden(默认 visible)
|
||||
turn_origin user | agent_continuation | scheduled(默认 user)
|
||||
```
|
||||
|
||||
hidden message 参与 Provider replay 和事务回滚,但 `SessionHistory`、`TurnCommitted.messages` 与 Channel 投递只投影 visible message。Storage 的 continuation commit API 必须在一个事务中写 hidden trigger、可见 assistant/tool 消息、usage 和 event consumption。
|
||||
|
||||
内部历史读取与客户端历史读取必须拆开:Session 恢复和 Provider replay 读取 visible+hidden;WebSocket/HTTP 历史、管理面消息查询和 committed delta 默认只读 visible。hidden `role=user` 不增加面向用户的 `message_count`,也不参与自动标题生成阈值;上下文压缩和 Provider token 占用仍必须统计它。
|
||||
|
||||
root session 还需持久化最近一次有效 delivery binding:`channel`、`chat_id` 和 Channel 明确标为 durable 的 opaque context。`ChannelContext` 必须把 `durable_private` 与当前仅用于一次回复的 `reply_to`/`private` 分开;核心只持久化 `durable_private`,不通过猜测 key 名过滤现有 `private`。binding 在成功接纳外部用户输入时更新;平台 thread/root 等稳定字段保持 Channel 私有,核心只存取和回传,不解释。binding 不得包含 message/reaction ID、token、临时上传 ID 或其他短期 credential。
|
||||
|
||||
## 15. Background 事件投递
|
||||
|
||||
### 15.1 统一输入类型
|
||||
@ -670,6 +758,7 @@ pub enum TurnInputSource {
|
||||
User,
|
||||
AgentSignal { run_id: String, agent_id: String },
|
||||
AgentCompletion { run_id: String, agent_id: String },
|
||||
AgentGroupCompletion { group_id: String },
|
||||
}
|
||||
|
||||
pub enum InputDelivery {
|
||||
@ -684,9 +773,9 @@ pub enum InputDelivery {
|
||||
|
||||
| 主 Agent 状态 | queue | steer |
|
||||
|----------------|-------|-------|
|
||||
| 无活动 Turn | 入 session queue,启动内部 Turn | 退化为 queue,启动内部 Turn |
|
||||
| 无活动 Turn | 保持 pending、唤醒 worker claim,启动内部 Turn | 退化为 queue,同左 |
|
||||
| 活动 Turn 接受输入 | 入下一 Turn | 入当前 TurnMailbox |
|
||||
| TurnMailbox 满/已关闭 | 保持 durable pending 后排队 | 可靠退化为 queue |
|
||||
| TurnMailbox 满/已关闭 | 保持 durable pending,唤醒 worker | 可靠退化为 queue |
|
||||
| Provider 请求进行中 | 等下一 Turn | 等请求结束后的安全边界 |
|
||||
| 普通工具批次进行中 | 等下一 Turn | 等完整工具批次结束 |
|
||||
| sleep 进行中 | 唤醒 sleep,内容仍留在 queue | 唤醒 sleep,并在工具批次后注入当前 Turn |
|
||||
@ -695,15 +784,26 @@ Steer 不承诺硬实时抢占。最迟可见时间由当前不可分割 Provide
|
||||
|
||||
### 15.3 原子 admission 与 fallback
|
||||
|
||||
AgentResultRouter 对 steer 事件执行与用户 steering 相同级别的原子判定:
|
||||
AgentResultRouter 对 steer 事件使用不跨 Session 锁做 SQLite I/O 的两阶段 admission:
|
||||
|
||||
1. 获取目标 Session。
|
||||
2. 在 Session 状态锁内分配单调 sequence。
|
||||
3. 若 active Turn 正在 accepting,尝试 push TurnMailbox。
|
||||
4. 若 closed/full/不存在,创建内部 AgentTask 放入有界 session queue。
|
||||
5. 内存 admission 结果与 durable event lease 关联。
|
||||
1. 在锁外以条件更新把 event 从 `pending` claim 为 `leased`。
|
||||
2. 在 Session 状态锁内为当前 accepting Turn 分配 sequence 和不可排空的 mailbox reservation;closed/full/不存在则不创建 reservation。
|
||||
3. 在锁外以 lease token 把 event 条件更新为 `admitted` 并写 `admitted_turn_id`。
|
||||
4. 重新取得 Session 锁;只有同一 Turn/generation 仍 accepting 时才把 reservation 激活为 AgentLoop 可见输入。
|
||||
5. 任一步失败都删除 reservation,并以 lease token 把 event 恢复 `pending`;若恰逢 `/stop`,由 `/stop` 的 admitted-turn 条件释放和 lease expiry 兜底。
|
||||
|
||||
任何竞态下事件只能属于当前 Turn 或后续 Turn之一,不能同时进入两者,也不能两者都不进入。Session queue 饱和时事件继续保持 durable pending,由 Router 重试;不能像普通瞬时通知一样丢弃。
|
||||
AgentLoop 只能排空已经激活的 reservation,因此不会在 durable admission 成功前看到事件。若事件不适合当前 Turn,Router 释放 lease、保持 `pending`,只递增该 session 的 inbox wake revision。
|
||||
|
||||
任何竞态下事件只能属于当前 Turn 或后续 Turn之一,不能同时进入两者,也不能两者都不进入。durable event payload 不进入保存用户 `AgentTask` 的 mpsc,因此普通 session queue 饱和不影响它。worker 在准备运行 continuation 时才从 SQLite claim lease;内存 wake 丢失由 pending/expired lease 扫描恢复。
|
||||
|
||||
Session worker 的接收面分为:
|
||||
|
||||
```text
|
||||
user task lane bounded mpsc(32),保存 payload,满时明确拒绝
|
||||
agent inbox wake lane watch revision,只合并“SQLite 有待处理事件”的提示
|
||||
```
|
||||
|
||||
watch revision 是延迟优化而不是事实来源;不为每个 event 建立另一个可饱和 payload 队列。Router/worker 遇到瞬时错误按 `next_attempt_at` 退避重试,默认最多 8 次并受 event TTL 限制;永久错误立即进入 dead-letter。
|
||||
|
||||
### 15.4 Mailbox 容量与公平性
|
||||
|
||||
@ -716,6 +816,8 @@ agent event lane: 8 messages / 32 KiB
|
||||
|
||||
排空时按 session sequence 合并。重要 AgentSignal 可以保留专用容量,但不默认越过更早已接受的用户输入。信号洪泛由 emit_signal rate limit 和 inbox 上限共同控制。
|
||||
|
||||
queue continuation 在 Turn 调度边界采用有界公平,而不是依赖 UI 保证可见:通常先处理用户任务;连续处理 `max_user_turn_burst_before_inbox`(默认 4)个用户 Turn,或最老 pending event 等待达到 `max_inbox_wait_secs`(默认 30 秒)后,下一个调度项必须是一个有界 event batch。当前活动 Turn 从不被 queue event 抢占,因此等待上限从下一个调度边界计算。UI 未读状态只是投影,不参与正确性。
|
||||
|
||||
### 15.5 安全边界注入
|
||||
|
||||
AgentLoop 只在以下边界排空 steer:
|
||||
@ -743,12 +845,12 @@ Session worker 调度优先级:
|
||||
|
||||
```text
|
||||
当前活动 Turn
|
||||
> 已排队用户输入
|
||||
> 已排队用户输入(受 burst/age 公平上限约束)
|
||||
> queue background result continuation
|
||||
> 等待新事件
|
||||
```
|
||||
|
||||
Steer event 在没有活动 Turn 时按 queue 处理。用户输入优先避免后台总结打断新请求;UI 未读状态避免持续用户流量下结果不可见。
|
||||
Steer event 在没有活动 Turn 时按 queue 处理。用户输入通常优先以避免后台总结打断新请求,但 §15.4 的 burst/age 规则保证结果不会在持续用户流量下无限饥饿。UI 未读状态只展示 pending/dead-letter 数量,不承担调度正确性。
|
||||
|
||||
### 16.3 内部 continuation Turn
|
||||
|
||||
@ -764,6 +866,14 @@ enum AgentTaskSource {
|
||||
|
||||
SessionManager 直接把领取的结果构造成 bounded runtime context,并加入一个内部触发语义:“检查这些后台结果,结合原始目标验证和汇总,再向用户报告。”内部输入不显示用户气泡;主 Agent输出按普通 assistant Turn 持久化和投递。
|
||||
|
||||
continuation 必须创建一条 durable hidden trigger message,而不是只在内存临时拼 prompt:
|
||||
|
||||
- 数据库 role 使用 Provider-compatible `user`,source 为 `agent_signal`/`agent_result`,并标记 `client_visibility=hidden`、`turn_origin=agent_continuation`。
|
||||
- hidden content 是有界 runtime envelope,包含 event/run references 和不可信数据边界;Provider history replay 会保留它,普通 history/WebSocket 投影会过滤它。
|
||||
- assistant/tool 消息照常可见。`turn_updated`/`turn_committed` 携带 turn origin,客户端可以显示“后台结果处理”标签,但不创建伪用户气泡。
|
||||
|
||||
内部 Turn 的 delivery target 来自 root session 的 durable binding:`channel`、`chat_id` 和可复用的 thread/root context。一次性 `reply_to` 不得复用。没有可用外部 binding 时仍提交历史并等待 WebUI/TUI 读取,不能猜测或改投其他 chat。
|
||||
|
||||
### 16.4 消费确认
|
||||
|
||||
领取流程:
|
||||
@ -772,7 +882,7 @@ SessionManager 直接把领取的结果构造成 bounded runtime context,并
|
||||
pending → leased → admitted → consumed
|
||||
```
|
||||
|
||||
`lease_token` 防止重复 worker 处理。同一事务必须保存主 Agent Turn 和把对应 inbox events 标记 consumed。若 AgentLoop 失败、Turn 取消或 Gateway 崩溃,lease 到期后事件恢复 pending。
|
||||
`lease_token` 防止重复 worker 处理。同一事务必须保存 hidden trigger、主 Agent Turn、usage,并把对应 inbox events 标记 consumed。continuation 持有 `InboxLeaseGuard`:AgentLoop 失败、Turn 取消、generation stale 等正常退出会以 token 显式 release;只有进程崩溃或强制 abort 才依赖 lease 到期恢复 pending。
|
||||
|
||||
外部 LLM 调用无法严格 exactly-once。为降低恢复重跑的副作用,background result continuation 默认只开放只读/汇总工具;需要外部写操作时由主 Agent向用户确认,或工具自身使用幂等键。
|
||||
|
||||
@ -790,6 +900,8 @@ pending → leased → admitted → consumed
|
||||
|
||||
Sleep 只负责唤醒,不负责消费输入。queue 内容仍属于下一 Turn;steer 内容仍由 TurnMailbox 在工具批次后注入。
|
||||
|
||||
上述“当前 session 输入”只适用于 root interactive Turn。sub-run 没有独立 session input lane,`ToolExecutionContext.turn_wakeup=None`,其 sleep 只响应 timer、run cancellation、timeout 或 shutdown;不会因 root session 用户输入或 sibling signal 被唤醒。Root 如需停止 sleeping sub-run,应调用 `agent_task.cancel`。
|
||||
|
||||
### 17.2 Wakeup handle
|
||||
|
||||
`ToolExecutionContext` 增加:
|
||||
@ -864,6 +976,10 @@ Foreground 子 run 是父 run 的结构化子任务:
|
||||
- run 进入 waiting_children 时仍保留所有权,但不能长期占用模型执行 permit。
|
||||
- 父取消后迟到结果不能提交为 completed。
|
||||
|
||||
这是 AgentLoop 的显式接口约束:root Turn、foreground child、run timeout 和 runtime shutdown 的 `CancellationToken` 必须贯穿 Provider stream、可取消等待和工具批次外层,不能只依赖调用 future 被 drop。结构化取消先作为 Phase 2A 独立落点实现并回归现有 root Turn 行为,再接入嵌套 run。
|
||||
|
||||
“不默认硬中断 Provider/副作用工具”只约束普通 `steer`;`steer` 等待安全边界。`/stop` 保持现有强停止语义:取消 token 并使 root Turn future 失效,独立 child 在有界宽限期后仍未退出则由 Coordinator/Supervisor abort。Coordinator 的 terminal condition update 始终阻止取消后的迟到完成提交。
|
||||
|
||||
### 18.2 Background 所有权
|
||||
|
||||
Background run 归 root session 所有,不归发起它的模型 future 所有。Root Turn结束不会自动取消它。
|
||||
@ -874,8 +990,9 @@ Background run 归 root session 所有,不归发起它的模型 future 所有
|
||||
|
||||
用户 steering 按现有语义可被 `/stop` 丢弃;已经持久化的 AgentSignal/Completion 不能静默消失:
|
||||
|
||||
- 已进入 current Turn 但尚未提交的 durable event 恢复 pending。
|
||||
- 被取消 background run 产生 cancelled completion,供 UI/主 Agent获知。
|
||||
- `/stop` 关闭 TurnMailbox 时收集尚未提交的 event ID,并在 generation 失效后按 `lease_token`/`admitted_turn_id` 条件更新立即恢复 pending;lease expiry 只是崩溃兜底。
|
||||
- continuation 的 `InboxLeaseGuard` 在 worker 正常退出、失败或取消时显式 release;durable payload 不进入会被 `agent_tx.take()` 丢弃的普通 mpsc。
|
||||
- 被取消 background run 仍由 Coordinator 条件事务写 cancelled completion。由本次 `/stop` 自身造成的 completion 使用 `requires_continuation=false`,保留审计和 UI 状态但不反向启动新 Turn;`/stop` 前已经存在的其他 durable event 恢复 pending 后继续投递。
|
||||
- 用户显式执行 `agent_task.cancel` 后,可以将该 run 未消费的普通 signal 标记 superseded,但保留审计记录。
|
||||
|
||||
### 18.4 Gateway reload
|
||||
@ -892,6 +1009,12 @@ AgentCatalog、ProviderFactory、Coordinator 和 inbox router 属于 Gateway run
|
||||
|
||||
进程退出后无法恢复正在进行的 LLM stream。启动恢复将旧 `running/waiting_children` 标记 `interrupted` 并生成 completion。普通有副作用 Agent run 不自动重试;只读、显式配置 idempotency/restart policy 的监控任务可以创建新 attempt,并保留原 run 的中断记录。
|
||||
|
||||
### 18.6 Session 归档与删除
|
||||
|
||||
- session 被归档后不再启动内部 continuation;未消费事件进入 `dead_letter(session_archived)` 并继续在管理面可见,非 Scheduler 所有的未终态 run 被取消。该生命周期原因不发送 system fallback。
|
||||
- session 被软删除后同样取消未终态 run,释放 reservation,并将未消费事件收敛为 `dead_letter(session_deleted)`;不得猜测其他 session 或 Channel 作为替代目标,也不发送 system fallback。
|
||||
- `/stop` 不是归档或删除:它取消当前 Turn 和该 session 的 active background run,但 `/stop` 前已经存在的 durable event 仍恢复为 pending;由本次停止产生的取消 completion 仅做 status-only 审计。
|
||||
|
||||
## 19. 并发、预算与死锁避免
|
||||
|
||||
### 19.1 限制层次
|
||||
@ -915,6 +1038,13 @@ Gateway 全局 active provider/tool permits
|
||||
|
||||
permit 应限制活跃 Provider/工具步骤,而不是整个 Agent Run 生命周期。父 run 进入 `waiting_children` 前释放执行 permit,子 run 完成后父 run 再竞争 permit 继续模型迭代。Task tree ownership、timeout 和 cancellation 不随 permit 释放而消失。
|
||||
|
||||
具体归属如下:
|
||||
|
||||
- Coordinator 的 run admission quota 统计已接纳且未终态的 run,可以跨 `waiting_children` 持有。
|
||||
- AgentLoop 在每次 Provider 请求前按 global → session → agent 的固定顺序获取 provider step permits,stream 结束/取消即释放。
|
||||
- tool executor 只为普通工具调用获取 tool step permit;`delegate`、`agent_task`、`emit_signal` 等 runtime-control 工具不占这种 permit。
|
||||
- foreground delegate 通过状态 guard 在等待前条件更新 `running → waiting_children`,返回/取消时再条件更新;等待动作本身不持有 provider/tool permit。
|
||||
|
||||
### 19.3 预算传播
|
||||
|
||||
每次子委托从父 budget 派生硬上限:
|
||||
@ -922,10 +1052,11 @@ permit 应限制活跃 Provider/工具步骤,而不是整个 Agent Run 生命
|
||||
```text
|
||||
child deadline <= parent deadline
|
||||
child max depth <= remaining depth
|
||||
sum child token/cost reservation <= remaining tree budget
|
||||
sum child token reservation <= remaining tree budget
|
||||
sum child cost reservation <= remaining tree budget(仅有价格配置时)
|
||||
```
|
||||
|
||||
调用方可以收紧 timeout/结果大小,但不能超过 Agent Definition 和系统上限。
|
||||
调用方可以收紧 timeout/结果大小,但不能超过 Agent Definition 和系统上限。Provider profile 没有价格信息时不启用 cost reservation,只执行 token、迭代、deadline 和 run-count 硬预算。
|
||||
|
||||
## 20. 可观测性与客户端表现
|
||||
|
||||
@ -948,6 +1079,19 @@ WebUI 管理面展示:
|
||||
- queue completion 在主 Agent内部 continuation 后只显示主 Agent汇总回复。
|
||||
- steer 信号可以在当前 Turn 工具状态中显示“已接纳”,最终历史由 Turn commit 校准。
|
||||
|
||||
WebSocket 协议使用通用 run/event 投影,不为 Signal 复制一套状态机:
|
||||
|
||||
```text
|
||||
WsInbound::GetAgentRuns { session_id, cursor, limit }
|
||||
WsInbound::GetAgentRun { session_id, run_id }
|
||||
|
||||
WsOutbound::SessionAgentRuns { session_id, revision, runs, next_cursor }
|
||||
WsOutbound::AgentRunUpdated { session_id, revision, run }
|
||||
WsOutbound::AgentEventUpdated { session_id, revision, event }
|
||||
```
|
||||
|
||||
`AgentEventUpdated` 覆盖 accepted/admitted/consumed/dead-letter 状态,客户端按 `(session_id, revision, event_id)` 幂等合并;重连后用 `GetAgentRuns` 全量校准。`TurnSnapshot` 和 `TurnCommitted` 增加 `turn_origin = user | agent_continuation | scheduled`。第一版取消入口继续使用 `/stop`、`agent_task.cancel` 或受保护管理 API,不增加缺少任务树授权上下文的裸 WebSocket cancel 帧。
|
||||
|
||||
### 20.3 隐私与日志
|
||||
|
||||
- 不显示/记录 Agent reasoning 和 Provider 私有 state。
|
||||
@ -962,16 +1106,21 @@ WebUI 管理面展示:
|
||||
| Agent Definition 无效 | 拒绝候选运行代;旧代继续服务 |
|
||||
| 委托边不允许 | delegate 立即返回 permission denied,不创建 run |
|
||||
| Background 持久化失败 | delegate 返回失败,不报告 run ID |
|
||||
| Completion capacity 无法预留 | delegate 在创建 run 前返回 inbox capacity exceeded |
|
||||
| TaskSupervisor 拒绝 spawn | run 条件更新 cancelled/failed,再返回失败 |
|
||||
| Provider 创建失败 | run failed,foreground 返回错误;background 生成 failure event |
|
||||
| Signal inbox 无可用容量 | emit_signal 返回 inbox_full;run 继续执行 |
|
||||
| Inbox wakeup 丢失 | pending event 由恢复扫描重新唤醒 |
|
||||
| TurnMailbox closed/full | steer 可靠退化 queue |
|
||||
| Session queue 满 | durable event 保持 pending,Router 有界重试 |
|
||||
| Main continuation Provider 失败 | event lease 到期并重试;不标 consumed |
|
||||
| Session user queue 满 | 用户输入明确拒绝;durable event 不经过该队列 |
|
||||
| Main continuation Provider 失败 | lease guard 显式 release 并退避重试;崩溃时才等 lease 到期 |
|
||||
| 主 Agent回复持久化失败 | event 不确认,避免结果消失 |
|
||||
| Event 超过 attempts/TTL | 标 dead_letter,并幂等尝试一次 system fallback |
|
||||
| Channel 最终投递失败 | assistant history已持久化;沿用 DeliveryCoordinator terminal fallback |
|
||||
|
||||
所有重试必须有次数、退避、deadline 和分类;永久错误立即终态化,不能无界重试。
|
||||
所有重试必须有次数、退避、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 伪装成已交付。
|
||||
|
||||
## 22. 兼容迁移
|
||||
|
||||
@ -983,10 +1132,9 @@ WebUI 管理面展示:
|
||||
inline → foreground
|
||||
parallel → foreground + tasks[]
|
||||
background → background
|
||||
async → background(若曾接受该别名)
|
||||
```
|
||||
|
||||
过渡期解析旧参数并在 tool result/日志中给出弃用提示;新 system prompt 只描述 canonical 值 `foreground/background`。
|
||||
过渡期只解析代码中确实存在的旧值并在 tool result/日志中给出弃用提示;`async` 从未是有效值,不新增该别名。新 system prompt 只描述 canonical 值 `foreground/background`。
|
||||
|
||||
### 22.2 allowed_tools
|
||||
|
||||
@ -1014,28 +1162,37 @@ async → background(若曾接受该别名)
|
||||
- Delegate schema 使用 target + foreground/background canonical modes。
|
||||
- 批量 foreground 并发执行并聚合。
|
||||
- 显式 AgentExecutionContext 和委托图授权。
|
||||
- 明确 skills/memory 不继承、具名 Agent browser scope 隔离和 legacy general scope 兼容。
|
||||
- 保持旧 background 通知路径作为兼容,但不开放嵌套 background。
|
||||
|
||||
### Phase 2:统一 Agent Run 持久化
|
||||
### Phase 2A:AgentLoop 结构化取消
|
||||
|
||||
- CancellationToken 贯穿 root Turn、Provider stream、工具批次和 AgentRunner。
|
||||
- 保持 `/stop` 强停止、普通 `steer` 安全边界语义。
|
||||
- 用现有 sleep cancellation、Provider stream 和 steering recovery tests 锁定回归基线。
|
||||
|
||||
### Phase 2B:统一 Agent Run 持久化
|
||||
|
||||
- 新增 `agent_runs`、`agent_run_groups`、Storage transaction API。
|
||||
- 拆分 `delegate` 与 `agent_task`。
|
||||
- Foreground 结果也持久化,修复截断结果不可查询。
|
||||
- 实现结构化取消、预算与 permit 释放。
|
||||
- 实现预算、run admission quota 与 step execution permit 释放。
|
||||
|
||||
### Phase 3:Agent Inbox 与 Queue Completion
|
||||
|
||||
- 新增 `agent_inbox_events`、lease、恢复扫描。
|
||||
- 新增 completion capacity reservation、合并式 wake lane和 worker 有界公平。
|
||||
- Background completion 从 Channel direct notification 改为主 Agent内部 continuation。
|
||||
- 增加 SourceKind::AgentResult、内部 AgentTask 和 WebUI 投影。
|
||||
- 增加 hidden continuation trigger、SourceKind::AgentResult、Turn origin 和 WebSocket run/event 投影。
|
||||
- 批次 completion 合并与 debounce。
|
||||
- 增加 dead-letter system fallback、UI 未读计数和 reconnect 全量校准。
|
||||
|
||||
### Phase 4:Emit Signal 与 Steer
|
||||
|
||||
- 新增 EmitSignalTool、SignalContract 和 rate/dedupe。
|
||||
- SteeringMailbox 泛化为来源感知 TurnMailbox。
|
||||
- 实现 steer admission、queue fallback、durable ack/recovery。
|
||||
- 添加 AgentSignal UI 和任务树。
|
||||
- 添加 AgentSignal UI;任务树复用 Phase 3 的 run/event 协议。
|
||||
|
||||
### Phase 5:可唤醒 Sleep 与工具中断元数据
|
||||
|
||||
@ -1043,6 +1200,7 @@ async → background(若曾接受该别名)
|
||||
- SleepTool 使用 watch revision + timer + cancellation select。
|
||||
- queue/steer 唤醒内容边界和测试。
|
||||
- 为工具增加 InputInterruptPolicy,默认 Never。
|
||||
- sub-run 保持 timer/cancellation-only,不获得 root TurnWakeupHandle。
|
||||
|
||||
## 24. 预计代码边界
|
||||
|
||||
@ -1066,7 +1224,7 @@ src/tools/
|
||||
|
||||
src/session/
|
||||
├── turn_mailbox.rs typed steer inputs
|
||||
├── agent_inbox.rs claim/admit/ack orchestration
|
||||
├── agent_inbox.rs claim/admit/ack、lease guard、coalesced wake
|
||||
└── session.rs typed AgentTask scheduling
|
||||
|
||||
src/storage/
|
||||
@ -1095,6 +1253,7 @@ src/storage/
|
||||
- 单项失败不丢其他项结果。
|
||||
- Background 只有持久化并成功 spawn 后才返回 run ID。
|
||||
- 同 idempotency key 不重复创建 run。
|
||||
- Root caller scope 的 idempotency key 在 SQLite 中同样去重。
|
||||
- Background completion 不直接伪装为用户消息。
|
||||
|
||||
### 25.3 Provider 与工具
|
||||
@ -1104,6 +1263,8 @@ src/storage/
|
||||
- RootOnly 工具不能通过 Markdown 或兼容 allowed_tools 获得。
|
||||
- runtime-injected delegate/emit_signal 只在上下文允许时存在。
|
||||
- 并行 run 的 browser/resource scope 隔离。
|
||||
- persistent browser profile 可显式共享;具名 Agent不继承 transient parent scope。
|
||||
- 子 Agent不隐式继承主会话 history/memory/临时 Skill。
|
||||
|
||||
### 25.4 Inbox 与投递竞态
|
||||
|
||||
@ -1115,7 +1276,12 @@ src/storage/
|
||||
- 用户队列优先于 queue completion。
|
||||
- Turn persist 失败时 event 不 consumed。
|
||||
- lease 超时后可重领,旧 lease token 不能提交。
|
||||
- 多 run group 只触发一次汇总 Turn。
|
||||
- 正常取消/worker 退出由 lease guard 立即 release,不等待 lease timeout。
|
||||
- user mpsc 满不影响 durable wake;wake revision 丢失后扫描可恢复。
|
||||
- 用户持续输入时 burst/age 公平上限仍调度 continuation。
|
||||
- completion capacity 在 background 接纳时预留,signal 不能抢占。
|
||||
- `each` 逐 run 提前投递;`all` 只触发一次 group 汇总 Turn。
|
||||
- retries/TTL 耗尽进入 dead-letter,system fallback 最多发送一次。
|
||||
|
||||
### 25.5 Signal
|
||||
|
||||
@ -1135,12 +1301,14 @@ src/storage/
|
||||
- 多条输入只消费一次且顺序稳定。
|
||||
- `/stop`、parent cancellation、shutdown 取消 sleep 并终态化工具块。
|
||||
- wakeup 与 timer 同时发生时不丢输入;输入若未入当前 Turn则可靠排队。
|
||||
- sub-run sleep 不被 root session 输入唤醒,只响应 timer/cancellation。
|
||||
|
||||
### 25.7 取消、并发与恢复
|
||||
|
||||
- 父 foreground 取消级联后代。
|
||||
- 父 waiting_children 不持有唯一 permit,无死锁。
|
||||
- `/stop` 取消 session background runs,并恢复未提交 durable events。
|
||||
- `/stop` 产生的 cancelled completion 只投影状态,不启动新的 continuation。
|
||||
- 迟到结果不能覆盖 cancelled/interrupted。
|
||||
- reload 关闭 admission 后拒绝新 run/signal,pending inbox 由新代恢复。
|
||||
- Gateway 重启把 running 标记 interrupted 并生成 completion。
|
||||
@ -1150,9 +1318,12 @@ src/storage/
|
||||
|
||||
- AgentSignal 不渲染为用户气泡。
|
||||
- Internal continuation 输入不出现在普通历史,assistant 汇总正常持久化。
|
||||
- hidden trigger 会参与 Provider replay,并与 assistant/usage/event consumed 原子提交。
|
||||
- continuation 使用稳定 delivery binding,绝不复用一次性 reply_to。
|
||||
- reasoning/provider state 不进入信号、API、客户端和日志。
|
||||
- send_message 仍走外部投递确认;emit_signal 不走 OutboundDispatcher。
|
||||
- 同 Turn 附件兼容路径与未来 attach_artifact 不产生重复历史。
|
||||
- run/event 增量按 revision 幂等,断线重连后可全量校准。
|
||||
|
||||
## 26. 必须保持的架构不变量
|
||||
|
||||
@ -1161,7 +1332,7 @@ src/storage/
|
||||
3. Foreground/Background 只描述委托方等待行为;并发是独立调度维度。
|
||||
4. Queue 输入永不泄漏正文到当前 Turn;Steer 只在安全边界注入。
|
||||
5. Signal 先持久化后唤醒;内存通知不是事实来源。
|
||||
6. Signal 是非终态事件,Completion 由运行时自动生成且恰好对应一个 run 终态。
|
||||
6. Signal 是非终态事件;run Completion 由运行时自动生成且恰好对应一个 run 终态,`all` policy 的 Group Completion 恰好对应一个 group 终态。
|
||||
7. SendMessage 是外部输出,EmitSignal 是内部输入,不能用一个公开万能工具混合权限。
|
||||
8. Durable Agent event 在 `/stop`、Turn 失败或 Gateway 崩溃时不能静默丢失。
|
||||
9. Agent Definition 和 Provider 绑定 runtime generation;运行中不热切换。
|
||||
@ -1169,6 +1340,9 @@ src/storage/
|
||||
11. 父 run 等待子 run 时不持有会造成递归死锁的执行 permit。
|
||||
12. 完成状态、结果、usage、计划子项和 inbox event 使用事务/条件更新提交。
|
||||
13. 客户端、Channel 和日志永不暴露 Provider 私有 reasoning state、secret 或本地内部路径。
|
||||
14. Durable event payload 只以 SQLite inbox 为权威来源;普通 user task mpsc 和合并式 wake lane 都不能成为确认点。
|
||||
15. Continuation 的 hidden trigger 必须可供 Provider replay,但不得投影为用户消息;trigger、回复和 event consumption 原子提交。
|
||||
16. 已接纳 background run 的 terminal completion 容量已经预留;运行结束不能因 signal 洪泛丢失 completion。
|
||||
|
||||
## 27. 设计结论
|
||||
|
||||
@ -1185,7 +1359,7 @@ AgentCoordinator
|
||||
↓
|
||||
queue | steer
|
||||
↓
|
||||
Session queue | current TurnMailbox
|
||||
inbox wake/worker claim | current TurnMailbox
|
||||
↓
|
||||
Root Agent
|
||||
```
|
||||
|
||||
1113
docs/SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md
Normal file
1113
docs/SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md
Normal file
File diff suppressed because one or more lines are too long
153
docs/SUB_AGENT_ORCHESTRATION_REVIEW.md
Normal file
153
docs/SUB_AGENT_ORCHESTRATION_REVIEW.md
Normal file
@ -0,0 +1,153 @@
|
||||
# 子 Agent 编排与信号投递设计审核报告
|
||||
|
||||
> 状态:审核完成(2026-08)。审核对象为设计提案 `docs/SUB_AGENT_ORCHESTRATION_DESIGN.md`,该设计尚未实现;本文所有"现状"描述以当前代码和测试为准。
|
||||
>
|
||||
> 本文结合现有实现逐条核实设计的现状诊断,评估架构合理性,并按严重程度列出缺陷与落地前必须补齐的定义。行号基于审核时的代码快照,后续实现合并后可能过时。
|
||||
>
|
||||
> 设计方逐项答复见 [`SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md`](SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md);已接受结论同步写入设计文档。
|
||||
|
||||
## 1. 审核范围与依据
|
||||
|
||||
### 1.1 审核对象
|
||||
|
||||
- 设计文档:`docs/SUB_AGENT_ORCHESTRATION_DESIGN.md`(提案,未实现;`rg` 确认 `src/` 与 `webui/` 中无任何 `AgentCatalog`/`AgentCoordinator`/`agent_inbox`/`emit_signal` 相关实现)
|
||||
- 对照实现:`src/agent/sub_agent.rs`、`src/agent/agent_loop.rs`、`src/agent/steering.rs`、`src/session/session.rs`、`src/session/turn_input.rs`、`src/session/persistence.rs`、`src/tools/delegate.rs`、`src/tools/sleep.rs`、`src/tools/send_message.rs`、`src/tools/traits.rs`、`src/storage/`、`src/work/mod.rs`、`src/scheduler/mod.rs`、`src/task_supervisor.rs`、`src/config/mod.rs`、`src/gateway/reload.rs`
|
||||
|
||||
### 1.2 审核依据
|
||||
|
||||
- `docs/ARCHITECTURE.md` 与 AGENTS.md 中的架构边界和并发不变量
|
||||
- 现有相似机制:Scheduler durable lease(`src/storage/scheduler.rs:310-348`)、WorkManager 乐观并发(`src/work/mod.rs:320-342`)、TurnController"持久化后才 Completed"(`src/session/persistence.rs:147-167`)、RuntimeAdmission(`src/gateway/reload.rs`)
|
||||
|
||||
## 2. 总体结论
|
||||
|
||||
**设计方向合理,可以按分期推进;但存在 5 处与现有代码强耦合的接缝缺口(A1–A5),落地前必须先补齐定义,否则 Phase 2/3 会被迫返工。**
|
||||
|
||||
设计的核心决策——`foreground/background` 与 `queue/steer` 两个正交维度、先持久化后唤醒、SQLite inbox 为权威来源、lease/consumed 事务提交、ancestry 环检查、AgentCatalog 绑定运行代——与 PicoBot 既有不变量一致,且现状诊断(设计 §1 的 9 条)逐条属实(见第 3 节)。主要问题不在方向,而在设计与现有 session worker、`/stop`、AgentLoop 取消机制的衔接处留白过多。
|
||||
|
||||
## 3. 现状诊断核实
|
||||
|
||||
设计 §1 的 9 条诊断全部与代码一致:
|
||||
|
||||
| # | 设计诊断 | 代码证据 | 结论 |
|
||||
|---|----------|----------|------|
|
||||
| 1 | 所有子 Agent 复用同一 `LLMProviderConfig` | `SubAgentManager.provider_config` 单实例(`src/agent/sub_agent.rs:119`),inline/background 均用它创建 Provider(:204、:477) | 属实 |
|
||||
| 2 | 无具名角色文件,工具权限由 `allowed_tools` 临时决定 | `delegate` schema 的 `allowed_tools` 数组(`src/tools/delegate.rs:50-54`);未填时用默认只读集(`sub_agent.rs:34-41`) | 属实 |
|
||||
| 3 | 子 Agent 被统一移除 `delegate` | `filter_tools` 硬编码排除 `delegate`/`todo`/`reload_config`(`sub_agent.rs:177-181`) | 属实 |
|
||||
| 4 | `DelegateContext` 只有 session/channel/chat | `sub_agent.rs:90-95`;无 caller/parent/depth/ancestry | 属实 |
|
||||
| 5 | `parallel` 混淆"委托方是否等待"与"是否并发" | `run_parallel` 就是 `join_all(run_inline)`(`sub_agent.rs:332-346`) | 属实 |
|
||||
| 6 | 后台完成通知直发 `MessageBus.outbound`,不成为主 Agent 输入 | `background-task-notifications` 任务格式化后 `publish_outbound` fire-and-forget(`src/session/session.rs:1757-1776`),不写会话历史、不触发 Turn | 属实 |
|
||||
| 7 | Steering mailbox 只建模用户输入,且继承 `/stop` 丢弃语义 | admission 只推 `SourceKind::UserInput`(`session.rs:2910-2938`);AgentLoop 防御性归一 `role=user`(`src/agent/agent_loop.rs:624-628`);`/stop` 调 `close_and_take_pending` 主动丢弃(`session.rs:2120-2128`、`src/agent/steering.rs:215-229`) | 属实 |
|
||||
| 8 | `SleepTool` 只等定时器 | `tokio::time::sleep` 单一路径(`src/tools/sleep.rs:72`),无 wakeup/cancel 分支 | 属实 |
|
||||
| 9 | `send_message` 同时覆盖跨 Channel、目标会话写入和同 Turn 附件暂存 | `src/tools/send_message.rs` 的 target/content/origin/files 参数;`OutboundDelivery::AttachedToCurrentTurn` 同 Turn 分支(`src/tools/traits.rs:127-131`) | 属实 |
|
||||
|
||||
**补充:设计隐含覆盖了一个现存 bug。** inline 结果截断时提示"完整结果请使用 check_task 查看"(`sub_agent.rs:809`),但 `run_inline` 从不写 `background_tasks` 表(只有 `run_background` 写,`sub_agent.rs:373-398`),`check_task`(`sub_agent.rs:707-713`)查不到 inline 结果。设计 Phase 2"Foreground 结果也持久化"(§23)修复此问题,分期安排正确。
|
||||
|
||||
## 4. 设计合理性评估
|
||||
|
||||
以下决策予以肯定:
|
||||
|
||||
| 设计点 | 评估 |
|
||||
|--------|------|
|
||||
| 执行生命周期与投递方式正交化(§4.1) | ✅ 干净消除 `parallel` 的语义混淆;批量 foreground 并发等价旧 parallel 但不作为第三种模式 |
|
||||
| 先持久化后唤醒、内存 wakeup 仅为加速器(§6.4、§14.4) | ✅ 与"持久化后才 Completed"既有不变量(`persistence.rs:147-167`)同构 |
|
||||
| lease/consumed 与条件更新(§14、§16.4) | ✅ 复用 Scheduler durable lease 与 WorkManager 乐观并发的成熟模式 |
|
||||
| `target not in ancestry` 拒绝 A→B→A(§7.1) | ✅ 以显式 iteration workflow 替代隐式递归,边界正确 |
|
||||
| 授权不依赖 task-local(§8) | ✅ 正确诊断现状 `DELEGATE_CONTEXT` task-local(`sub_agent.rs:19-28`)不是授权事实来源;`tokio::spawn` 不传播 task-local |
|
||||
| AgentCatalog 以 Arc 固定运行代(§5.4、§18.4) | ✅ 与 RuntimeAdmission 现有集成一致(`SubAgentManager` 已接 admission,`sub_agent.rs:157-163、353-358`) |
|
||||
| `llm_profile` 引用现有 `config.agents`(§5.2) | ✅ `Config.agents` 与 `get_provider_config(agent_name)` 已存在(`src/config/mod.rs:45、712-745`),无需配置重构 |
|
||||
| Completion 由运行时自动生成、不依赖模型记得调工具(§12.1) | ✅ 正确;`emit_signal` 无任意目标参数,收敛了权限面 |
|
||||
| 可唤醒 sleep 用 watch revision 而非裸 Notify(§17.2) | ✅ 正确规避"输入先于订阅到达"的丢失唤醒竞态 |
|
||||
| §26 不变量清单 | ✅ 与 ARCHITECTURE.md 一致,可作为实现验收标准 |
|
||||
| 分期顺序(§23) | ✅ Phase 1 纯增量;Phase 2 顺带修复 inline 截断 bug;依赖方向正确 |
|
||||
|
||||
## 5. 缺陷清单
|
||||
|
||||
严重程度:A=主要(落地前必须补齐定义);B=中等(实现对应 Phase 前补齐);C=次要(修订文档即可)。
|
||||
|
||||
### 5.1 A 级:主要缺陷
|
||||
|
||||
**A1 — Session 队列饱和语义与现状冲突(设计 §15.3)**
|
||||
|
||||
现状:session 队列容量 32(`session.rs:27`),满时 `try_send` 失败直接丢弃输入并回复"队列已满"(`session.rs:3001-3007`)。设计要求 durable event 在队列饱和时"保持 durable pending,由 Router 有界重试,不能丢弃",但未定义:
|
||||
|
||||
- agent 事件与用户输入是否共用同一 mpsc(共用则用户流量可长期占满队列,事件重试无收敛界);
|
||||
- Router 重试的退避、deadline 与最终处置;
|
||||
- §15.4 只拆分了 TurnMailbox 的 lane(user 32/64KiB、agent 8/32KiB),session 级队列的 agent lane 容量与优先级未定义。
|
||||
|
||||
**A2 — `/stop` 与 worker 退出时 durable event 的恢复机制缺失(设计 §18.3)**
|
||||
|
||||
现状 `/stop`:`current_cancel.take()`(`session.rs:2117`)→ `close_and_take_pending` 丢弃 steering(:2120-2128)→ `agent_tx.take()` 丢弃全部排队任务(:2136)→ bump generation/state_version(:2139-2140)→ 取消后台子任务(:2144-2148)。mpsc 被 drop 时没有逐项回调。设计未定义:
|
||||
|
||||
- 被丢弃的内部 `AgentTask`(含 `BackgroundAgentResults`)如何触发 inbox lease 释放——只能靠 lease 超时被动收敛(应明说延迟界),或为 AgentTask 增加 Drop guard(未提);
|
||||
- `/stop` 后 worker 退出(`task_rx.recv()` 返回 None 即 break,`session.rs:3150-3152`),被取消 run 异步生成的 cancelled completion 由谁、何时消费,完整链路未写。
|
||||
|
||||
**A3 — `waiting_children` permit 释放在现有结构中无落点(设计 §19.2)**
|
||||
|
||||
AgentLoop 目前没有任何 permit/cancellation 原语(`agent_loop.rs` 无 CancellationToken/select;取消靠 worker 整体 drop future,`session.rs:3778-3804`);TaskSupervisor 也没有并发上限,只在 stopping 时拒绝 spawn(`task_supervisor.rs:60-89`)。设计只给出原则"permit 限制活跃 Provider/工具步骤",未定义:
|
||||
|
||||
- permit 由谁持有与获取/释放(AgentLoop?AgentRunner?Coordinator?);
|
||||
- delegate 在父 run 的 tool batch 内执行(`agent_loop.rs:1187-1242`),父 run 进入等待时释放 permit 的钩子如何嵌入现有批处理流程。
|
||||
|
||||
这是 Phase 2 复杂度最高的部分,只给原则不够。
|
||||
|
||||
**A4 — 结构化取消是前置条件,但 AgentLoop 当前零支持(设计 §18.1)**
|
||||
|
||||
新架构中子 run 是 Coordinator spawn 的独立任务,父 future 被 drop 不再传播取消,必须用显式 CancellationToken 树贯穿 AgentLoop——这是横切重构,设计只在 Phase 2 列了一行"实现结构化取消"。另有一处表述需要澄清:§3 非目标"不默认硬中断正在进行的 Provider 请求"只约束 `steer`;现有 `/stop` 恰是硬 drop(drop `process_future` 连带中断 provider 流,`session.rs:3778-3804`)。文档应显式声明 `/stop` 保持硬语义,避免实现时误读为 `/stop` 也要走安全边界。
|
||||
|
||||
**A5 — 内部 continuation Turn 的消息/持久化/渲染模型未定义(设计 §16.3)**
|
||||
|
||||
现有 Turn 以用户消息为起点:先持久化用户消息(`session.rs:3191`);`prepare_turn_input` 把 runtime context 附加到最后一条 user message(`src/session/turn_input.rs:19-25`);WebUI/TUI 按 user/assistant 交替渲染。设计说"内部输入不显示用户气泡",但未定义:
|
||||
|
||||
- continuation Turn 写什么消息行(无 user 行?系统行?)、历史 replay 给 provider 时的形态;
|
||||
- 客户端如何渲染无用户消息的 Turn(§15.1 的 SourceKind 扩展只解决标记问题);
|
||||
- continuation 输出的投递目标:`AgentTask` 的 channel/chat_id/channel_context 来自 InboundMessage(`session.rs:631-641`),内部任务没有 channel 上下文,应显式规定投递到 session 最近的 channel/chat。
|
||||
|
||||
### 5.2 B 级:中等缺陷
|
||||
|
||||
**B1 — browser/resource scope 隔离是行为破坏,且无共享出口(设计 §10)。** 现状子 Agent 复用父对话的 browser session(`browser_session_id` 回退到 delegate context 的 session_id,`sub_agent.rs:264-279`;background 路径 :505-508 同)。改为 `root_session_id + run_id` 隔离会破坏依赖父会话登录态/cookie 的场景。"确需共享必须由工具定义显式支持"没有给出机制,应指明 `browser_profiles` persistent ID 为官方共享路径。
|
||||
|
||||
**B2 — dead_letter 与重试上限策略空缺(设计 §14.3、§21)。** Phase 3 移除直发通知后,continuation 反复失败转 dead_letter 时结果对用户彻底不可见,文档未定义 dead-letter 后的用户可见行为(如回退一条系统通知)。`max_pending_inbox_events_per_session=128` 打满后新事件的行为同样未定义。
|
||||
|
||||
**B3 — `completion_policy=each` 只有字段没有语义(设计 §14.2)。** 正文只描述了 `all`:一个 run 超时会把全组结果交付拖到 group deadline,缺少 per-run 提前交付或分组拆分策略。
|
||||
|
||||
**B4 — "UI 未读状态"是防饥饿的关键依赖,但不存在且未立项(设计 §16.2)。** 调度优先级把 queue completion 排在用户输入之后,持续用户流量下后台结果会被无限推迟,设计靠"UI 未读状态"兜底;该 WebUI 功能当前不存在,Phase 3 只写了"WebUI 投影",未列为明确工作项。
|
||||
|
||||
**B5 — `cost` 字段假设了不存在的定价配置(设计 §6.5、§14.1)。** `agent_runs.cost` 与 ProviderFactory"复用价格信息"的前提不成立:config 从不填 `price_input/output_per_million`(`src/config/mod.rs:742-743` 硬编码 None,无配置键解析)。要么补定价配置,要么注明 cost 暂为 NULL。
|
||||
|
||||
**B6 — 子 Agent run 内 sleep 的唤醒语义未定义(设计 §17)。** 全章隐含 root session 的 Turn;sub-run 没有"当前 session 用户输入"概念。应显式规定 sub-run 内 sleep 只响应自身 cancellation/timeout,否则 `TurnWakeupHandle` 的来源不明。
|
||||
|
||||
### 5.3 C 级:次要问题
|
||||
|
||||
**C1 — SQLite UNIQUE 与 NULL 语义(设计 §14.3、§9.6)。** `UNIQUE(run_id, event_type, event_key)`:`emit_signal` 未提供 `dedupe_key` 时 `event_key` 的生成规则未定义。`idempotency_key` 的唯一范围 `(root_session_id, caller_run_id, key)` 在 ROOT 调用时 `caller_run_id` 为 NULL,SQLite 中 NULL≠NULL 会导致去重失效,需要哨兵值(如 `root`)。
|
||||
|
||||
**C2 — 授权与上下文的细节留白(设计 §7.1、§9.1、§10)。** definition `max_depth` 与全局 `max_tree_depth` 是否取 min 未明说;`agent_task` 工具能否操作本 root session 任务树之外的 run(跨 session 越权)未明说;skills/memory 是否进入子 Agent 上下文未提(现状子 Agent 可带 skills prompt,`sub_agent.rs:188-197`;memory recall 只在 session Turn,`turn_input.rs:47`)。
|
||||
|
||||
**C3 — `async` 别名是多余假设(设计 §22.1)。** 现代码从未接受 `async`(`delegate.rs:151-165` 只解析 inline/background/parallel),该迁移条目可删。
|
||||
|
||||
**C4 — 客户端协议变更未枚举(设计 §20.2、Phase 4)。** AgentSignal 卡片、"已接纳"状态、continuation Turn 都需要新的 `WsOutbound` 消息类型(`src/protocol.rs`),Phase 4 只写"添加 AgentSignal UI 和任务树",未列协议变更清单。
|
||||
|
||||
## 6. 修订建议
|
||||
|
||||
实现启动前,建议在设计文档中补充五个专项定义(对应 A 级缺陷):
|
||||
|
||||
1. **Durable event 与 session 队列的 lane 划分**:agent 事件是否独立队列、饱和时的重试退避/deadline/dead-letter 策略、与用户输入的优先级关系(A1)。
|
||||
2. **`/stop`、worker 退出与 lease 释放的衔接**:被丢弃内部任务的 lease 释放路径(Drop guard 或明确依赖 lease 超时及延迟界)、`/stop` 后生成的 cancelled completion 的消费链路(A2)。
|
||||
3. **Permit 归属**:执行 permit 在 AgentLoop/AgentRunner/Coordinator 之间的获取与释放点,特别是 `waiting_children` 前后的钩子位置(A3)。
|
||||
4. **Continuation Turn 模型**:消息行写入形态、provider replay 形态、客户端渲染契约、输出投递目标(A5)。
|
||||
5. **取消横切方案**:CancellationToken 贯穿 AgentLoop 的接口设计,并显式声明 `/stop` 保持硬 drop 语义、安全边界注入只约束 `steer`(A4)。
|
||||
|
||||
B 级问题建议在对应 Phase 实现前补齐:B1/B6 在 Phase 1,B5 在 Phase 2,B2/B3/B4 在 Phase 3。
|
||||
|
||||
## 7. 分期实施意见
|
||||
|
||||
| Phase | 风险 | 意见 |
|
||||
|-------|------|------|
|
||||
| 1 具名 Agent 与 Foreground | 低 | 纯增量。`llm_profile` 直接复用 `Config::get_provider_config`(`config/mod.rs:712-745`),无配置重构。注意 B1:browser scope 隔离会改变现有子 Agent 共享父会话 browser 的行为,需要迁移说明 |
|
||||
| 2 统一 Run 持久化 | 高 | 改动面最大:AgentLoop 取消与 permit 均为横切变更。建议先独立原型"CancellationToken 贯穿 AgentLoop",用现有 sleep 取消测试(`sleep.rs:194-237`)与 steering 恢复测试锁定回归基线,再叠加 permit |
|
||||
| 3 Inbox 与 Queue Completion | 中 | UX 拐点:完成通知从直发 Channel 改为主 Agent continuation。建议保留配置开关回退直发通知,覆盖 B2 的 dead-letter 空窗;§25.4 测试矩阵是本 Phase 验收关键 |
|
||||
| 4 Emit Signal 与 Steer | 中 | TurnMailbox 泛化触及 `handle_message` 核心 admission 路径(`session.rs:2815-2953`),与 `/stop` 的原子性必须沿用现有同锁判定模式;先补 C4 协议清单 |
|
||||
| 5 可唤醒 Sleep | 低 | 相对独立。watch revision 方案正确;先补 B6 的 sub-run 语义 |
|
||||
|
||||
## 8. 结论
|
||||
|
||||
设计的现状诊断准确、核心决策与既有架构不变量兼容、分期依赖方向正确,**审核结论为"方向通过,需修订后实现"**。A1–A5 五个接缝缺口不是方向错误,而是设计与 `session worker`/`/stop`/`AgentLoop` 取消机制的衔接定义不足;按第 6 节补齐专项定义后,可按第 7 节顺序分期实施。
|
||||
222
docs/SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md
Normal file
222
docs/SUB_AGENT_ORCHESTRATION_REVIEW_RESPONSE.md
Normal file
@ -0,0 +1,222 @@
|
||||
# 子 Agent 编排与信号投递设计评审答复
|
||||
|
||||
> 状态:设计方答复(2026-08)。
|
||||
>
|
||||
> 本文逐项回应 `docs/SUB_AGENT_ORCHESTRATION_REVIEW.md`。评审原文作为审核记录保留;已接受的结论同时回写到 `docs/SUB_AGENT_ORCHESTRATION_DESIGN.md`,后者仍是后续实现的规范来源。代码级实施方案见 [`SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md`](SUB_AGENT_ORCHESTRATION_IMPLEMENTATION.md)。
|
||||
|
||||
## 1. 总体答复
|
||||
|
||||
接受评审的总体结论:方案方向成立,但 A1–A5 必须在实现前成为明确契约。全部 A、B、C 项均采纳;其中 A2、B2 和 B4 不只补充文字,还调整了原方案的数据流:
|
||||
|
||||
- durable Agent event 不进入普通 session 消息队列,而由 SQLite inbox 保存 payload、独立的合并式 wake lane 只传递“有待处理事件”的提示。
|
||||
- `/stop` 清理瞬时用户工作,但不通过丢弃内存队列来确认 durable event;事件由条件更新立即释放,lease expiry 只作为崩溃兜底。
|
||||
- 后台结果调度采用有界公平策略,UI 未读状态降为可观察性能力,不再承担防饥饿正确性。
|
||||
- inbox 容量在接纳 background run 时预留终态事件空间;signal 可以因容量不足被拒绝,completion 不能在 run 结束时才发现无处落库。
|
||||
- continuation 使用“持久化但对客户端隐藏”的内部触发消息,保证 Provider replay、事务提交和客户端渲染三者一致。
|
||||
|
||||
| 评审项 | 答复 | 设计处理 |
|
||||
|--------|------|----------|
|
||||
| A1 | 接受 | 独立 durable wake lane、claim-on-run、公平调度和有界重试 |
|
||||
| A2 | 接受 | `/stop` 条件释放、lease guard、取消 completion 的 status-only 语义 |
|
||||
| A3 | 接受 | run admission 与 step execution permit 分离 |
|
||||
| A4 | 接受 | CancellationToken 贯穿 AgentLoop;明确 `/stop` 与 `steer` 不同 |
|
||||
| A5 | 接受 | hidden trigger message、Turn origin、稳定 delivery binding |
|
||||
| B1 | 接受 | 默认隔离;persistent browser profile 是唯一显式共享路径 |
|
||||
| B2 | 接受 | 容量预留、dead-letter fallback、重试边界 |
|
||||
| B3 | 接受 | `all` 与 `each` 的事件生成和 deadline 语义 |
|
||||
| B4 | 接受 | worker 有界公平;UI 未读只负责呈现 |
|
||||
| B5 | 接受 | 未配置价格时 `cost=NULL` |
|
||||
| B6 | 接受 | sub-run sleep 只响应 timer/cancellation |
|
||||
| C1 | 接受 | 非空 event key、非空 caller scope、partial unique index |
|
||||
| C2 | 接受 | 深度、task-tree 授权、skills/memory 继承规则 |
|
||||
| C3 | 接受 | 删除不存在的 `async` 迁移别名 |
|
||||
| C4 | 接受 | 枚举 WebSocket 请求、投影和 Turn origin 变更 |
|
||||
|
||||
## 2. A 级意见答复
|
||||
|
||||
### A1 — Session 队列饱和语义
|
||||
|
||||
**答复:接受。durable event 不与用户 `AgentTask` 共用 payload mpsc。**
|
||||
|
||||
实现采用两条不同语义的 lane:
|
||||
|
||||
```text
|
||||
user task lane bounded mpsc(32),保存任务;满时明确拒绝新用户输入
|
||||
agent inbox wake lane watch revision,合并通知;payload 始终留在 SQLite
|
||||
```
|
||||
|
||||
Router 对活动 Turn 的 `steer` 使用 lease → mailbox reservation → durable admitted → activate 的两阶段 admission;reservation 在 durable 更新成功前不可被 AgentLoop 排空,且 SQLite I/O 不跨 Session 锁。失败或 `queue` 事件都恢复/保持 `pending`,只递增 wake revision。worker 在真正准备执行 continuation 时才领取 lease,不先把已 leased 的事件塞进可能被丢弃的 mpsc。
|
||||
|
||||
`watch` 只负责降低延迟:发送失败、revision 被合并或进程退出都不影响事实状态。Gateway 启动、reload 激活和周期恢复扫描会重新发现 `pending`/expired lease。普通 session 队列满不再构成 durable event 丢失或无限重试问题。
|
||||
|
||||
worker 在每个 Turn 调度边界执行有界公平:通常先处理用户任务;连续处理 4 个用户 Turn,或最老 pending event 已等待 30 秒后,必须先领取一批 continuation。它仍不能中断当前不可分割的 Turn,因此时限从下一个调度边界计算。
|
||||
|
||||
### A2 — `/stop`、worker 退出与事件恢复
|
||||
|
||||
**答复:接受。lease expiry 只能是崩溃兜底,不能是正常 `/stop` 的唯一恢复路径。**
|
||||
|
||||
调整后的链路为:
|
||||
|
||||
1. `/stop` 在关闭 TurnMailbox 时取回尚未提交的 durable event IDs。
|
||||
2. 在 session generation 失效后,以 `lease_token`/`admitted_turn_id` 条件更新把这些事件立即恢复为 `pending`。
|
||||
3. continuation 执行持有 `InboxLeaseGuard`;正常失败、取消或 stale generation 会显式 release,只有进程崩溃或任务被强制 abort 才等待 `lease_until` 到期。
|
||||
4. 普通内部 continuation 不作为 payload 存在 session mpsc 中,因此 `agent_tx.take()` 不会吞掉 leased event;worker 领取后才在本地构造 typed task source。
|
||||
5. Coordinator 独立拥有 background run。`/stop` 取消 run token 后,Coordinator 仍负责用条件事务写入 `cancelled` 终态,迟到的 completed 结果不能覆盖它。
|
||||
|
||||
由本次 `/stop` 自身造成的 cancelled completion 设为 `requires_continuation=false`:事件和 run 状态会持久化并投影到任务树,但事件在同一事务中记为 status-only consumed,不会在 `/stop` 后反向启动一个“任务已取消”的主 Agent Turn。`/stop` 前已经存在、尚未处理的 signal/completion 不被确认或删除,恢复为 pending 后仍可继续投递。
|
||||
|
||||
### A3 — `waiting_children` permit 归属
|
||||
|
||||
**答复:接受。permit 不由整个 Agent Run 持有,也不由 `delegate` 等编排工具持有。**
|
||||
|
||||
Coordinator 管理两类限制:
|
||||
|
||||
- **run admission quota**:限制树、session、Agent 的已接纳/未终态 run 数量;可以跨 `waiting_children` 持有。
|
||||
- **step execution permit**:限制当前正在占用 Provider 或普通工具执行资源的步骤;只在一个步骤期间持有。
|
||||
|
||||
AgentLoop 在每次 Provider 请求前按固定顺序获取 global → session → agent provider permits,流结束或取消后立即释放。普通工具调用由 tool executor 获取 tool permit;`delegate`、`agent_task`、`emit_signal` 等 runtime-control 工具不获取这种稀缺执行 permit。
|
||||
|
||||
foreground `delegate` 在创建 child 前通过状态 guard 把父 run 从 `running` 条件更新为 `waiting_children`,等待期间没有 provider/tool permit。children 终态后 guard 把父状态恢复为 `running`;父 Agent 的下一次模型迭代重新竞争 permit。这样即使 provider 并发上限为 1,父等待 child 也不会死锁。
|
||||
|
||||
### A4 — AgentLoop 结构化取消
|
||||
|
||||
**答复:接受,并把它提升为 Phase 2 的独立前置里程碑。**
|
||||
|
||||
`AgentLoop` 的执行入口将显式接收 cancellation context,而不是只依赖父 future 被 drop:
|
||||
|
||||
```text
|
||||
root Turn token
|
||||
└── foreground run token
|
||||
└── descendant foreground run token
|
||||
|
||||
root session token ── independently owns background run tokens
|
||||
```
|
||||
|
||||
Provider stream、可取消等待和工具批次外层都观察 token;AgentRunner 的终结路径把取消归一为类型化 `cancelled`,Coordinator 再用 execution ID 条件提交。父取消、run timeout、reload/shutdown 可以组合为任一触发即取消。
|
||||
|
||||
“不默认硬中断”只约束普通 `steer`:它等待安全边界,不取消 Provider 或副作用工具。`/stop` 保持现有强停止语义,会取消 token 并使 root Turn future 失效;未在宽限期内自行退出的独立 child task 由 Coordinator/Supervisor abort。无论 future 如何结束,terminal condition update 都阻止迟到结果提交。
|
||||
|
||||
### A5 — continuation Turn 模型
|
||||
|
||||
**答复:接受。continuation 需要同时满足 durable replay、无伪用户气泡和正常 Turn 投递。**
|
||||
|
||||
每个 continuation 生成一条内部触发消息:
|
||||
|
||||
- 数据库 role 使用 Provider 可兼容的 `user`,source 为 `agent_signal`/`agent_result`。
|
||||
- 增加 `client_visibility=hidden` 和 `turn_origin=agent_continuation`;普通历史 API 和 `turn_committed.messages` 不投影这条消息。
|
||||
- 内容是有界、带 event/run reference 的 runtime envelope,不伪造外部 sender,也不直接采用子 Agent 输出中的指令优先级。
|
||||
- Provider 历史 replay 会包含该隐藏消息,因此后续 assistant 回复不会成为无来源的悬空历史。
|
||||
- 隐藏触发消息、assistant/tool 结果、usage 和 inbox `consumed` 在同一事务中提交;失败时全部不确认。
|
||||
|
||||
客户端继续使用 `turn_updated`/`turn_committed` 展示 assistant Turn,但帧增加 `turn_origin`,从而可以显示“后台结果处理”标记且不创建用户气泡。
|
||||
|
||||
内部 Turn 的出站目标来自 root session 的 durable delivery binding:`channel`、`chat_id` 以及可复用的 thread/root 上下文。一次性 `reply_to` 不得复用。若没有可用的外部 binding,结果仍持久化并供 WebUI/TUI 历史读取,不猜测其他目标。
|
||||
|
||||
## 3. B 级意见答复
|
||||
|
||||
### B1 — browser/resource scope 隔离
|
||||
|
||||
**答复:接受。** 新具名 Agent 默认使用 `root_session_id + run_id` 的瞬时资源 scope,不继承父会话 browser cookie。需要共享登录态时,官方路径是由 Root 创建/选择经过校验的 `browser_profiles` persistent ID,并把该 ID 作为显式 task/artifact reference 交给获准使用 browser 的子 Agent;子 Agent必须在每次相关调用中显式传入该 ID。
|
||||
|
||||
为平滑迁移,由无 target 的旧调用映射出的内置 `general` 兼容 Agent 可在弃用期保留父 session transient scope;具名 Agent不继承这个例外。文档和 tool result 会明确提示两种 scope 的差异。
|
||||
|
||||
### B2 — dead letter、重试和 inbox 上限
|
||||
|
||||
**答复:接受。** 接纳 background run 时按 completion policy 预留不可抢占的终态 event slot:`each` 每个 run 一个,`all` 每个 group 一个。容量不足时 `delegate` 在创建 run 前拒绝。signal 只使用未预留容量,满时 `emit_signal` 返回 `inbox_full`,但不终止 run。因此已接纳 run 的 completion 永远不会在终结时因 inbox 满而丢失。
|
||||
|
||||
暂定恢复策略为 8 次可配置尝试,退避 `1s/5s/30s/2m/10m` 后封顶 10 分钟,并同时受 event TTL 限制。瞬时 Storage/worker/Provider continuation 失败可重试;session 已删除、授权事实失效或 payload 永久损坏立即 dead-letter。lease timeout 不单独计作永久错误,但会记录 attempt 和原因。
|
||||
|
||||
事件进入 dead-letter 后:
|
||||
|
||||
1. 保存最终原因和 `dead_lettered_at`,在任务树/API 中持续可见。
|
||||
2. 通过 OutboundDispatcher 最多发送一次有界 system fallback,内容只包含 run/group ID、终态和查询提示,不复制大结果。
|
||||
3. 用 `fallback_notified_at` 保证 fallback 幂等;渠道也失败时仍以 SQLite 记录和管理 UI 为最终可诊断出口。
|
||||
|
||||
### B3 — `completion_policy=each`
|
||||
|
||||
**答复:接受。** 语义修订为:
|
||||
|
||||
- `each`:每个 run 进入终态即创建独立 completion event;Router 可在 300–500ms debounce 窗口合并一次 continuation,但不能等待其他 sibling。
|
||||
- `all`:单 run 终态只更新 group 计数,不创建可投递 completion;全部终态或 group deadline 到达后创建一个 `group_completion` event,包含全部逐项状态和 result references。
|
||||
- group deadline 到达时,未终态 children 被取消并条件更新为 `timed_out`,随后生成唯一 group completion。
|
||||
|
||||
因此 inbox schema 允许 run-scoped 或 group-scoped event 二选一,而不是强制 `run_id NOT NULL`。
|
||||
|
||||
### B4 — UI 未读状态与防饥饿
|
||||
|
||||
**答复:接受。** 正确性由 A1 的 worker 有界公平策略保证;UI 未读只呈现尚未汇总/已 dead-letter 的事件数量,不参与调度。Phase 3 明确包含未读计数、event revision 和 reconnect 后全量校准。
|
||||
|
||||
### B5 — `cost` 与定价配置
|
||||
|
||||
**答复:接受。** Phase 2 保留 nullable `cost` 字段,但只有 Provider profile 明确提供 input/output/cache 价格时才计算;当前配置没有价格来源,因此写 `NULL`。usage token 仍照常持久化。价格配置和历史重算不属于本次编排功能的前置条件。
|
||||
|
||||
### B6 — sub-run 内 sleep
|
||||
|
||||
**答复:接受。** `TurnWakeupHandle` 只存在于 root interactive Turn。sub-run 的 `ToolExecutionContext.turn_wakeup=None`,其 `sleep` 只等待 timer、run cancellation、timeout 或 shutdown;不会监听 root session 用户输入或其他 Agent signal。需要被主 Agent立即控制时使用 `agent_task.cancel`,由 cancellation token 唤醒。
|
||||
|
||||
## 4. C 级意见答复
|
||||
|
||||
### C1 — SQLite NULL 与事件去重
|
||||
|
||||
**答复:接受。** 所有参与唯一约束的 scope/key 都规范化为非空值:
|
||||
|
||||
- background idempotency 使用 `caller_scope_id TEXT NOT NULL`;Root 固定为字面量 `ROOT`。
|
||||
- `idempotency_key` 仍可为空,但使用 `CREATE UNIQUE INDEX ... WHERE idempotency_key IS NOT NULL` 的 partial unique index。
|
||||
- 无 `dedupe_key` 的 signal 使用 `signal:<event_uuid>`。
|
||||
- 有 `dedupe_key` 的 signal 使用 `signal:<normalized-key>:<cooldown-window-id>`,只在冷却窗口内去重,不会永久压制同类告警。
|
||||
- run completion 使用固定 `completion:terminal-v1`;group completion 使用 `group-completion:terminal-v1`。
|
||||
|
||||
### C2 — 深度、task-tree 授权和上下文继承
|
||||
|
||||
**答复:接受。**
|
||||
|
||||
- 全局 `max_tree_depth` 是 root-relative 硬上限;Definition `limits.max_depth` 是该 Agent可继续创建的最大相对后代深度。child 的 remaining depth 为 `min(parent_remaining - 1, target_definition.max_depth)`,任何一项为 0 都不能继续委托。
|
||||
- `agent_task` 查询必须匹配当前 `root_session_id`。Root 可操作本 session 的整棵树;子 Agent只能读取自身与后代,只能取消其未终态后代,不能通过猜测 run ID 跨 session 或操作祖先/sibling。
|
||||
- 子 Agent不继承主会话 history、memory recall 或临时 activated skills。第一版仅在 Definition 工具集中包含 `get_skill` 时注入受信任 Skill catalog;调用方只能通过显式 task/context 传递事实。若未来开放 memory,必须新增管理员配置的只读 scope,不能默认继承。
|
||||
|
||||
### C3 — `async` 别名
|
||||
|
||||
**答复:接受。** 删除 `async → background`。迁移只接受代码中确实存在的 `inline`、`parallel`、`background`;新 prompt/schema 只公布 `foreground`、`background`。
|
||||
|
||||
### C4 — 客户端协议清单
|
||||
|
||||
**答复:接受。** 协议按通用运行投影设计,不为 Signal 单独复制一套模型:
|
||||
|
||||
- `WsInbound::GetAgentRuns { session_id, cursor, limit }`
|
||||
- `WsInbound::GetAgentRun { session_id, run_id }`
|
||||
- `WsOutbound::SessionAgentRuns { session_id, revision, runs, next_cursor }`
|
||||
- `WsOutbound::AgentRunUpdated { session_id, revision, run }`
|
||||
- `WsOutbound::AgentEventUpdated { session_id, revision, event }`
|
||||
- `TurnSnapshot` 与 `WsOutbound::TurnCommitted` 增加 `turn_origin = user | agent_continuation | scheduled`
|
||||
|
||||
`AgentEventUpdated` 同时承载 accepted、admitted、consumed、dead-letter 等状态,按 `(session_id, revision, event_id)` 幂等合并。断线重连后客户端用 `GetAgentRuns` 全量校准,实时帧只是增量。取消操作第一版继续通过 `/stop`、`agent_task.cancel` 或受保护管理 API,不额外开放一个缺少权限上下文的裸 WebSocket cancel 帧。
|
||||
|
||||
## 5. 对分期的调整
|
||||
|
||||
| Phase | 调整后的完成条件 |
|
||||
|-------|------------------|
|
||||
| 1 | 除原内容外,明确 browser 兼容 scope、skills/memory 规则和 sub-run sleep 行为 |
|
||||
| 2A | CancellationToken 贯穿 AgentLoop,先以现有 root Turn/sleep/Provider tests 锁定取消语义 |
|
||||
| 2B | run/group 持久化、step execution gate、foreground child cancellation 和结果查询 |
|
||||
| 3 | durable wake lane、capacity reservation、hidden continuation trigger、bounded fairness、dead-letter fallback 与 WebSocket run/event projection |
|
||||
| 4 | typed TurnMailbox、emit_signal、steer admission,以及同一 `AgentEventUpdated` 的 Signal 卡片呈现 |
|
||||
| 5 | root Turn wake-aware sleep;sub-run 保持 timer/cancellation-only |
|
||||
|
||||
Phase 3 上线时保留旧 direct notification 的受控兼容开关,但仅作为 rollback 手段,默认路径必须是 inbox/continuation;不能同时投递两条用户通知。开关移除前必须验证 dead-letter fallback、重启恢复和 reconnect 校准。
|
||||
|
||||
## 6. 最终结论
|
||||
|
||||
评审结论“方向通过,需修订后实现”成立。修订后的关键边界是:
|
||||
|
||||
```text
|
||||
SQLite inbox 保存事实
|
||||
├── direct steer admission → 当前 TurnMailbox
|
||||
└── coalesced wake → worker claim → hidden continuation Turn
|
||||
|
||||
普通 user mpsc 满 ≠ durable event 丢失
|
||||
/stop 丢弃瞬时用户工作 ≠ 确认 durable event
|
||||
run 存活配额 ≠ Provider/tool step permit
|
||||
UI 未读提示 ≠ 调度正确性
|
||||
```
|
||||
|
||||
在 A1–A5 的专项契约和上述 schema/protocol 调整落地前,不应开始 Phase 3/4 的生产实现。
|
||||
@ -50,7 +50,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
||||
- 每个活动 Turn 独占一个 TurnSink;平台 message ID 和 reaction 清理状态只存在于 sink 内
|
||||
- Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext`
|
||||
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry
|
||||
- 子 Agent 由 `delegate` 工具创建,复用 provider 配置和按需过滤后的工具集;后台任务结果通过 MessageBus 发回原会话
|
||||
- 具名子 Agent 从运行代不可变 `AgentCatalog` 加载,Definition 固定 Provider profile、工具/Skill allowlist、委托边与限制;新工具默认 RootOnly。Phase 1 支持单个/批量 foreground(批量并发、按请求顺序返回)和显式父子授权;具名 background 在 durable run/inbox 完成前拒绝。禁用编排时旧 general background 仍通过 MessageBus 直接通知原会话
|
||||
- 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
|
||||
- WebUI 聊天复用 `/ws` 与 `cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
|
||||
- WebUI 使用 Svelte 5 + Vite,Bits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
|
||||
@ -258,11 +258,12 @@ Gateway 初始化时读取 `config.mcp.servers`:
|
||||
|
||||
| 模式 | 行为 |
|
||||
|------|------|
|
||||
| `inline` | 当前轮阻塞等待子 Agent 返回 |
|
||||
| `background` | 后台运行,完成后通过原 channel/chat 通知 |
|
||||
| `parallel` | 多个子 Agent 并发执行并聚合结果 |
|
||||
| `foreground` | 当前轮等待一个或多个子 Agent;批量任务并发执行并按请求顺序聚合 |
|
||||
| `background` | 异步执行并返回 run ID;当前只有旧 general 兼容路径可用 |
|
||||
|
||||
默认工具集是只读工具:`file_read`、`file_search`、`content_search`、`web_fetch`、`http_request`、`calculator`。调用时可通过 `allowed_tools` 显式放开其他工具。后台任务会写入 `background_tasks` 表,默认 24 小时后清理。
|
||||
启用 `agent_orchestration` 后,具名 Definition 固定角色、Provider profile、工具/Skill allowlist、委托边和限制。`allowed_tools` 只能收窄 Definition,不能扩权;新工具默认 RootOnly,当前明确可委托的工具包括 `file_read`、`file_search`、`content_search`、`web_fetch`、`calculator`、普通 `browser` 动作和 `sleep`。具名 Agent可按委托图继续 foreground 委托,但 ancestry 重复、越深度或不在白名单的目标会拒绝。具名 background 需要后续 durable run/inbox,当前明确拒绝。
|
||||
|
||||
未启用编排或省略 target 时使用旧 general 兼容路径。其工具也只能取旧默认集合与 Delegatable 策略的交集;旧后台任务写入 `background_tasks` 表,完成后通过原 channel/chat 直接通知,默认 24 小时后清理,不具备 durable inbox 语义。
|
||||
|
||||
后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册,受 `gateway.max_concurrent_background_tasks` 限制;Gateway 关停时先收到取消信号,再在总宽限期内清理。
|
||||
|
||||
|
||||
@ -11,7 +11,8 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
||||
{
|
||||
"providers": {}, // LLM 提供商配置
|
||||
"models": {}, // 模型配置
|
||||
"agents": {}, // agent 配置
|
||||
"agents": {}, // Provider/Model profile
|
||||
"agent_orchestration": {}, // 具名子 Agent Definition 与编排上限
|
||||
"gateway": {}, // 网关配置
|
||||
"client": {}, // 客户端配置
|
||||
"channels": {}, // 渠道配置
|
||||
@ -51,6 +52,28 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
||||
| `max_tool_iterations` | int | 99 | 最大工具调用轮数 |
|
||||
| `token_limit` | int | 128000 | 上下文 token 限制 |
|
||||
|
||||
## agent_orchestration 字段
|
||||
|
||||
默认 `enabled=false`。启用后,`definitions_dir` 相对 `config.json` 所在目录解析,且不得通过绝对路径或 symlink 逃逸该受信任配置目录。Gateway 启动和热重载会严格校验全部 Markdown Definition;任一无效 Provider profile、工具、Skill 或委托目标会拒绝整个候选运行代。
|
||||
|
||||
| 字段 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `enabled` | false | 是否启用具名 Agent Catalog |
|
||||
| `definitions_dir` | agents | 第一层 `*.md` Definition 目录 |
|
||||
| `root_delegates` | [] | Root 可委托的具名 Agent ID |
|
||||
| `max_tree_depth` | 4 | Root-relative 委托深度硬上限 |
|
||||
| `max_runs_per_tree` | 16 | 单任务树 run 预算(Phase 2 持久 Coordinator 强制完整树计数) |
|
||||
| `max_concurrent_runs` / `max_concurrent_runs_per_session` | 6 / 4 | run admission 上限(Phase 2) |
|
||||
| `max_concurrent_provider_steps` / `..._per_session` | 8 / 4 | Provider step 上限(Phase 2) |
|
||||
| `max_concurrent_tool_steps` / `..._per_session` | 16 / 8 | 普通工具 step 上限(Phase 2) |
|
||||
| `max_pending_inbox_events_per_session` | 128 | durable inbox 容量(Phase 3) |
|
||||
| `inbox_event_ttl_hours` | 168 | inbox event TTL(Phase 3) |
|
||||
| `max_inbox_delivery_attempts` | 8 | inbox 最大投递次数(Phase 3) |
|
||||
| `max_user_turn_burst_before_inbox` | 4 | 用户 Turn 公平调度阈值(Phase 3) |
|
||||
| `max_inbox_wait_secs` | 30 | inbox 最大等待阈值(Phase 3) |
|
||||
|
||||
当前已实现具名 foreground Agent、不同 `llm_profile`、固定工具/Skill allowlist、批量并发和父子委托边校验。具名 background 会明确拒绝,直到 durable run/inbox 实现;未启用时旧 general background 兼容路径保持可用。
|
||||
|
||||
## gateway 字段
|
||||
|
||||
| 字段 | 类型 | 默认 | 说明 |
|
||||
|
||||
@ -137,21 +137,34 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `action` | 是 | `run`, `check_task`, `cancel_task`, `list_tasks` |
|
||||
| `prompt` | run 必填 | 子任务描述 |
|
||||
| `mode` | 否 | `inline`, `background`, `parallel`,默认 `inline` |
|
||||
| `allowed_tools` | 否 | 子 Agent 可用工具列表;默认只读工具集 |
|
||||
| `max_iterations` | 否 | 最大迭代次数,默认 99 |
|
||||
| `timeout_secs` | 否 | 超时秒数,默认 3600 |
|
||||
| `tasks` | parallel 必填 | 并行子任务数组 |
|
||||
| `action` | 否 | 默认 `run`;迁移期仍支持 `check_task`, `cancel_task`, `list_tasks` |
|
||||
| `target` | 具名 Agent 必填 | `root_delegates` 或当前 Agent Definition 允许的目标 ID |
|
||||
| `task` | 单任务必填 | 明确、独立、可验收的子任务;旧 `prompt` 仅兼容解析 |
|
||||
| `context` | 否 | 子 Agent 所需的显式事实;不会继承完整主会话历史 |
|
||||
| `mode` | 否 | `foreground`(默认)或 `background`;批量并发不是第三种 mode |
|
||||
| `tasks` | 批量必填 | 子任务数组;foreground 并发执行、结果保持请求顺序 |
|
||||
| `allowed_tools` | 否 | 迁移字段,只能收窄具名 Definition 或旧 general 默认集,不能扩权 |
|
||||
| `max_iterations` | 否 | 旧 general 兼容限制;具名 Agent使用 Definition limits |
|
||||
| `timeout_secs` | 否 | 旧 general 兼容限制;具名 Agent使用 Definition limits |
|
||||
| `task_id` | 查询/取消必填 | 后台任务 ID |
|
||||
| `plan_item_id` | 否 | 将 inline/background 子 Agent 绑定到当前计划子项;parallel 数组中的每项也可分别绑定 |
|
||||
| `plan_item_id` | 否 | 绑定当前计划子项;批量数组中的每项可分别绑定 |
|
||||
|
||||
默认只读工具集:`file_read`、`file_search`、`content_search`、`web_fetch`、`http_request`、`calculator`。
|
||||
旧 `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_task — 具名 Agent Run 查询与控制
|
||||
|
||||
仅在 `agent_orchestration` 启用时注册。查询或控制已持久化的具名 foreground run;授权来自调用者身份(ROOT 限本 session,具名 Agent 限自身子树),run ID 本身不是凭证。
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `action` | 是 | `get` 查询单个 run;`list` 列出 session 的 run;`get_result` 读取终态完整结果;`cancel` 取消未终态 run |
|
||||
| `run_id` | get/get_result/cancel 必填 | 目标 run ID |
|
||||
| `cursor_created_at` / `cursor_id` | 否 | list 分页游标,必须成对出现 |
|
||||
| `limit` | 否 | list 上限,默认 20 |
|
||||
|
||||
## todo — Session 任务计划
|
||||
|
||||
仅用于明确的复杂、多轮或并行任务。`create` 创建当前 session 唯一的 active plan;`view` 查看;`append` 增加子项;`update` 修改子项状态;`close` 完成或取消计划。普通闲聊和单步操作不应创建计划。子 Agent 始终被过滤掉 `todo` 和 `delegate`,计划结构只由主 Agent 管理。
|
||||
仅用于明确的复杂、多轮或并行任务。`create` 创建当前 session 唯一的 active plan;`view` 查看;`append` 增加子项;`update` 修改子项状态;`close` 完成或取消计划。普通闲聊和单步操作不应创建计划。子 Agent 始终不能使用 `todo`;只有 Definition 声明委托边的具名 Agent 会获得运行时注入的 `delegate`,且只能 foreground 委托允许目标。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -46,6 +46,24 @@
|
||||
"token_limit": 128000
|
||||
}
|
||||
},
|
||||
"agent_orchestration": {
|
||||
"enabled": false,
|
||||
"definitions_dir": "agents",
|
||||
"root_delegates": [],
|
||||
"max_tree_depth": 4,
|
||||
"max_runs_per_tree": 16,
|
||||
"max_concurrent_runs": 6,
|
||||
"max_concurrent_runs_per_session": 4,
|
||||
"max_concurrent_provider_steps": 8,
|
||||
"max_concurrent_provider_steps_per_session": 4,
|
||||
"max_concurrent_tool_steps": 16,
|
||||
"max_concurrent_tool_steps_per_session": 8,
|
||||
"max_pending_inbox_events_per_session": 128,
|
||||
"inbox_event_ttl_hours": 168,
|
||||
"max_inbox_delivery_attempts": 8,
|
||||
"max_user_turn_burst_before_inbox": 4,
|
||||
"max_inbox_wait_secs": 30
|
||||
},
|
||||
"gateway": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 19876,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use crate::agent::context_compressor::estimate_tokens;
|
||||
use crate::agent::media_handler::MediaHandlerRegistry;
|
||||
use crate::agent::steering::SteeringDrain;
|
||||
use crate::agent::steering::{SteeringDrain, TurnInput};
|
||||
use crate::agent::system_prompt::build_system_prompt;
|
||||
use crate::agent::turn_event::{AgentTurnContext, TurnEvent};
|
||||
use crate::bus::message::ContentBlock;
|
||||
@ -526,18 +526,63 @@ impl AgentLoop {
|
||||
&self.tools
|
||||
}
|
||||
|
||||
/// Acquire the provider-step permit for this session when an execution
|
||||
/// gate is installed. Root Turns are not Agent Runs but still share the
|
||||
/// gateway-wide step caps, so background runs cannot bypass them.
|
||||
async fn acquire_provider_permit(
|
||||
context: &ToolExecutionContext,
|
||||
cancellation: &tokio_util::sync::CancellationToken,
|
||||
) -> Result<Option<crate::agent::gate::StepPermit>, AgentError> {
|
||||
match context.execution_gate.as_ref() {
|
||||
Some(gate) => gate
|
||||
.acquire_provider(context.session_id.as_deref(), cancellation)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(|_| AgentError::Cancelled),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn acquire_tool_permit(
|
||||
context: &ToolExecutionContext,
|
||||
) -> Result<Option<crate::agent::gate::StepPermit>, AgentError> {
|
||||
match context.execution_gate.as_ref() {
|
||||
Some(gate) => gate
|
||||
.acquire_tool(context.session_id.as_deref(), &context.cancellation)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(|_| AgentError::Cancelled),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_completion(
|
||||
&self,
|
||||
request: ChatCompletionRequest,
|
||||
iteration: u32,
|
||||
turn: Option<&AgentTurnContext>,
|
||||
cancellation: &tokio_util::sync::CancellationToken,
|
||||
) -> Result<ChatCompletionResponse, AgentError> {
|
||||
let metrics = crate::observability::metrics::global_metrics();
|
||||
let provider_name = self.provider.name().to_string();
|
||||
let provider_model = self.provider.model_id().to_string();
|
||||
let start = Instant::now();
|
||||
|
||||
let mut provider_stream = match self.provider.stream(request).await {
|
||||
let stream_result = tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => {
|
||||
metrics.record_provider(
|
||||
&provider_name,
|
||||
&provider_model,
|
||||
None,
|
||||
start.elapsed().as_millis() as u64,
|
||||
true,
|
||||
);
|
||||
return Err(AgentError::Cancelled);
|
||||
}
|
||||
result = self.provider.stream(request) => result,
|
||||
};
|
||||
let mut provider_stream = match stream_result {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
tracing::error!(error = %error, "LLM request failed");
|
||||
@ -552,7 +597,22 @@ impl AgentLoop {
|
||||
}
|
||||
};
|
||||
let mut accumulator = ProviderResponseAccumulator::default();
|
||||
while let Some(chunk) = provider_stream.next().await {
|
||||
loop {
|
||||
let next = tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => {
|
||||
metrics.record_provider(
|
||||
&provider_name,
|
||||
&provider_model,
|
||||
None,
|
||||
start.elapsed().as_millis() as u64,
|
||||
true,
|
||||
);
|
||||
return Err(AgentError::Cancelled);
|
||||
}
|
||||
chunk = provider_stream.next() => chunk,
|
||||
};
|
||||
let Some(chunk) = next else { break };
|
||||
let chunk = match chunk {
|
||||
Ok(chunk) => chunk,
|
||||
Err(error) => {
|
||||
@ -609,30 +669,25 @@ impl AgentLoop {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add steering messages to the in-memory transcript in receive order.
|
||||
/// They remain ordinary `role=user` messages so every provider sees the
|
||||
/// same conversation semantics and persistence can commit them alongside
|
||||
/// the rest of this turn.
|
||||
/// Add steering inputs to the in-memory transcript in receive order.
|
||||
/// User inputs remain ordinary `role=user` messages so every provider
|
||||
/// sees the same conversation semantics and persistence can commit them
|
||||
/// alongside the rest of this turn. Agent steer inputs project to hidden
|
||||
/// user messages: the client renders the durable Signal projection, not
|
||||
/// a user bubble, while the model still sees the envelope.
|
||||
fn append_steering_messages(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
emitted_messages: &mut Vec<ChatMessage>,
|
||||
consumed_steering: &mut Vec<ChatMessage>,
|
||||
steering_messages: Vec<ChatMessage>,
|
||||
consumed_steering: &mut Vec<TurnInput>,
|
||||
steering_inputs: Vec<TurnInput>,
|
||||
turn: &AgentTurnContext,
|
||||
iteration: u32,
|
||||
) {
|
||||
for mut message in steering_messages {
|
||||
// Session routes only ordinary user input to the mailbox. Keep a
|
||||
// defensive normalization here because the mailbox is public and
|
||||
// can also be used by embedders/tests.
|
||||
message.role = "user".to_string();
|
||||
if message.turn_id.is_none() {
|
||||
message.turn_id = Some(turn.turn_id.clone());
|
||||
}
|
||||
if message.iteration.is_none() {
|
||||
message.iteration = Some(iteration);
|
||||
}
|
||||
consumed_steering.push(message.clone());
|
||||
for input in steering_inputs {
|
||||
let message = input
|
||||
.clone()
|
||||
.into_chat_message(turn.turn_id.clone(), iteration);
|
||||
consumed_steering.push(input);
|
||||
messages.push(message.clone());
|
||||
emitted_messages.push(message);
|
||||
}
|
||||
@ -644,7 +699,7 @@ impl AgentLoop {
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_steering(turn: Option<&AgentTurnContext>, consumed_steering: Vec<ChatMessage>) {
|
||||
fn restore_steering(turn: Option<&AgentTurnContext>, consumed_steering: Vec<TurnInput>) {
|
||||
if consumed_steering.is_empty() {
|
||||
return;
|
||||
}
|
||||
@ -753,6 +808,10 @@ impl AgentLoop {
|
||||
tool_context: ToolExecutionContext,
|
||||
) -> Result<AgentProcessResult, AgentError> {
|
||||
let turn_start = Instant::now();
|
||||
let cancellation = tool_context.cancellation.clone();
|
||||
if cancellation.is_cancelled() {
|
||||
return Err(AgentError::Cancelled);
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(
|
||||
@ -785,6 +844,10 @@ impl AgentLoop {
|
||||
let mut last_request_usage = None;
|
||||
|
||||
for iteration in 0..self.max_iterations {
|
||||
if cancellation.is_cancelled() {
|
||||
Self::restore_steering(turn.as_ref(), consumed_steering);
|
||||
return Err(AgentError::Cancelled);
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(iteration, "Agent iteration started");
|
||||
let last_iteration = iteration.saturating_add(1) >= self.max_iterations;
|
||||
@ -833,18 +896,22 @@ impl AgentLoop {
|
||||
return Err(AgentError::Other("tool iteration exceeds u32".to_string()));
|
||||
}
|
||||
};
|
||||
let response = match self
|
||||
.stream_completion(request, iteration, turn.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
// The invocation may be retried from persisted history.
|
||||
// Restore every steering message consumed by an earlier
|
||||
// boundary; Session decides whether to retry or close
|
||||
// and queue them after receiving this error.
|
||||
Self::restore_steering(turn.as_ref(), consumed_steering);
|
||||
return Err(error);
|
||||
let response = {
|
||||
let _provider_permit =
|
||||
Self::acquire_provider_permit(&tool_context, &cancellation).await?;
|
||||
match self
|
||||
.stream_completion(request, iteration, turn.as_ref(), &cancellation)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
// The invocation may be retried from persisted history.
|
||||
// Restore every steering message consumed by an earlier
|
||||
// boundary; Session decides whether to retry or close
|
||||
// and queue them after receiving this error.
|
||||
Self::restore_steering(turn.as_ref(), consumed_steering);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -1083,10 +1150,13 @@ impl AgentLoop {
|
||||
return Err(AgentError::Other("tool iteration exceeds u32".to_string()));
|
||||
}
|
||||
};
|
||||
match self
|
||||
.stream_completion(request, summary_iteration, turn.as_ref())
|
||||
.await
|
||||
{
|
||||
let summary_result = {
|
||||
let _provider_permit =
|
||||
Self::acquire_provider_permit(&tool_context, &cancellation).await?;
|
||||
self.stream_completion(request, summary_iteration, turn.as_ref(), &cancellation)
|
||||
.await
|
||||
};
|
||||
match summary_result {
|
||||
Ok(response) => {
|
||||
accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
|
||||
merge_usage(&mut accumulated_usage, &response.usage);
|
||||
@ -1202,7 +1272,11 @@ impl AgentLoop {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute tools in parallel using join_all.
|
||||
/// Execute tools in parallel using join_all. Cancellation wins over a
|
||||
/// completed batch: once the token fires, PicoBot no longer owns the
|
||||
/// results. Dropping the tool futures only means PicoBot stops waiting;
|
||||
/// external side effects are not rolled back and late results must be
|
||||
/// blocked by execution-ID conditional commits downstream.
|
||||
async fn execute_tools_parallel(
|
||||
&self,
|
||||
tool_calls: &[ToolCall],
|
||||
@ -1215,10 +1289,13 @@ impl AgentLoop {
|
||||
.map(|tool_call| self.execute_one_tool(tool_call, iteration, turn, context))
|
||||
.collect();
|
||||
|
||||
futures_util::future::join_all(futures)
|
||||
.await
|
||||
.into_iter()
|
||||
.collect()
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = context.cancellation.cancelled() => Err(AgentError::Cancelled),
|
||||
results = futures_util::future::join_all(futures) => {
|
||||
results.into_iter().collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute tools sequentially.
|
||||
@ -1232,10 +1309,15 @@ impl AgentLoop {
|
||||
let mut outcomes = Vec::with_capacity(tool_calls.len());
|
||||
|
||||
for tool_call in tool_calls {
|
||||
outcomes.push(
|
||||
self.execute_one_tool(tool_call, iteration, turn, context)
|
||||
.await?,
|
||||
);
|
||||
if context.cancellation.is_cancelled() {
|
||||
return Err(AgentError::Cancelled);
|
||||
}
|
||||
let outcome = tokio::select! {
|
||||
biased;
|
||||
_ = context.cancellation.cancelled() => return Err(AgentError::Cancelled),
|
||||
result = self.execute_one_tool(tool_call, iteration, turn, context) => result?,
|
||||
};
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
|
||||
Ok(outcomes)
|
||||
@ -1269,7 +1351,9 @@ impl AgentLoop {
|
||||
});
|
||||
}
|
||||
|
||||
let tool_permit = Self::acquire_tool_permit(context).await?;
|
||||
let result = self.execute_tool_internal(tool_call, context).await;
|
||||
drop(tool_permit);
|
||||
let duration = start.elapsed();
|
||||
|
||||
if let Some(turn) = turn {
|
||||
@ -1345,7 +1429,9 @@ impl AgentLoop {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::SteeringMailbox;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::agent::TurnMailbox;
|
||||
use crate::observability::{MultiObserver, Observer};
|
||||
use crate::providers::{
|
||||
ChatCompletionResponse, FinishReason, ProviderChunk, ProviderStream, Usage,
|
||||
@ -1517,9 +1603,15 @@ mod tests {
|
||||
PathBuf::from("."),
|
||||
Vec::new(),
|
||||
);
|
||||
let mailbox = Arc::new(SteeringMailbox::new());
|
||||
let mailbox = Arc::new(TurnMailbox::new());
|
||||
mailbox
|
||||
.try_push(ChatMessage::user("please include the log summary"))
|
||||
.try_push_user(crate::agent::TurnInput::user(
|
||||
"steer-1",
|
||||
"please include the log summary",
|
||||
Vec::new(),
|
||||
None,
|
||||
1_000,
|
||||
))
|
||||
.unwrap();
|
||||
let (controller, emitter, _) = TurnController::start("session", "assistant-id");
|
||||
let initial = controller.snapshot();
|
||||
@ -1565,8 +1657,16 @@ mod tests {
|
||||
PathBuf::from("."),
|
||||
Vec::new(),
|
||||
);
|
||||
let mailbox = Arc::new(SteeringMailbox::new());
|
||||
mailbox.try_push(ChatMessage::user("retry me")).unwrap();
|
||||
let mailbox = Arc::new(TurnMailbox::new());
|
||||
mailbox
|
||||
.try_push_user(crate::agent::TurnInput::user(
|
||||
"steer-1",
|
||||
"retry me",
|
||||
Vec::new(),
|
||||
None,
|
||||
1_000,
|
||||
))
|
||||
.unwrap();
|
||||
let (controller, emitter, _) = TurnController::start("session", "assistant-id");
|
||||
let initial = controller.snapshot();
|
||||
let context = AgentTurnContext::new_with_steering(
|
||||
@ -1586,7 +1686,7 @@ mod tests {
|
||||
assert!(!mailbox.is_closed());
|
||||
let restored = mailbox.take_pending();
|
||||
assert_eq!(restored.len(), 1);
|
||||
assert_eq!(restored[0].content, "retry me");
|
||||
assert_eq!(restored.user_inputs[0].content, "retry me");
|
||||
}
|
||||
|
||||
impl TestObserver {
|
||||
@ -1858,9 +1958,15 @@ mod tests {
|
||||
vec!["text".to_string(), "image".to_string()],
|
||||
);
|
||||
|
||||
let mailbox = Arc::new(SteeringMailbox::new());
|
||||
let mailbox = Arc::new(TurnMailbox::new());
|
||||
mailbox
|
||||
.try_push(ChatMessage::user("also explain what you found"))
|
||||
.try_push_user(crate::agent::TurnInput::user(
|
||||
"steer-2",
|
||||
"also explain what you found",
|
||||
Vec::new(),
|
||||
None,
|
||||
1_000,
|
||||
))
|
||||
.unwrap();
|
||||
let (controller, emitter, _) = TurnController::start("session", "assistant-id");
|
||||
let turn = controller.snapshot();
|
||||
@ -2146,20 +2252,301 @@ mod tests {
|
||||
messages.push(ChatMessage::assistant("final"));
|
||||
assert!(!should_include_message_media(&messages, 4));
|
||||
}
|
||||
|
||||
struct HangingStreamProvider;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LLMProvider for HangingStreamProvider {
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: ChatCompletionRequest,
|
||||
) -> Result<ProviderStream, crate::providers::DynProviderError> {
|
||||
Ok(Box::pin(futures_util::stream::pending()))
|
||||
}
|
||||
|
||||
fn ptype(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"hanging-stream"
|
||||
}
|
||||
|
||||
fn model_id(&self) -> &str {
|
||||
"hanging-stream"
|
||||
}
|
||||
}
|
||||
|
||||
struct HangingConnectProvider {
|
||||
connected: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LLMProvider for HangingConnectProvider {
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: ChatCompletionRequest,
|
||||
) -> Result<ProviderStream, crate::providers::DynProviderError> {
|
||||
self.connected
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
futures_util::future::pending::<()>().await;
|
||||
unreachable!("connect must be cancelled before completing")
|
||||
}
|
||||
|
||||
fn ptype(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"hanging-connect"
|
||||
}
|
||||
|
||||
fn model_id(&self) -> &str {
|
||||
"hanging-connect"
|
||||
}
|
||||
}
|
||||
|
||||
struct BarrierTool {
|
||||
barrier: Arc<tokio::sync::Notify>,
|
||||
read_only: bool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for BarrierTool {
|
||||
fn name(&self) -> &str {
|
||||
"hang"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"waits until the test releases the barrier"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({ "type": "object" })
|
||||
}
|
||||
|
||||
fn read_only(&self) -> bool {
|
||||
self.read_only
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
self.barrier.notified().await;
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: "late result".to_string(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolCallThenHangProvider {
|
||||
tool_calls: usize,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LLMProvider for ToolCallThenHangProvider {
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: ChatCompletionRequest,
|
||||
) -> Result<ProviderStream, crate::providers::DynProviderError> {
|
||||
let mut chunks = vec![ProviderChunk::Metadata {
|
||||
id: "tool-call".into(),
|
||||
model: "tool-call-hang".into(),
|
||||
}];
|
||||
for index in 0..self.tool_calls {
|
||||
chunks.push(ProviderChunk::ToolCallStart {
|
||||
index,
|
||||
id: Some(format!("call-{index}")),
|
||||
name: Some("hang".to_string()),
|
||||
});
|
||||
chunks.push(ProviderChunk::ToolCallArguments {
|
||||
index,
|
||||
delta: "{}".to_string(),
|
||||
});
|
||||
}
|
||||
chunks.push(ProviderChunk::Done(FinishReason::ToolCalls));
|
||||
Ok(Box::pin(futures_util::stream::iter(
|
||||
chunks.into_iter().map(Ok),
|
||||
)))
|
||||
}
|
||||
|
||||
fn ptype(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"tool-call-hang"
|
||||
}
|
||||
|
||||
fn model_id(&self) -> &str {
|
||||
"tool-call-hang"
|
||||
}
|
||||
}
|
||||
|
||||
fn cancellation_agent(provider: Arc<dyn LLMProvider>, tools: Arc<ToolRegistry>) -> AgentLoop {
|
||||
AgentLoop::with_provider_and_tools(
|
||||
provider,
|
||||
tools,
|
||||
4,
|
||||
"cancel-test".to_string(),
|
||||
std::env::temp_dir(),
|
||||
vec!["text".to_string()],
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_cancelled_token_returns_cancelled_before_provider() {
|
||||
let provider = Arc::new(HangingStreamProvider);
|
||||
let agent = cancellation_agent(provider.clone(), Arc::new(ToolRegistry::new()));
|
||||
let context = ToolExecutionContext::default();
|
||||
context.cancellation.cancel();
|
||||
|
||||
let error = agent
|
||||
.process_with_context(vec![ChatMessage::user("work")], context)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, AgentError::Cancelled));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_during_provider_stream_returns_cancelled() {
|
||||
let agent = cancellation_agent(
|
||||
Arc::new(HangingStreamProvider),
|
||||
Arc::new(ToolRegistry::new()),
|
||||
);
|
||||
let context = ToolExecutionContext::default();
|
||||
let token = context.cancellation.clone();
|
||||
|
||||
let process = tokio::spawn(async move {
|
||||
agent
|
||||
.process_with_context(vec![ChatMessage::user("work")], context)
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
token.cancel();
|
||||
|
||||
let error = tokio::time::timeout(Duration::from_secs(5), process)
|
||||
.await
|
||||
.expect("cancellation must end the provider stream promptly")
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, AgentError::Cancelled));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_during_provider_connect_returns_cancelled() {
|
||||
let provider = Arc::new(HangingConnectProvider {
|
||||
connected: std::sync::atomic::AtomicBool::new(false),
|
||||
});
|
||||
let agent = cancellation_agent(provider.clone(), Arc::new(ToolRegistry::new()));
|
||||
let context = ToolExecutionContext::default();
|
||||
let token = context.cancellation.clone();
|
||||
|
||||
let process = tokio::spawn(async move {
|
||||
agent
|
||||
.process_with_context(vec![ChatMessage::user("work")], context)
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
token.cancel();
|
||||
|
||||
let error = tokio::time::timeout(Duration::from_secs(5), process)
|
||||
.await
|
||||
.expect("cancellation must interrupt the provider connect")
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, AgentError::Cancelled));
|
||||
assert!(provider.connected.load(std::sync::atomic::Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_during_tool_batch_returns_cancelled_and_discards_late_result() {
|
||||
let barrier = Arc::new(tokio::sync::Notify::new());
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(BarrierTool {
|
||||
barrier: barrier.clone(),
|
||||
read_only: false,
|
||||
});
|
||||
let agent = cancellation_agent(Arc::new(ToolCallThenHangProvider { tool_calls: 1 }), tools);
|
||||
let context = ToolExecutionContext::default();
|
||||
let token = context.cancellation.clone();
|
||||
|
||||
let process = tokio::spawn(async move {
|
||||
agent
|
||||
.process_with_context(vec![ChatMessage::user("work")], context)
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
token.cancel();
|
||||
|
||||
let error = tokio::time::timeout(Duration::from_secs(5), process)
|
||||
.await
|
||||
.expect("cancellation must end the tool batch promptly")
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, AgentError::Cancelled));
|
||||
|
||||
// The cancelled batch dropped its tool futures. Releasing the
|
||||
// barrier afterwards must not surface the late result anywhere.
|
||||
barrier.notify_waiters();
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_during_parallel_tool_batch_returns_cancelled() {
|
||||
let barrier = Arc::new(tokio::sync::Notify::new());
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(BarrierTool {
|
||||
barrier: barrier.clone(),
|
||||
read_only: true,
|
||||
});
|
||||
let agent = cancellation_agent(Arc::new(ToolCallThenHangProvider { tool_calls: 2 }), tools);
|
||||
let context = ToolExecutionContext::default();
|
||||
let token = context.cancellation.clone();
|
||||
|
||||
let process = tokio::spawn(async move {
|
||||
agent
|
||||
.process_with_context(vec![ChatMessage::user("work")], context)
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
token.cancel();
|
||||
|
||||
let error = tokio::time::timeout(Duration::from_secs(5), process)
|
||||
.await
|
||||
.expect("cancellation must end the parallel tool batch promptly")
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, AgentError::Cancelled));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AgentError {
|
||||
ProviderCreation(String),
|
||||
LlmError(String),
|
||||
/// The run was cancelled by `/stop`, a parent run, timeout ownership or
|
||||
/// shutdown. Terminal state must be decided by this variant, never by
|
||||
/// matching error strings.
|
||||
Cancelled,
|
||||
/// The run exceeded its deadline.
|
||||
TimedOut,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl AgentError {
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
matches!(self, AgentError::Cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AgentError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AgentError::ProviderCreation(e) => write!(f, "Provider creation error: {}", e),
|
||||
AgentError::LlmError(e) => write!(f, "LLM error: {}", e),
|
||||
AgentError::Cancelled => write!(f, "agent run cancelled"),
|
||||
AgentError::TimedOut => write!(f, "agent run timed out"),
|
||||
AgentError::Other(e) => write!(f, "{}", e),
|
||||
}
|
||||
}
|
||||
|
||||
440
src/agent/catalog.rs
Normal file
440
src/agent/catalog.rs
Normal file
@ -0,0 +1,440 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::{AgentOrchestrationConfig, LLMProviderConfig, expand_path};
|
||||
use crate::skills::SkillsLoader;
|
||||
use crate::tools::{DelegationPolicy, ToolRegistry};
|
||||
|
||||
use super::definition::{AgentDefinition, AgentDefinitionError, parse_definition};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AgentCatalogError {
|
||||
#[error("invalid Agent orchestration config: {0}")]
|
||||
Config(String),
|
||||
#[error(transparent)]
|
||||
Definition(#[from] AgentDefinitionError),
|
||||
#[error("Agent definition directory error: {0}")]
|
||||
Directory(String),
|
||||
#[error("Agent '{agent}' references unknown Provider profile '{profile}'")]
|
||||
UnknownProfile { agent: String, profile: String },
|
||||
#[error("Agent '{agent}' references invalid tool '{tool}': {reason}")]
|
||||
InvalidTool {
|
||||
agent: String,
|
||||
tool: String,
|
||||
reason: String,
|
||||
},
|
||||
#[error("Agent '{agent}' references unknown delegate '{target}'")]
|
||||
UnknownDelegate { agent: String, target: String },
|
||||
#[error("Agent '{agent}' references unknown skill '{skill}'")]
|
||||
UnknownSkill { agent: String, skill: String },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AgentCatalog {
|
||||
definitions: BTreeMap<String, Arc<AgentDefinition>>,
|
||||
root_delegates: BTreeSet<String>,
|
||||
runtime_generation: u64,
|
||||
enabled: bool,
|
||||
max_tree_depth: u16,
|
||||
max_runs_per_tree: usize,
|
||||
}
|
||||
|
||||
impl AgentCatalog {
|
||||
pub fn legacy() -> Self {
|
||||
Self {
|
||||
definitions: BTreeMap::new(),
|
||||
root_delegates: BTreeSet::new(),
|
||||
runtime_generation: 0,
|
||||
enabled: false,
|
||||
max_tree_depth: 4,
|
||||
max_runs_per_tree: 16,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(
|
||||
config: &AgentOrchestrationConfig,
|
||||
config_dir: &Path,
|
||||
provider_profiles: &HashMap<String, LLMProviderConfig>,
|
||||
tools: &ToolRegistry,
|
||||
skills_loader: &SkillsLoader,
|
||||
runtime_generation: u64,
|
||||
) -> Result<Self, AgentCatalogError> {
|
||||
config.validate().map_err(AgentCatalogError::Config)?;
|
||||
if !config.enabled {
|
||||
return Ok(Self::legacy());
|
||||
}
|
||||
|
||||
let trusted_root = config_dir.canonicalize().map_err(|error| {
|
||||
AgentCatalogError::Directory(format!("{}: {error}", config_dir.display()))
|
||||
})?;
|
||||
let configured = expand_path(&config.definitions_dir);
|
||||
let definitions_dir = if configured.is_absolute() {
|
||||
configured
|
||||
} else {
|
||||
trusted_root.join(configured)
|
||||
};
|
||||
let definitions_dir = definitions_dir.canonicalize().map_err(|error| {
|
||||
AgentCatalogError::Directory(format!("{}: {error}", definitions_dir.display()))
|
||||
})?;
|
||||
if !definitions_dir.starts_with(&trusted_root) {
|
||||
return Err(AgentCatalogError::Directory(format!(
|
||||
"{} escapes trusted config directory {}",
|
||||
definitions_dir.display(),
|
||||
trusted_root.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut paths = definition_paths(&definitions_dir)?;
|
||||
paths.sort();
|
||||
let loaded_skills: HashSet<String> = skills_loader
|
||||
.list_skills()
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
let mut definitions = BTreeMap::new();
|
||||
|
||||
for path in paths {
|
||||
let yaml = read_profile_name(&path)?;
|
||||
let provider = provider_profiles.get(&yaml).cloned().ok_or_else(|| {
|
||||
AgentCatalogError::UnknownProfile {
|
||||
agent: path.display().to_string(),
|
||||
profile: yaml.clone(),
|
||||
}
|
||||
})?;
|
||||
let definition = Arc::new(parse_definition(&path, Arc::new(provider))?);
|
||||
if definitions.contains_key(&definition.id) {
|
||||
return Err(AgentCatalogError::Config(format!(
|
||||
"duplicate Agent id '{}'",
|
||||
definition.id
|
||||
)));
|
||||
}
|
||||
validate_definition_tools(&definition, tools)?;
|
||||
for skill in &definition.skills {
|
||||
if !loaded_skills.contains(skill) {
|
||||
return Err(AgentCatalogError::UnknownSkill {
|
||||
agent: definition.id.clone(),
|
||||
skill: skill.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !definition.skills.is_empty()
|
||||
&& !definition.tools.iter().any(|tool| tool == "get_skill")
|
||||
{
|
||||
return Err(AgentCatalogError::InvalidTool {
|
||||
agent: definition.id.clone(),
|
||||
tool: "get_skill".to_string(),
|
||||
reason: "skills require get_skill in the definition tool list".to_string(),
|
||||
});
|
||||
}
|
||||
definitions.insert(definition.id.clone(), definition);
|
||||
}
|
||||
|
||||
for definition in definitions.values() {
|
||||
for target in &definition.delegates {
|
||||
if !definitions.contains_key(target) {
|
||||
return Err(AgentCatalogError::UnknownDelegate {
|
||||
agent: definition.id.clone(),
|
||||
target: target.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let root_delegates: BTreeSet<_> = config.root_delegates.iter().cloned().collect();
|
||||
if root_delegates.len() != config.root_delegates.len() {
|
||||
return Err(AgentCatalogError::Config(
|
||||
"root_delegates contains duplicates".to_string(),
|
||||
));
|
||||
}
|
||||
for target in &root_delegates {
|
||||
if !definitions.contains_key(target) {
|
||||
return Err(AgentCatalogError::UnknownDelegate {
|
||||
agent: "ROOT".to_string(),
|
||||
target: target.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
definitions,
|
||||
root_delegates,
|
||||
runtime_generation,
|
||||
enabled: true,
|
||||
max_tree_depth: config.max_tree_depth,
|
||||
max_runs_per_tree: config.max_runs_per_tree,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
pub fn runtime_generation(&self) -> u64 {
|
||||
self.runtime_generation
|
||||
}
|
||||
|
||||
pub fn max_tree_depth(&self) -> u16 {
|
||||
self.max_tree_depth
|
||||
}
|
||||
|
||||
pub fn max_runs_per_tree(&self) -> usize {
|
||||
self.max_runs_per_tree
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<Arc<AgentDefinition>> {
|
||||
self.definitions.get(id).cloned()
|
||||
}
|
||||
|
||||
pub fn root_can_delegate(&self, target: &str) -> bool {
|
||||
self.root_delegates.contains(target)
|
||||
}
|
||||
|
||||
pub fn can_delegate(&self, caller: &str, target: &str) -> bool {
|
||||
self.definitions
|
||||
.get(caller)
|
||||
.is_some_and(|definition| definition.delegates.iter().any(|id| id == target))
|
||||
}
|
||||
|
||||
pub fn root_targets(&self) -> Vec<Arc<AgentDefinition>> {
|
||||
self.root_delegates
|
||||
.iter()
|
||||
.filter_map(|id| self.get(id))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn definition_paths(directory: &Path) -> Result<Vec<PathBuf>, AgentCatalogError> {
|
||||
let mut paths = Vec::new();
|
||||
let entries = std::fs::read_dir(directory).map_err(|error| {
|
||||
AgentCatalogError::Directory(format!("{}: {error}", directory.display()))
|
||||
})?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|error| AgentCatalogError::Directory(error.to_string()))?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
let canonical = path.canonicalize().map_err(|error| {
|
||||
AgentCatalogError::Directory(format!("{}: {error}", path.display()))
|
||||
})?;
|
||||
if !canonical.starts_with(directory) {
|
||||
return Err(AgentCatalogError::Directory(format!(
|
||||
"{} escapes definitions directory",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
paths.push(path);
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn read_profile_name(path: &Path) -> Result<String, AgentCatalogError> {
|
||||
let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"{} must be a regular non-symlink file",
|
||||
path.display()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
if metadata.len() > super::definition::MAX_DEFINITION_FILE_BYTES {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"{} exceeds the {} byte limit",
|
||||
path.display(),
|
||||
super::definition::MAX_DEFINITION_FILE_BYTES
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let content = std::fs::read_to_string(path).map_err(|source| AgentDefinitionError::Io {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
let normalized = content.replace("\r\n", "\n");
|
||||
let rest = normalized.strip_prefix("---\n").ok_or_else(|| {
|
||||
AgentDefinitionError::Invalid(format!(
|
||||
"{} must start with a standalone --- line",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let (yaml, _) = rest.split_once("\n---\n").ok_or_else(|| {
|
||||
AgentDefinitionError::Invalid(format!(
|
||||
"{} has no closing frontmatter delimiter",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ProfileOnly {
|
||||
llm_profile: String,
|
||||
}
|
||||
let parsed: ProfileOnly = serde_yaml::from_str(yaml).map_err(AgentDefinitionError::Yaml)?;
|
||||
Ok(parsed.llm_profile)
|
||||
}
|
||||
|
||||
fn validate_definition_tools(
|
||||
definition: &AgentDefinition,
|
||||
tools: &ToolRegistry,
|
||||
) -> Result<(), AgentCatalogError> {
|
||||
for name in &definition.tools {
|
||||
let tool = tools
|
||||
.get(name)
|
||||
.ok_or_else(|| AgentCatalogError::InvalidTool {
|
||||
agent: definition.id.clone(),
|
||||
tool: name.clone(),
|
||||
reason: "tool is not registered in the prepared runtime".to_string(),
|
||||
})?;
|
||||
let allowed = tool.delegation_policy() == DelegationPolicy::Delegatable
|
||||
|| (name == "get_skill"
|
||||
&& tool.delegation_policy() == DelegationPolicy::RuntimeInjected);
|
||||
if !allowed {
|
||||
return Err(AgentCatalogError::InvalidTool {
|
||||
agent: definition.id.clone(),
|
||||
tool: name.clone(),
|
||||
reason: format!("policy is {:?}", tool.delegation_policy()),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::{CalculatorTool, FileWriteTool};
|
||||
|
||||
fn provider() -> LLMProviderConfig {
|
||||
LLMProviderConfig {
|
||||
provider_type: "openai".to_string(),
|
||||
name: "test".to_string(),
|
||||
base_url: "https://example.invalid/v1".to_string(),
|
||||
api_key: "test".to_string(),
|
||||
extra_headers: HashMap::new(),
|
||||
model_id: "test-model".to_string(),
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
model_extra: HashMap::new(),
|
||||
max_tool_iterations: 99,
|
||||
token_limit: 4096,
|
||||
workspace_dir: std::env::temp_dir(),
|
||||
input_types: vec!["text".to_string()],
|
||||
price_input_per_million: None,
|
||||
price_output_per_million: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn config() -> AgentOrchestrationConfig {
|
||||
AgentOrchestrationConfig {
|
||||
enabled: true,
|
||||
definitions_dir: "agents".to_string(),
|
||||
root_delegates: vec!["researcher".to_string()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn write_agent(root: &Path, id: &str, tools: &[&str], delegates: &[&str]) {
|
||||
let tools = (!tools.is_empty()).then(|| {
|
||||
format!(
|
||||
"tools:\n{}\n",
|
||||
tools
|
||||
.iter()
|
||||
.map(|name| format!(" - {name}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
)
|
||||
});
|
||||
let delegates = (!delegates.is_empty()).then(|| {
|
||||
format!(
|
||||
"delegates:\n{}\n",
|
||||
delegates
|
||||
.iter()
|
||||
.map(|name| format!(" - {name}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
)
|
||||
});
|
||||
std::fs::write(
|
||||
root.join("agents").join(format!("{id}.md")),
|
||||
format!(
|
||||
"---\nid: {id}\ndescription: {id} role\nllm_profile: research\n{}{}---\n# Role\n\nDo the assigned work.\n",
|
||||
tools.unwrap_or_default(),
|
||||
delegates.unwrap_or_default()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_loads_provider_tools_and_delegation_graph() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join("agents")).unwrap();
|
||||
write_agent(root.path(), "researcher", &["calculator"], &["reviewer"]);
|
||||
write_agent(root.path(), "reviewer", &["calculator"], &[]);
|
||||
let tools = ToolRegistry::new();
|
||||
tools.register(CalculatorTool::new());
|
||||
let loader = SkillsLoader::new_for_testing(
|
||||
root.path().join("skills"),
|
||||
root.path().join("external-skills"),
|
||||
);
|
||||
let profiles = HashMap::from([("research".to_string(), provider())]);
|
||||
|
||||
let catalog =
|
||||
AgentCatalog::load(&config(), root.path(), &profiles, &tools, &loader, 7).unwrap();
|
||||
|
||||
assert!(catalog.root_can_delegate("researcher"));
|
||||
assert!(catalog.can_delegate("researcher", "reviewer"));
|
||||
assert_eq!(
|
||||
catalog.get("researcher").unwrap().provider_config.model_id,
|
||||
"test-model"
|
||||
);
|
||||
assert_eq!(catalog.runtime_generation(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_rejects_root_only_tool() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join("agents")).unwrap();
|
||||
write_agent(root.path(), "researcher", &["file_write"], &[]);
|
||||
let tools = ToolRegistry::new();
|
||||
tools.register(FileWriteTool::new());
|
||||
let loader = SkillsLoader::new_for_testing(
|
||||
root.path().join("skills"),
|
||||
root.path().join("external-skills"),
|
||||
);
|
||||
let profiles = HashMap::from([("research".to_string(), provider())]);
|
||||
|
||||
let error =
|
||||
AgentCatalog::load(&config(), root.path(), &profiles, &tools, &loader, 1).unwrap_err();
|
||||
|
||||
assert!(matches!(error, AgentCatalogError::InvalidTool { .. }));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn catalog_rejects_definition_symlink_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join("agents")).unwrap();
|
||||
let outside_file = outside.path().join("researcher.md");
|
||||
std::fs::write(
|
||||
&outside_file,
|
||||
"---\nid: researcher\ndescription: test\nllm_profile: research\n---\n# Role\n",
|
||||
)
|
||||
.unwrap();
|
||||
symlink(&outside_file, root.path().join("agents/researcher.md")).unwrap();
|
||||
let tools = ToolRegistry::new();
|
||||
let loader = SkillsLoader::new_for_testing(
|
||||
root.path().join("skills"),
|
||||
root.path().join("external-skills"),
|
||||
);
|
||||
let profiles = HashMap::from([("research".to_string(), provider())]);
|
||||
|
||||
assert!(
|
||||
AgentCatalog::load(&config(), root.path(), &profiles, &tools, &loader, 1,).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
1497
src/agent/coordinator.rs
Normal file
1497
src/agent/coordinator.rs
Normal file
File diff suppressed because it is too large
Load Diff
485
src/agent/definition.rs
Normal file
485
src/agent/definition.rs
Normal file
@ -0,0 +1,485 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::config::LLMProviderConfig;
|
||||
|
||||
pub const MAX_DEFINITION_FILE_BYTES: u64 = 256 * 1024;
|
||||
const MAX_DESCRIPTION_CHARS: usize = 4_096;
|
||||
const MAX_ROLE_CHARS: usize = 65_536;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct AgentLimits {
|
||||
pub timeout_secs: u64,
|
||||
pub max_iterations: usize,
|
||||
pub max_children: usize,
|
||||
pub max_depth: u16,
|
||||
pub max_concurrent_runs: usize,
|
||||
pub max_concurrent_provider_steps: usize,
|
||||
pub max_concurrent_tool_steps: usize,
|
||||
pub max_result_chars: usize,
|
||||
}
|
||||
|
||||
impl Default for AgentLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeout_secs: 900,
|
||||
max_iterations: 24,
|
||||
max_children: 4,
|
||||
max_depth: 3,
|
||||
max_concurrent_runs: 2,
|
||||
max_concurrent_provider_steps: 1,
|
||||
max_concurrent_tool_steps: 4,
|
||||
max_result_chars: 16_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal delivery lane for background Agent events.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SignalDelivery {
|
||||
#[default]
|
||||
Queue,
|
||||
Steer,
|
||||
}
|
||||
|
||||
impl SignalDelivery {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Queue => "queue",
|
||||
Self::Steer => "steer",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Durable emit_signal contract. An Agent only sees the `emit_signal` tool
|
||||
/// when this block is present; every limit below is enforced by the tool and
|
||||
/// the Coordinator, not by the model.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct SignalContract {
|
||||
pub delivery: SignalDelivery,
|
||||
/// Total number of signals one run may emit.
|
||||
pub max_total: u32,
|
||||
/// Upper bound for the serialized `details` JSON payload of one signal.
|
||||
pub max_details_bytes: usize,
|
||||
/// Minimum wall time between two signals of the same run.
|
||||
pub min_interval_ms: u64,
|
||||
/// Maximum signals emitted within `burst_window_ms`.
|
||||
pub max_burst: u32,
|
||||
pub burst_window_ms: u64,
|
||||
/// Allowlisted severities; anything else is rejected.
|
||||
pub severity_allowlist: Vec<String>,
|
||||
/// Dedupe key cooldown window; repeated keys inside the window collapse
|
||||
/// to the same event, keys outside it emit again.
|
||||
pub dedupe_cooldown_ms: u64,
|
||||
/// Maximum JSON nesting depth of `details`.
|
||||
pub max_payload_depth: usize,
|
||||
}
|
||||
|
||||
impl Default for SignalContract {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
delivery: SignalDelivery::Queue,
|
||||
max_total: 64,
|
||||
max_details_bytes: 8 * 1024,
|
||||
min_interval_ms: 500,
|
||||
max_burst: 5,
|
||||
burst_window_ms: 10_000,
|
||||
severity_allowlist: vec![
|
||||
"info".to_string(),
|
||||
"warning".to_string(),
|
||||
"critical".to_string(),
|
||||
],
|
||||
dedupe_cooldown_ms: 60_000,
|
||||
max_payload_depth: 16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SignalContract {
|
||||
fn validate(&self) -> Result<(), AgentDefinitionError> {
|
||||
if self.max_total == 0 || self.max_total > 1024 {
|
||||
return Err(AgentDefinitionError::Invalid(
|
||||
"signal.max_total must be between 1 and 1024".to_string(),
|
||||
));
|
||||
}
|
||||
if self.max_details_bytes == 0 || self.max_details_bytes > 64 * 1024 {
|
||||
return Err(AgentDefinitionError::Invalid(
|
||||
"signal.max_details_bytes must be between 1 and 65536".to_string(),
|
||||
));
|
||||
}
|
||||
if self.max_burst == 0 || self.max_burst > self.max_total {
|
||||
return Err(AgentDefinitionError::Invalid(
|
||||
"signal.max_burst must be between 1 and max_total".to_string(),
|
||||
));
|
||||
}
|
||||
if self.max_payload_depth == 0 || self.max_payload_depth > 64 {
|
||||
return Err(AgentDefinitionError::Invalid(
|
||||
"signal.max_payload_depth must be between 1 and 64".to_string(),
|
||||
));
|
||||
}
|
||||
if self.severity_allowlist.is_empty() || self.severity_allowlist.len() > 16 {
|
||||
return Err(AgentDefinitionError::Invalid(
|
||||
"signal.severity_allowlist must contain 1..=16 severities".to_string(),
|
||||
));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if let Some(duplicate) = self
|
||||
.severity_allowlist
|
||||
.iter()
|
||||
.find(|severity| !seen.insert(severity.as_str()))
|
||||
{
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"duplicate signal severity '{duplicate}'"
|
||||
)));
|
||||
}
|
||||
if self
|
||||
.severity_allowlist
|
||||
.iter()
|
||||
.any(|severity| severity.trim().is_empty() || severity.len() > 64)
|
||||
{
|
||||
return Err(AgentDefinitionError::Invalid(
|
||||
"signal severities must be non-empty and at most 64 characters".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentLimits {
|
||||
fn validate(&self) -> Result<(), AgentDefinitionError> {
|
||||
if self.timeout_secs == 0 || self.timeout_secs > 86_400 {
|
||||
return Err(AgentDefinitionError::Invalid(
|
||||
"limits.timeout_secs must be between 1 and 86400".to_string(),
|
||||
));
|
||||
}
|
||||
if self.max_iterations == 0 || self.max_iterations > 256 {
|
||||
return Err(AgentDefinitionError::Invalid(
|
||||
"limits.max_iterations must be between 1 and 256".to_string(),
|
||||
));
|
||||
}
|
||||
if self.max_children == 0 || self.max_children > 128 {
|
||||
return Err(AgentDefinitionError::Invalid(
|
||||
"limits.max_children must be between 1 and 128".to_string(),
|
||||
));
|
||||
}
|
||||
if self.max_depth == 0 || self.max_depth > 32 {
|
||||
return Err(AgentDefinitionError::Invalid(
|
||||
"limits.max_depth must be between 1 and 32".to_string(),
|
||||
));
|
||||
}
|
||||
for (name, value, hard_max) in [
|
||||
("max_concurrent_runs", self.max_concurrent_runs, 128),
|
||||
(
|
||||
"max_concurrent_provider_steps",
|
||||
self.max_concurrent_provider_steps,
|
||||
128,
|
||||
),
|
||||
(
|
||||
"max_concurrent_tool_steps",
|
||||
self.max_concurrent_tool_steps,
|
||||
512,
|
||||
),
|
||||
] {
|
||||
if value == 0 || value > hard_max {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"limits.{name} must be between 1 and {hard_max}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if self.max_result_chars == 0 || self.max_result_chars > 1_000_000 {
|
||||
return Err(AgentDefinitionError::Invalid(
|
||||
"limits.max_result_chars must be between 1 and 1000000".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AgentFrontmatter {
|
||||
id: String,
|
||||
description: String,
|
||||
llm_profile: String,
|
||||
#[serde(default)]
|
||||
tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
delegates: Vec<String>,
|
||||
#[serde(default)]
|
||||
skills: Vec<String>,
|
||||
#[serde(default)]
|
||||
limits: AgentLimits,
|
||||
#[serde(default)]
|
||||
signal: Option<SignalContract>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentDefinition {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
pub llm_profile: String,
|
||||
pub provider_config: Arc<LLMProviderConfig>,
|
||||
pub tools: Vec<String>,
|
||||
pub delegates: Vec<String>,
|
||||
pub skills: Vec<String>,
|
||||
pub limits: AgentLimits,
|
||||
pub signal_contract: Option<SignalContract>,
|
||||
pub role_prompt: String,
|
||||
pub definition_hash: String,
|
||||
pub source_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AgentDefinitionError {
|
||||
#[error("failed to read Agent definition {path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("invalid Agent definition: {0}")]
|
||||
Invalid(String),
|
||||
#[error("invalid YAML frontmatter: {0}")]
|
||||
Yaml(#[from] serde_yaml::Error),
|
||||
}
|
||||
|
||||
pub(crate) fn parse_definition(
|
||||
path: &Path,
|
||||
provider_config: Arc<LLMProviderConfig>,
|
||||
) -> Result<AgentDefinition, AgentDefinitionError> {
|
||||
let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"{} must be a regular non-symlink file",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if metadata.len() > MAX_DEFINITION_FILE_BYTES {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"{} exceeds the {} byte limit",
|
||||
path.display(),
|
||||
MAX_DEFINITION_FILE_BYTES
|
||||
)));
|
||||
}
|
||||
let content = std::fs::read_to_string(path).map_err(|source| AgentDefinitionError::Io {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
let normalized = content.replace("\r\n", "\n");
|
||||
let mut lines = normalized.lines();
|
||||
if lines.next() != Some("---") {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"{} must start with a standalone --- line",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut yaml_lines = Vec::new();
|
||||
let mut found_end = false;
|
||||
for line in &mut lines {
|
||||
if line == "---" {
|
||||
found_end = true;
|
||||
break;
|
||||
}
|
||||
yaml_lines.push(line);
|
||||
}
|
||||
if !found_end {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"{} has no closing frontmatter delimiter",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let role_prompt = lines.collect::<Vec<_>>().join("\n").trim().to_string();
|
||||
if role_prompt.is_empty() {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"{} has an empty role body",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if role_prompt.chars().count() > MAX_ROLE_CHARS {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"{} role body exceeds {MAX_ROLE_CHARS} characters",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let frontmatter: AgentFrontmatter = serde_yaml::from_str(&yaml_lines.join("\n"))?;
|
||||
validate_agent_id(&frontmatter.id)?;
|
||||
if frontmatter.description.trim().is_empty()
|
||||
|| frontmatter.description.chars().count() > MAX_DESCRIPTION_CHARS
|
||||
{
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"Agent '{}' description must contain 1..={MAX_DESCRIPTION_CHARS} characters",
|
||||
frontmatter.id
|
||||
)));
|
||||
}
|
||||
if frontmatter.llm_profile.trim().is_empty() {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"Agent '{}' has an empty llm_profile",
|
||||
frontmatter.id
|
||||
)));
|
||||
}
|
||||
frontmatter.limits.validate()?;
|
||||
if let Some(signal) = frontmatter.signal.as_ref() {
|
||||
signal.validate()?;
|
||||
}
|
||||
reject_duplicates("tools", &frontmatter.tools)?;
|
||||
reject_duplicates("delegates", &frontmatter.delegates)?;
|
||||
reject_duplicates("skills", &frontmatter.skills)?;
|
||||
|
||||
let file_stem = path.file_stem().and_then(|value| value.to_str());
|
||||
if file_stem != Some(frontmatter.id.as_str()) {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"Agent id '{}' must match file name {}",
|
||||
frontmatter.id,
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let canonical_frontmatter = serde_json::to_vec(&frontmatter)
|
||||
.map_err(|error| AgentDefinitionError::Invalid(error.to_string()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(canonical_frontmatter);
|
||||
hasher.update(b"\n---\n");
|
||||
hasher.update(role_prompt.as_bytes());
|
||||
let definition_hash = hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
|
||||
Ok(AgentDefinition {
|
||||
id: frontmatter.id,
|
||||
description: frontmatter.description.trim().to_string(),
|
||||
llm_profile: frontmatter.llm_profile,
|
||||
provider_config,
|
||||
tools: frontmatter.tools,
|
||||
delegates: frontmatter.delegates,
|
||||
skills: frontmatter.skills,
|
||||
limits: frontmatter.limits,
|
||||
signal_contract: frontmatter.signal,
|
||||
role_prompt,
|
||||
definition_hash,
|
||||
source_path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_agent_id(id: &str) -> Result<(), AgentDefinitionError> {
|
||||
let valid = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id
|
||||
.bytes()
|
||||
.next()
|
||||
.is_some_and(|value| value.is_ascii_lowercase())
|
||||
&& id.bytes().all(|value| {
|
||||
value.is_ascii_lowercase() || value.is_ascii_digit() || value == b'_' || value == b'-'
|
||||
});
|
||||
if !valid || matches!(id, "root" | "main" | "default" | "general") {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"invalid or reserved Agent id '{id}'"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_duplicates(field: &str, values: &[String]) -> Result<(), AgentDefinitionError> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if let Some(value) = values.iter().find(|value| !seen.insert(value.as_str())) {
|
||||
return Err(AgentDefinitionError::Invalid(format!(
|
||||
"duplicate {field} entry '{value}'"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn provider() -> Arc<LLMProviderConfig> {
|
||||
Arc::new(LLMProviderConfig {
|
||||
provider_type: "openai".to_string(),
|
||||
name: "test".to_string(),
|
||||
base_url: "https://example.invalid/v1".to_string(),
|
||||
api_key: "test".to_string(),
|
||||
extra_headers: HashMap::new(),
|
||||
model_id: "test-model".to_string(),
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
model_extra: HashMap::new(),
|
||||
max_tool_iterations: 99,
|
||||
token_limit: 4096,
|
||||
workspace_dir: std::env::temp_dir(),
|
||||
input_types: vec!["text".to_string()],
|
||||
price_input_per_million: None,
|
||||
price_output_per_million: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_definition_parses_and_hashes_stably() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("researcher.md");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"---\n",
|
||||
"id: researcher\n",
|
||||
"description: Research primary sources\n",
|
||||
"llm_profile: research\n",
|
||||
"tools:\n",
|
||||
" - calculator\n",
|
||||
"limits:\n",
|
||||
" max_iterations: 12\n",
|
||||
"---\n",
|
||||
"# Role\n\n",
|
||||
"Return evidence and uncertainty.\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let first = parse_definition(&path, provider()).unwrap();
|
||||
let second = parse_definition(&path, provider()).unwrap();
|
||||
|
||||
assert_eq!(first.id, "researcher");
|
||||
assert_eq!(first.limits.max_iterations, 12);
|
||||
assert_eq!(first.definition_hash, second.definition_hash);
|
||||
assert_eq!(first.definition_hash.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_frontmatter_key_is_rejected() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("researcher.md");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"---\nid: researcher\ndescription: test\nllm_profile: research\nunsafe_tools: true\n---\n# Role\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(parse_definition(&path, provider()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_and_mismatched_ids_are_rejected() {
|
||||
assert!(validate_agent_id("root").is_err());
|
||||
assert!(validate_agent_id("Uppercase").is_err());
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("researcher.md");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"---\nid: reviewer\ndescription: test\nllm_profile: research\n---\n# Role\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(parse_definition(&path, provider()).is_err());
|
||||
}
|
||||
}
|
||||
339
src/agent/gate.rs
Normal file
339
src/agent/gate.rs
Normal file
@ -0,0 +1,339 @@
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Step permits are held while a provider request or tool invocation is in
|
||||
/// flight and released as soon as the step finishes. Session permits are
|
||||
/// released before global permits (reverse acquisition order). The fields
|
||||
/// are never read; ownership alone keeps the quotas reserved (RAII).
|
||||
#[derive(Debug)]
|
||||
pub struct StepPermit {
|
||||
#[allow(dead_code)]
|
||||
session: Option<OwnedSemaphorePermit>,
|
||||
#[allow(dead_code)]
|
||||
global: OwnedSemaphorePermit,
|
||||
}
|
||||
|
||||
/// Run quota permits cover a whole Agent Run from admission until its
|
||||
/// terminal commit. Foreground delegation does not take run permits; the
|
||||
/// quota gates background admission so a waiting parent can never deadlock
|
||||
/// nested foreground children.
|
||||
#[derive(Debug)]
|
||||
pub struct RunPermit {
|
||||
#[allow(dead_code)]
|
||||
session: Option<OwnedSemaphorePermit>,
|
||||
#[allow(dead_code)]
|
||||
global: OwnedSemaphorePermit,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GateError {
|
||||
#[error("execution gate wait cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct KeyedSemaphores {
|
||||
permits: usize,
|
||||
map: DashMap<String, Weak<Semaphore>>,
|
||||
}
|
||||
|
||||
impl KeyedSemaphores {
|
||||
fn new(permits: usize) -> Self {
|
||||
Self {
|
||||
permits,
|
||||
map: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn semaphore(self: &Arc<Self>, key: &str) -> Arc<Semaphore> {
|
||||
if let Some(existing) = self.map.get(key)
|
||||
&& let Some(semaphore) = existing.upgrade()
|
||||
{
|
||||
return semaphore;
|
||||
}
|
||||
let semaphore = Arc::new(Semaphore::new(self.permits));
|
||||
self.map.insert(key.to_string(), Arc::downgrade(&semaphore));
|
||||
// Drop registry entries whose owners are gone so long-lived gateways
|
||||
// do not accumulate one semaphore per historical session.
|
||||
self.map.retain(|_, weak| weak.strong_count() > 0);
|
||||
semaphore
|
||||
}
|
||||
}
|
||||
|
||||
/// Concurrency gates shared by one runtime generation. Run quota and
|
||||
/// provider/tool step permits have independent lifecycles; acquisition order
|
||||
/// is always global -> session and release order is reversed.
|
||||
pub struct ExecutionGate {
|
||||
run_global: Arc<Semaphore>,
|
||||
run_session: Arc<KeyedSemaphores>,
|
||||
provider_global: Arc<Semaphore>,
|
||||
provider_session: Arc<KeyedSemaphores>,
|
||||
tool_global: Arc<Semaphore>,
|
||||
tool_session: Arc<KeyedSemaphores>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ExecutionGate {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ExecutionGate")
|
||||
.field("run_available", &self.run_global.available_permits())
|
||||
.field(
|
||||
"provider_available",
|
||||
&self.provider_global.available_permits(),
|
||||
)
|
||||
.field("tool_available", &self.tool_global.available_permits())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecutionGate {
|
||||
pub fn new(config: &crate::config::AgentOrchestrationConfig) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
run_global: Arc::new(Semaphore::new(config.max_concurrent_runs)),
|
||||
run_session: Arc::new(KeyedSemaphores::new(config.max_concurrent_runs_per_session)),
|
||||
provider_global: Arc::new(Semaphore::new(config.max_concurrent_provider_steps)),
|
||||
provider_session: Arc::new(KeyedSemaphores::new(
|
||||
config.max_concurrent_provider_steps_per_session,
|
||||
)),
|
||||
tool_global: Arc::new(Semaphore::new(config.max_concurrent_tool_steps)),
|
||||
tool_session: Arc::new(KeyedSemaphores::new(
|
||||
config.max_concurrent_tool_steps_per_session,
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Unlimited gate used when orchestration is disabled; root Turns still
|
||||
/// route through it so the code path stays uniform.
|
||||
pub fn unbounded() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
run_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
|
||||
run_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)),
|
||||
provider_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
|
||||
provider_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)),
|
||||
tool_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)),
|
||||
tool_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn acquire_run(
|
||||
self: &Arc<Self>,
|
||||
session_id: &str,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<RunPermit, GateError> {
|
||||
let global = acquire_owned(self.run_global.clone(), cancellation).await?;
|
||||
let session = acquire_keyed(&self.run_session, session_id, cancellation).await?;
|
||||
Ok(RunPermit {
|
||||
session: Some(session),
|
||||
global,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn acquire_provider(
|
||||
self: &Arc<Self>,
|
||||
session_id: Option<&str>,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<StepPermit, GateError> {
|
||||
let global = acquire_owned(self.provider_global.clone(), cancellation).await?;
|
||||
let session = match session_id {
|
||||
Some(session_id) => {
|
||||
Some(acquire_keyed(&self.provider_session, session_id, cancellation).await?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(StepPermit { session, global })
|
||||
}
|
||||
|
||||
pub async fn acquire_tool(
|
||||
self: &Arc<Self>,
|
||||
session_id: Option<&str>,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<StepPermit, GateError> {
|
||||
let global = acquire_owned(self.tool_global.clone(), cancellation).await?;
|
||||
let session = match session_id {
|
||||
Some(session_id) => {
|
||||
Some(acquire_keyed(&self.tool_session, session_id, cancellation).await?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(StepPermit { session, global })
|
||||
}
|
||||
|
||||
pub fn available_provider_permits(&self) -> usize {
|
||||
self.provider_global.available_permits()
|
||||
}
|
||||
|
||||
pub fn available_tool_permits(&self) -> usize {
|
||||
self.tool_global.available_permits()
|
||||
}
|
||||
}
|
||||
|
||||
async fn acquire_owned(
|
||||
semaphore: Arc<Semaphore>,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<OwnedSemaphorePermit, GateError> {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => Err(GateError::Cancelled),
|
||||
result = semaphore.acquire_owned() => {
|
||||
result.map_err(|_| GateError::Cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn acquire_keyed(
|
||||
keyed: &Arc<KeyedSemaphores>,
|
||||
key: &str,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<OwnedSemaphorePermit, GateError> {
|
||||
acquire_owned(keyed.semaphore(key), cancellation).await
|
||||
}
|
||||
|
||||
/// Test helper: wait until a predicate holds or fail after the timeout.
|
||||
#[cfg(test)]
|
||||
async fn eventually<F: Fn() -> bool>(predicate: F) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if predicate() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("condition did not hold within timeout");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::AgentOrchestrationConfig;
|
||||
|
||||
/// Global caps stay larger than session caps so a session-level waiter
|
||||
/// can never exhaust the global permits while blocked (acquisition order
|
||||
/// is global -> session by design).
|
||||
fn gate(provider_session: usize, tool_session: usize) -> Arc<ExecutionGate> {
|
||||
let config = AgentOrchestrationConfig {
|
||||
enabled: true,
|
||||
max_concurrent_runs: 8,
|
||||
max_concurrent_runs_per_session: 1,
|
||||
max_concurrent_provider_steps: 8,
|
||||
max_concurrent_provider_steps_per_session: provider_session,
|
||||
max_concurrent_tool_steps: 8,
|
||||
max_concurrent_tool_steps_per_session: tool_session,
|
||||
..Default::default()
|
||||
};
|
||||
ExecutionGate::new(&config)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_permits_are_released_after_drop() {
|
||||
let gate = gate(1, 1);
|
||||
let token = CancellationToken::new();
|
||||
let permit = gate.acquire_provider(None, &token).await.unwrap();
|
||||
assert_eq!(gate.available_provider_permits(), 7);
|
||||
drop(permit);
|
||||
assert_eq!(gate.available_provider_permits(), 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_quota_blocks_second_session_step_until_release() {
|
||||
let gate = gate(1, 8);
|
||||
let token = CancellationToken::new();
|
||||
let first = gate
|
||||
.acquire_provider(Some("cli:a:d1"), &token)
|
||||
.await
|
||||
.unwrap();
|
||||
// Global capacity remains, but the same session is capped at one.
|
||||
let blocked = tokio::spawn({
|
||||
let gate = gate.clone();
|
||||
let token = token.clone();
|
||||
async move { gate.acquire_provider(Some("cli:a:d1"), &token).await }
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert!(!blocked.is_finished());
|
||||
// A different session is unaffected.
|
||||
let other = gate
|
||||
.acquire_provider(Some("cli:b:d2"), &token)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(other);
|
||||
drop(first);
|
||||
let second = blocked.await.unwrap().unwrap();
|
||||
drop(second);
|
||||
assert_eq!(gate.available_provider_permits(), 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_aborts_permit_wait() {
|
||||
let gate = gate(8, 1);
|
||||
let token = CancellationToken::new();
|
||||
let held = gate.acquire_tool(Some("cli:a:d1"), &token).await.unwrap();
|
||||
|
||||
let waiter_token = CancellationToken::new();
|
||||
let waiter = tokio::spawn({
|
||||
let gate = gate.clone();
|
||||
let waiter_token = waiter_token.clone();
|
||||
async move { gate.acquire_tool(Some("cli:a:d1"), &waiter_token).await }
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
waiter_token.cancel();
|
||||
let error = waiter.await.unwrap().unwrap_err();
|
||||
assert!(matches!(error, GateError::Cancelled));
|
||||
drop(held);
|
||||
assert_eq!(gate.available_tool_permits(), 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_quota_is_scoped_per_session() {
|
||||
let gate = gate(8, 8);
|
||||
let token = CancellationToken::new();
|
||||
let held = gate.acquire_run("cli:a:d1", &token).await.unwrap();
|
||||
|
||||
let blocked = tokio::spawn({
|
||||
let gate = gate.clone();
|
||||
let token = token.clone();
|
||||
async move { gate.acquire_run("cli:a:d1", &token).await }
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert!(!blocked.is_finished());
|
||||
|
||||
let other = gate.acquire_run("cli:b:d2", &token).await.unwrap();
|
||||
drop(other);
|
||||
drop(held);
|
||||
blocked.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keyed_semaphores_are_reclaimed_when_idle() {
|
||||
let keyed = Arc::new(KeyedSemaphores::new(1));
|
||||
let semaphore = keyed.semaphore("cli:a:d1");
|
||||
assert_eq!(keyed.map.len(), 1);
|
||||
drop(semaphore);
|
||||
keyed.semaphore("cli:b:d2");
|
||||
assert_eq!(keyed.map.len(), 1, "idle session entry must be reclaimed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn waiting_parent_holds_no_step_permits() {
|
||||
// With a session cap of 1, a parent that merely waits for its child
|
||||
// must leave the session's single provider permit free; otherwise
|
||||
// nested foreground delegation would deadlock.
|
||||
let gate = gate(1, 1);
|
||||
let token = CancellationToken::new();
|
||||
let parent_step = gate
|
||||
.acquire_provider(Some("cli:a:d1"), &token)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(parent_step);
|
||||
// Parent is now in the waiting_children state: no permits held.
|
||||
eventually(|| gate.available_provider_permits() == 8).await;
|
||||
let child = gate
|
||||
.acquire_provider(Some("cli:a:d1"), &token)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(child);
|
||||
assert_eq!(gate.available_provider_permits(), 8);
|
||||
}
|
||||
}
|
||||
52
src/agent/inbox.rs
Normal file
52
src/agent/inbox.rs
Normal file
@ -0,0 +1,52 @@
|
||||
use std::sync::{Arc, RwLock, Weak};
|
||||
|
||||
/// Wake contract implemented by the SessionManager. The notifier holds only
|
||||
/// a weak reference so the coordinator can be dropped independently and no
|
||||
/// release cycle is formed.
|
||||
#[async_trait::async_trait]
|
||||
pub trait AgentInboxWakeTarget: Send + Sync {
|
||||
async fn wake_agent_inbox(&self, session_id: &str, revision: i64);
|
||||
}
|
||||
|
||||
/// Late-bound wake bus between the AgentCoordinator and Session workers.
|
||||
/// Event commits never wait for a wake; the durable inbox is the source of
|
||||
/// truth and a lost wake only delays the next claim until the periodic
|
||||
/// re-check.
|
||||
pub struct AgentInboxNotifier {
|
||||
target: RwLock<Option<Weak<dyn AgentInboxWakeTarget + Send + Sync>>>,
|
||||
}
|
||||
|
||||
impl Default for AgentInboxNotifier {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentInboxNotifier {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
pub fn bind(&self, target: Weak<dyn AgentInboxWakeTarget + Send + Sync>) {
|
||||
*self
|
||||
.target
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(target);
|
||||
}
|
||||
|
||||
/// Best-effort wake. A missing or dead target is not an error; the
|
||||
/// periodic scanner will claim the event later.
|
||||
pub async fn notify(&self, session_id: &str, revision: i64) {
|
||||
let target = self
|
||||
.target
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.as_ref()
|
||||
.and_then(|weak| weak.upgrade());
|
||||
if let Some(target) = target {
|
||||
target.wake_agent_inbox(session_id, revision).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,28 @@
|
||||
pub mod agent_loop;
|
||||
pub mod catalog;
|
||||
pub mod context_compressor;
|
||||
pub mod coordinator;
|
||||
pub mod definition;
|
||||
pub mod gate;
|
||||
pub mod inbox;
|
||||
pub mod media_handler;
|
||||
pub mod projection;
|
||||
pub mod run;
|
||||
pub mod steering;
|
||||
pub mod sub_agent;
|
||||
pub mod system_prompt;
|
||||
pub mod turn_event;
|
||||
|
||||
pub use agent_loop::{AgentError, AgentLoop, AgentProcessResult};
|
||||
pub use catalog::{AgentCatalog, AgentCatalogError};
|
||||
pub use context_compressor::{ContextCompressor, estimate_tokens};
|
||||
pub use steering::{SteeringDrain, SteeringMailbox, SteeringPushError};
|
||||
pub use coordinator::{AgentCoordinator, CoordinatorError};
|
||||
pub use definition::{AgentDefinition, AgentLimits};
|
||||
pub use gate::ExecutionGate;
|
||||
pub use inbox::{AgentInboxNotifier, AgentInboxWakeTarget};
|
||||
pub use projection::AgentProjectionHub;
|
||||
pub use run::{AgentBudget, AgentCaller, AgentExecutionContext};
|
||||
pub use steering::{SteeringDrain, SteeringPushError, TurnInput, TurnInputSource, TurnMailbox};
|
||||
pub use sub_agent::{
|
||||
DelegateContext, ExecutionMode, SubAgentConfig, SubAgentError, SubAgentManager, SubAgentResult,
|
||||
TaskNotification, TaskStatus,
|
||||
|
||||
74
src/agent/projection.rs
Normal file
74
src/agent/projection.rs
Normal file
@ -0,0 +1,74 @@
|
||||
//! Broadcast projection for durable Agent run/event updates.
|
||||
//!
|
||||
//! The hub follows the WorkManager plan-change broadcast pattern: the
|
||||
//! Coordinator publishes a bounded view after each commit; the gateway
|
||||
//! relays it to WebSocket clients. A lost broadcast is never fatal — the
|
||||
//! client recalibrates with `GetAgentRuns`, whose `revision` comes from
|
||||
//! `agent_session_state`.
|
||||
|
||||
use crate::protocol::{AgentEventView, AgentRunView};
|
||||
|
||||
/// One projected change for a root session. Exactly one of `run`/`event` is
|
||||
/// present.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentProjection {
|
||||
pub session_id: String,
|
||||
pub revision: i64,
|
||||
pub run: Option<AgentRunView>,
|
||||
pub event: Option<AgentEventView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentProjectionHub {
|
||||
tx: tokio::sync::broadcast::Sender<AgentProjection>,
|
||||
}
|
||||
|
||||
impl Default for AgentProjectionHub {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentProjectionHub {
|
||||
pub fn new() -> Self {
|
||||
let (tx, _) = tokio::sync::broadcast::channel(256);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<AgentProjection> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
/// Publish a run update. Best-effort: relays lag and drop the event,
|
||||
/// clients recalibrate with a full query.
|
||||
pub fn publish(&self, projection: AgentProjection) {
|
||||
let _ = self.tx.send(projection);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn publish_reaches_subscribers_and_drop_is_nonfatal() {
|
||||
let hub = AgentProjectionHub::new();
|
||||
let mut rx = hub.subscribe();
|
||||
hub.publish(AgentProjection {
|
||||
session_id: "cli:test:d".to_string(),
|
||||
revision: 1,
|
||||
run: None,
|
||||
event: None,
|
||||
});
|
||||
let received = rx.try_recv().unwrap();
|
||||
assert_eq!(received.session_id, "cli:test:d");
|
||||
// No subscribers after the first is dropped; publish must not panic.
|
||||
drop(rx);
|
||||
hub.publish(AgentProjection {
|
||||
session_id: "cli:test:d".to_string(),
|
||||
revision: 2,
|
||||
run: None,
|
||||
event: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
160
src/agent/run.rs
Normal file
160
src/agent/run.rs
Normal file
@ -0,0 +1,160 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AgentCaller {
|
||||
Root,
|
||||
Agent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentBudget {
|
||||
pub remaining_runs: usize,
|
||||
pub remaining_depth: u16,
|
||||
}
|
||||
|
||||
/// A signal this run emitted, preserved so the terminal completion payload
|
||||
/// can reference the signal IDs for cross-checking.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmittedSignal {
|
||||
pub signal_id: String,
|
||||
pub severity: String,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentExecutionContext {
|
||||
pub root_session_id: String,
|
||||
pub root_turn_id: Option<String>,
|
||||
pub run_id: String,
|
||||
/// Execution attempt identifier owning conditional state transitions in
|
||||
/// Storage. Equal to `run_id` for the first attempt.
|
||||
pub execution_id: String,
|
||||
pub group_id: Option<String>,
|
||||
pub parent_run_id: Option<String>,
|
||||
pub caller_agent_id: String,
|
||||
pub current_agent_id: String,
|
||||
/// Agent IDs already present in this execution chain, including current.
|
||||
pub ancestry: Vec<String>,
|
||||
pub depth: u16,
|
||||
pub plan_item_id: Option<String>,
|
||||
pub cancellation: CancellationToken,
|
||||
pub budget: AgentBudget,
|
||||
/// Shared admission counter across the whole delegation tree, including
|
||||
/// the root run that owns this context. Durable run accounting replaces
|
||||
/// it once the Coordinator persists runs; until then it is the only
|
||||
/// tree-wide enforcement of `max_runs_per_tree`.
|
||||
pub tree_runs: Arc<AtomicUsize>,
|
||||
/// Durable emit_signal contract; `None` means this run has no signal
|
||||
/// capability and must not see the `emit_signal` tool.
|
||||
pub signal_contract: Option<Arc<crate::agent::definition::SignalContract>>,
|
||||
/// Signals accepted by this run so far, in emit order.
|
||||
pub emitted_signals: Arc<Mutex<Vec<EmittedSignal>>>,
|
||||
}
|
||||
|
||||
impl AgentExecutionContext {
|
||||
pub fn child(
|
||||
parent: &Arc<Self>,
|
||||
run_id: String,
|
||||
target: String,
|
||||
plan_item_id: Option<String>,
|
||||
cancellation: CancellationToken,
|
||||
) -> Self {
|
||||
let mut ancestry = parent.ancestry.clone();
|
||||
ancestry.push(target.clone());
|
||||
Self {
|
||||
root_session_id: parent.root_session_id.clone(),
|
||||
root_turn_id: parent.root_turn_id.clone(),
|
||||
run_id: run_id.clone(),
|
||||
execution_id: run_id,
|
||||
group_id: None,
|
||||
parent_run_id: Some(parent.run_id.clone()),
|
||||
caller_agent_id: parent.current_agent_id.clone(),
|
||||
current_agent_id: target,
|
||||
ancestry,
|
||||
depth: parent.depth.saturating_add(1),
|
||||
plan_item_id,
|
||||
cancellation,
|
||||
budget: AgentBudget {
|
||||
remaining_runs: parent.budget.remaining_runs.saturating_sub(1),
|
||||
remaining_depth: parent.budget.remaining_depth.saturating_sub(1),
|
||||
},
|
||||
tree_runs: parent.tree_runs.clone(),
|
||||
signal_contract: parent.signal_contract.clone(),
|
||||
emitted_signals: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserve one tree-run slot for a child that has already passed the
|
||||
/// caller's budget checks. Returns the total number of runs in the tree
|
||||
/// after reservation, or `None` (with the counter restored) when the
|
||||
/// reservation would exceed `max_runs_per_tree`.
|
||||
pub fn reserve_tree_run(&self, max_runs_per_tree: usize) -> Option<usize> {
|
||||
let previous = self.tree_runs.fetch_add(1, Ordering::SeqCst);
|
||||
let total = previous.saturating_add(1);
|
||||
if total > max_runs_per_tree {
|
||||
self.tree_runs.fetch_sub(1, Ordering::SeqCst);
|
||||
return None;
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
|
||||
/// Remaining tree capacity for batch admission checks.
|
||||
pub fn remaining_tree_runs(&self, max_runs_per_tree: usize) -> usize {
|
||||
max_runs_per_tree.saturating_sub(self.tree_runs.load(Ordering::SeqCst))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn root_context() -> AgentExecutionContext {
|
||||
AgentExecutionContext {
|
||||
root_session_id: "cli:test:dialog".to_string(),
|
||||
root_turn_id: None,
|
||||
run_id: "run-root".to_string(),
|
||||
execution_id: "run-root".to_string(),
|
||||
group_id: None,
|
||||
parent_run_id: None,
|
||||
caller_agent_id: "ROOT".to_string(),
|
||||
current_agent_id: "researcher".to_string(),
|
||||
ancestry: vec!["researcher".to_string()],
|
||||
depth: 1,
|
||||
plan_item_id: None,
|
||||
cancellation: CancellationToken::new(),
|
||||
budget: AgentBudget {
|
||||
remaining_runs: 15,
|
||||
remaining_depth: 3,
|
||||
},
|
||||
tree_runs: Arc::new(AtomicUsize::new(1)),
|
||||
signal_contract: None,
|
||||
emitted_signals: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_run_reservation_is_shared_and_bounded() {
|
||||
let parent = Arc::new(root_context());
|
||||
assert_eq!(parent.remaining_tree_runs(3), 2);
|
||||
|
||||
assert_eq!(parent.reserve_tree_run(3), Some(2));
|
||||
assert_eq!(parent.reserve_tree_run(3), Some(3));
|
||||
assert_eq!(parent.reserve_tree_run(3), None);
|
||||
assert_eq!(parent.tree_runs.load(Ordering::SeqCst), 3);
|
||||
assert_eq!(parent.remaining_tree_runs(3), 0);
|
||||
|
||||
let child = AgentExecutionContext::child(
|
||||
&parent,
|
||||
"run-child".to_string(),
|
||||
"reviewer".to_string(),
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
);
|
||||
assert!(Arc::ptr_eq(&parent.tree_runs, &child.tree_runs));
|
||||
assert_eq!(child.remaining_tree_runs(3), 0);
|
||||
}
|
||||
}
|
||||
@ -1,23 +1,166 @@
|
||||
//! Bounded, session-owned mailbox for same-turn user steering.
|
||||
//! Bounded, session-owned mailbox for same-turn steering input.
|
||||
//!
|
||||
//! A mailbox is intentionally separate from the session work queue. The
|
||||
//! gateway can accept a normal user message while a turn is running and place
|
||||
//! it here; [`AgentLoop`](super::AgentLoop) drains it only at safe model
|
||||
//! boundaries (after a complete tool batch, or before deciding that a
|
||||
//! response is final). The state transition performed by
|
||||
//! [`SteeringMailbox::drain_or_close`] is atomic with respect to producers,
|
||||
//! gateway can accept a normal user message or a durable Agent steer event
|
||||
//! while a turn is running and place it here;
|
||||
//! [`AgentLoop`](super::AgentLoop) drains it only at safe model boundaries
|
||||
//! (after a complete tool batch, or before deciding that a response is
|
||||
//! final). The state transition performed by
|
||||
//! [`TurnMailbox::drain_or_close`] is atomic with respect to producers,
|
||||
//! which means an input is either accepted by the active turn or rejected so
|
||||
//! the caller can put it on the next-turn queue -- never both and never
|
||||
//! neither.
|
||||
//!
|
||||
//! Durable steer events use a two-phase admission: the session first
|
||||
//! reserves an agent-lane entry, persists the `leased → admitted` transition
|
||||
//! (with the turn id), and only then activates the reservation into the
|
||||
//! drainable queue. Reservations and drained-but-uncommitted entries carry
|
||||
//! their storage lease token so an abandoned turn can return every admitted
|
||||
//! event to `pending` instead of losing it.
|
||||
|
||||
use crate::bus::ChatMessage;
|
||||
use crate::bus::{ChatMessage, MediaRef, MessageSource};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Default maximum number of steering messages accepted by one active turn.
|
||||
pub const DEFAULT_MAX_STEERING_MESSAGES: usize = 32;
|
||||
/// Default aggregate UTF-8 byte budget for pending steering messages.
|
||||
pub const DEFAULT_MAX_STEERING_BYTES: usize = 64 * 1024;
|
||||
/// Default maximum number of user steering messages accepted by one active
|
||||
/// turn.
|
||||
pub const DEFAULT_MAX_USER_STEERING_MESSAGES: usize = 32;
|
||||
/// Default aggregate UTF-8 byte budget for pending user steering messages.
|
||||
pub const DEFAULT_MAX_USER_STEERING_BYTES: usize = 64 * 1024;
|
||||
/// Default maximum number of Agent steer events accepted by one active turn.
|
||||
pub const DEFAULT_MAX_AGENT_STEERING_MESSAGES: usize = 8;
|
||||
/// Default aggregate UTF-8 byte budget for pending Agent steer events.
|
||||
pub const DEFAULT_MAX_AGENT_STEERING_BYTES: usize = 32 * 1024;
|
||||
|
||||
/// Origin of one mailbox input.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TurnInputSource {
|
||||
User,
|
||||
AgentSignal { run_id: String, agent_id: String },
|
||||
AgentCompletion { run_id: String, agent_id: String },
|
||||
AgentGroupCompletion { group_id: String },
|
||||
}
|
||||
|
||||
impl TurnInputSource {
|
||||
pub fn is_agent(&self) -> bool {
|
||||
!matches!(self, Self::User)
|
||||
}
|
||||
}
|
||||
|
||||
/// How the input reached the mailbox. Queue inputs belong to the next Turn;
|
||||
/// only Steer entries are drained by the active Turn.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InputDelivery {
|
||||
Queue,
|
||||
Steer,
|
||||
}
|
||||
|
||||
/// One steering input retained by the active Turn.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TurnInput {
|
||||
pub id: String,
|
||||
pub sequence: u64,
|
||||
pub source: TurnInputSource,
|
||||
pub delivery: InputDelivery,
|
||||
pub content: String,
|
||||
pub media_refs: Vec<MediaRef>,
|
||||
/// Durable inbox event id; `Some` only for Agent steer events.
|
||||
pub durable_event_id: Option<String>,
|
||||
pub received_at: i64,
|
||||
/// Channel attribution for user inputs, preserved through the turn.
|
||||
pub message_source: Option<MessageSource>,
|
||||
/// Storage lease token of a durable Agent event; the mailbox keeps it so
|
||||
/// an abandoned turn can release the event back to `pending`.
|
||||
pub(crate) lease_token: Option<String>,
|
||||
}
|
||||
|
||||
impl TurnInput {
|
||||
pub fn user(
|
||||
id: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
media_refs: Vec<MediaRef>,
|
||||
message_source: Option<MessageSource>,
|
||||
received_at: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
sequence: 0,
|
||||
source: TurnInputSource::User,
|
||||
delivery: InputDelivery::Steer,
|
||||
content: content.into(),
|
||||
media_refs,
|
||||
durable_event_id: None,
|
||||
received_at,
|
||||
message_source,
|
||||
lease_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Project this input into a provider-compatible user message. Agent
|
||||
/// inputs stay hidden from client history (the Signal UI comes from the
|
||||
/// durable event projection) but keep their typed source for rendering
|
||||
/// and cancellation recovery.
|
||||
pub fn into_chat_message(self, turn_id: String, iteration: u32) -> ChatMessage {
|
||||
let (client_visibility, source) = match &self.source {
|
||||
TurnInputSource::User => {
|
||||
let visibility = crate::bus::ClientVisibility::Visible;
|
||||
let source = self.message_source.clone();
|
||||
(visibility, source)
|
||||
}
|
||||
TurnInputSource::AgentSignal { run_id, agent_id } => {
|
||||
let source = MessageSource {
|
||||
kind: crate::bus::SourceKind::AgentSignal,
|
||||
from_channel: None,
|
||||
from_session: None,
|
||||
from_user_id: None,
|
||||
system_name: None,
|
||||
task_id: self.durable_event_id.clone(),
|
||||
from_run_id: Some(run_id.clone()),
|
||||
from_agent_id: Some(agent_id.clone()),
|
||||
group_id: None,
|
||||
};
|
||||
(crate::bus::ClientVisibility::Hidden, Some(source))
|
||||
}
|
||||
TurnInputSource::AgentCompletion { run_id, agent_id } => {
|
||||
let source = MessageSource {
|
||||
kind: crate::bus::SourceKind::AgentCompletion,
|
||||
from_channel: None,
|
||||
from_session: None,
|
||||
from_user_id: None,
|
||||
system_name: None,
|
||||
task_id: self.durable_event_id.clone(),
|
||||
from_run_id: Some(run_id.clone()),
|
||||
from_agent_id: Some(agent_id.clone()),
|
||||
group_id: None,
|
||||
};
|
||||
(crate::bus::ClientVisibility::Hidden, Some(source))
|
||||
}
|
||||
TurnInputSource::AgentGroupCompletion { group_id } => {
|
||||
let source = MessageSource {
|
||||
kind: crate::bus::SourceKind::AgentGroupCompletion,
|
||||
from_channel: None,
|
||||
from_session: None,
|
||||
from_user_id: None,
|
||||
system_name: None,
|
||||
task_id: self.durable_event_id.clone(),
|
||||
from_run_id: None,
|
||||
from_agent_id: None,
|
||||
group_id: Some(group_id.clone()),
|
||||
};
|
||||
(crate::bus::ClientVisibility::Hidden, Some(source))
|
||||
}
|
||||
};
|
||||
let mut message = ChatMessage::user(self.content);
|
||||
message.id = self.id;
|
||||
message.turn_id = Some(turn_id);
|
||||
message.iteration = Some(iteration);
|
||||
message.media_refs = self.media_refs;
|
||||
message.timestamp = self.received_at;
|
||||
message.client_visibility = client_visibility;
|
||||
message.source = source;
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MailboxPhase {
|
||||
@ -28,49 +171,54 @@ enum MailboxPhase {
|
||||
#[derive(Debug)]
|
||||
struct MailboxState {
|
||||
phase: MailboxPhase,
|
||||
pending: VecDeque<ChatMessage>,
|
||||
pending_bytes: usize,
|
||||
/// Messages drained at a safe boundary but not yet committed to durable
|
||||
/// history. Keeping their count/size reserved prevents concurrent
|
||||
/// producers from filling the capacity that an error retry may need to
|
||||
/// restore.
|
||||
in_flight_messages: usize,
|
||||
in_flight_bytes: usize,
|
||||
/// Exact drained messages retained until commit. This lets Session
|
||||
/// recover a successful AgentLoop result if its subsequent persistence
|
||||
/// transaction fails.
|
||||
in_flight: VecDeque<ChatMessage>,
|
||||
pending: VecDeque<TurnInput>,
|
||||
reserved: VecDeque<TurnInput>,
|
||||
/// Drained at a safe boundary but not yet committed to durable history.
|
||||
/// Keeping their count/size reserved prevents concurrent producers from
|
||||
/// filling the capacity that an error retry may need to restore.
|
||||
in_flight: VecDeque<TurnInput>,
|
||||
}
|
||||
|
||||
/// Error returned when the active turn cannot accept a steering message.
|
||||
///
|
||||
/// The original message is returned in the error so the caller can enqueue it
|
||||
/// as a normal next-turn task without cloning or losing media metadata.
|
||||
impl MailboxState {
|
||||
fn user_lane_used(&self) -> (usize, usize) {
|
||||
let mut count = 0usize;
|
||||
let mut bytes = 0usize;
|
||||
for input in self.pending.iter().chain(self.in_flight.iter()) {
|
||||
if !input.source.is_agent() {
|
||||
count = count.saturating_add(1);
|
||||
bytes = bytes.saturating_add(input_size_bytes(input));
|
||||
}
|
||||
}
|
||||
(count, bytes)
|
||||
}
|
||||
|
||||
fn agent_lane_used(&self) -> (usize, usize) {
|
||||
let mut count = 0usize;
|
||||
let mut bytes = 0usize;
|
||||
for input in self
|
||||
.pending
|
||||
.iter()
|
||||
.chain(self.in_flight.iter())
|
||||
.chain(self.reserved.iter())
|
||||
{
|
||||
if input.source.is_agent() {
|
||||
count = count.saturating_add(1);
|
||||
bytes = bytes.saturating_add(input_size_bytes(input));
|
||||
}
|
||||
}
|
||||
(count, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned when the active turn cannot accept a steering input.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SteeringPushError {
|
||||
/// The turn has reached a terminal boundary. Route the message to the
|
||||
/// session's ordinary queue.
|
||||
Closed(Box<ChatMessage>),
|
||||
/// The mailbox is accepting input, but its bounded capacity is exhausted.
|
||||
/// Route the message to the ordinary queue (and normally notify the user).
|
||||
Full(Box<ChatMessage>),
|
||||
}
|
||||
|
||||
impl SteeringPushError {
|
||||
/// Recover the message that was rejected by [`SteeringMailbox::try_push`].
|
||||
pub fn into_message(self) -> ChatMessage {
|
||||
match self {
|
||||
Self::Closed(message) | Self::Full(message) => *message,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
matches!(self, Self::Closed(_))
|
||||
}
|
||||
|
||||
pub fn is_full(&self) -> bool {
|
||||
matches!(self, Self::Full(_))
|
||||
}
|
||||
/// The turn has reached a terminal boundary. Route the input to the
|
||||
/// session's ordinary queue (or, for durable events, keep it pending).
|
||||
Closed,
|
||||
/// The mailbox is accepting input, but its bounded lane capacity is
|
||||
/// exhausted. Route the input to the ordinary queue.
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Result of the atomic final-response boundary operation.
|
||||
@ -78,61 +226,100 @@ impl SteeringPushError {
|
||||
pub enum SteeringDrain {
|
||||
/// One or more inputs were accepted and removed from the mailbox. The
|
||||
/// mailbox remains open for a subsequent safe boundary.
|
||||
Messages(Vec<ChatMessage>),
|
||||
Messages(Vec<TurnInput>),
|
||||
/// No pending input existed. The mailbox is now closed; later producers
|
||||
/// receive [`SteeringPushError::Closed`].
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Shared state for user steering during one active AgentLoop execution.
|
||||
///
|
||||
/// Cloning a mailbox is cheap and shares the same mutex-protected state. In
|
||||
/// practice the session stores an `Arc<SteeringMailbox>` in its active-turn
|
||||
/// handle and gives another clone to [`AgentTurnContext`](super::AgentTurnContext).
|
||||
#[derive(Clone)]
|
||||
pub struct SteeringMailbox {
|
||||
state: Arc<Mutex<MailboxState>>,
|
||||
max_messages: usize,
|
||||
max_bytes: usize,
|
||||
/// What an abandoned turn returns for requeue/release handling.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MailboxTake {
|
||||
/// Pending non-durable user inputs.
|
||||
pub user_inputs: Vec<TurnInput>,
|
||||
/// Durable Agent entries activated and drained or still pending:
|
||||
/// `(event_id, lease_token)` of `admitted` events.
|
||||
pub admitted_leases: Vec<(String, String)>,
|
||||
/// Reserved-but-not-activated Agent entries: `(event_id, lease_token)`
|
||||
/// of `leased` (or admitted) events.
|
||||
pub reserved_leases: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SteeringMailbox {
|
||||
impl MailboxTake {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.user_inputs.is_empty()
|
||||
&& self.admitted_leases.is_empty()
|
||||
&& self.reserved_leases.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.user_inputs.len() + self.admitted_leases.len() + self.reserved_leases.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared state for same-turn steering during one active AgentLoop
|
||||
/// execution.
|
||||
///
|
||||
/// Cloning a mailbox is cheap and shares the same mutex-protected state. In
|
||||
/// practice the session stores an `Arc<TurnMailbox>` in its active-turn
|
||||
/// handle and gives another clone to [`AgentTurnContext`](super::AgentTurnContext).
|
||||
#[derive(Clone)]
|
||||
pub struct TurnMailbox {
|
||||
state: Arc<Mutex<MailboxState>>,
|
||||
max_user_messages: usize,
|
||||
max_user_bytes: usize,
|
||||
max_agent_messages: usize,
|
||||
max_agent_bytes: usize,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TurnMailbox {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
formatter
|
||||
.debug_struct("SteeringMailbox")
|
||||
.debug_struct("TurnMailbox")
|
||||
.field("phase", &state.phase)
|
||||
.field("pending_messages", &state.pending.len())
|
||||
.field("pending_bytes", &state.pending_bytes)
|
||||
.field("max_messages", &self.max_messages)
|
||||
.field("max_bytes", &self.max_bytes)
|
||||
.field("pending", &state.pending.len())
|
||||
.field("reserved", &state.reserved.len())
|
||||
.field("in_flight", &state.in_flight.len())
|
||||
.field("max_user_messages", &self.max_user_messages)
|
||||
.field("max_agent_messages", &self.max_agent_messages)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SteeringMailbox {
|
||||
/// Construct a mailbox using the product defaults (32 messages/64 KiB).
|
||||
impl TurnMailbox {
|
||||
/// Construct a mailbox using the product defaults (32 user/8 agent).
|
||||
pub fn new() -> Self {
|
||||
Self::with_limits(DEFAULT_MAX_STEERING_MESSAGES, DEFAULT_MAX_STEERING_BYTES)
|
||||
Self::with_limits(
|
||||
DEFAULT_MAX_USER_STEERING_MESSAGES,
|
||||
DEFAULT_MAX_USER_STEERING_BYTES,
|
||||
DEFAULT_MAX_AGENT_STEERING_MESSAGES,
|
||||
DEFAULT_MAX_AGENT_STEERING_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
/// Construct a mailbox with explicit bounded capacities. Zero limits are
|
||||
/// allowed and make every push return [`SteeringPushError::Full`].
|
||||
pub fn with_limits(max_messages: usize, max_bytes: usize) -> Self {
|
||||
/// Construct a mailbox with explicit bounded capacities. Zero limits
|
||||
/// are allowed and make every push return [`SteeringPushError::Full`].
|
||||
pub fn with_limits(
|
||||
max_user_messages: usize,
|
||||
max_user_bytes: usize,
|
||||
max_agent_messages: usize,
|
||||
max_agent_bytes: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(MailboxState {
|
||||
phase: MailboxPhase::Accepting,
|
||||
pending: VecDeque::new(),
|
||||
pending_bytes: 0,
|
||||
in_flight_messages: 0,
|
||||
in_flight_bytes: 0,
|
||||
reserved: VecDeque::new(),
|
||||
in_flight: VecDeque::new(),
|
||||
})),
|
||||
max_messages,
|
||||
max_bytes,
|
||||
max_user_messages,
|
||||
max_user_bytes,
|
||||
max_agent_messages,
|
||||
max_agent_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
@ -141,44 +328,99 @@ impl SteeringMailbox {
|
||||
Arc::new(Self::new())
|
||||
}
|
||||
|
||||
/// Return an `Arc` suitable for storing in Session with explicit limits.
|
||||
pub fn shared_with_limits(max_messages: usize, max_bytes: usize) -> Arc<Self> {
|
||||
Arc::new(Self::with_limits(max_messages, max_bytes))
|
||||
}
|
||||
|
||||
/// Try to accept one real user [`ChatMessage`].
|
||||
/// Try to accept one real user steering input.
|
||||
///
|
||||
/// This operation and the final close operation use the same mutex. A
|
||||
/// producer racing with `drain_or_close` therefore receives a deterministic
|
||||
/// result and can route a rejected message to the ordinary queue.
|
||||
pub fn try_push(&self, message: ChatMessage) -> Result<(), SteeringPushError> {
|
||||
let message_bytes = message_size_bytes(&message);
|
||||
/// producer racing with `drain_or_close` therefore receives a
|
||||
/// deterministic result and can route a rejected input to the ordinary
|
||||
/// queue.
|
||||
pub fn try_push_user(&self, input: TurnInput) -> Result<(), SteeringPushError> {
|
||||
debug_assert!(!input.source.is_agent());
|
||||
let input_bytes = input_size_bytes(&input);
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if state.phase == MailboxPhase::Closed {
|
||||
return Err(SteeringPushError::Closed(Box::new(message)));
|
||||
return Err(SteeringPushError::Closed);
|
||||
}
|
||||
if state.pending.len().saturating_add(state.in_flight_messages) >= self.max_messages
|
||||
|| state
|
||||
.pending_bytes
|
||||
.saturating_add(state.in_flight_bytes)
|
||||
.saturating_add(message_bytes)
|
||||
> self.max_bytes
|
||||
let (count, bytes) = state.user_lane_used();
|
||||
if count >= self.max_user_messages
|
||||
|| bytes.saturating_add(input_bytes) > self.max_user_bytes
|
||||
{
|
||||
return Err(SteeringPushError::Full(Box::new(message)));
|
||||
return Err(SteeringPushError::Full);
|
||||
}
|
||||
state.pending_bytes = state.pending_bytes.saturating_add(message_bytes);
|
||||
state.pending.push_back(message);
|
||||
state.pending.push_back(input);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reserve an Agent steer event for the active Turn (two-phase admission,
|
||||
/// step 2). The entry is not drainable until
|
||||
/// [`activate_reserved`](Self::activate_reserved) succeeds for the same
|
||||
/// Turn. `lease_token` is the storage token of the `leased` event.
|
||||
pub fn try_reserve_steer(
|
||||
&self,
|
||||
input: TurnInput,
|
||||
lease_token: String,
|
||||
) -> Result<(), SteeringPushError> {
|
||||
debug_assert!(input.source.is_agent());
|
||||
let input_bytes = input_size_bytes(&input);
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if state.phase == MailboxPhase::Closed {
|
||||
return Err(SteeringPushError::Closed);
|
||||
}
|
||||
let (count, bytes) = state.agent_lane_used();
|
||||
if count >= self.max_agent_messages
|
||||
|| bytes.saturating_add(input_bytes) > self.max_agent_bytes
|
||||
{
|
||||
return Err(SteeringPushError::Full);
|
||||
}
|
||||
let mut input = input;
|
||||
input.lease_token = Some(lease_token);
|
||||
state.reserved.push_back(input);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Activate all reservations into the drainable queue. Returns the
|
||||
/// number activated. Callers invoke this only after the durable
|
||||
/// `leased → admitted` transition succeeded for the same Turn.
|
||||
pub fn activate_reserved(&self) -> usize {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let count = state.reserved.len();
|
||||
let reserved: Vec<_> = state.reserved.drain(..).collect();
|
||||
state.pending.extend(reserved);
|
||||
count
|
||||
}
|
||||
|
||||
/// Remove all reservations without activating them. Returns
|
||||
/// `(event_id, lease_token)` pairs so the caller can release the
|
||||
/// still-leased events back to `pending`.
|
||||
pub fn cancel_reserved(&self) -> Vec<(String, String)> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state
|
||||
.reserved
|
||||
.drain(..)
|
||||
.filter_map(|input| {
|
||||
let token = input.lease_token.clone()?;
|
||||
Some((input.durable_event_id.unwrap_or_default(), token))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drain currently pending inputs while leaving the mailbox open.
|
||||
///
|
||||
/// This is used after a complete tool-call batch. It intentionally does
|
||||
/// not close the mailbox: another input may steer a later iteration.
|
||||
pub fn drain(&self) -> Vec<ChatMessage> {
|
||||
pub fn drain(&self) -> Vec<TurnInput> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
@ -212,26 +454,36 @@ impl SteeringMailbox {
|
||||
state.phase = MailboxPhase::Closed;
|
||||
}
|
||||
|
||||
/// Close acceptance and return all pending messages. This is convenient
|
||||
/// for cancellation/error paths where the caller immediately owns the
|
||||
/// rejected messages. Any in-flight batch is intentionally discarded;
|
||||
/// `/stop` uses this method to preserve its queue-clearing semantics.
|
||||
pub fn close_and_take_pending(&self) -> Vec<ChatMessage> {
|
||||
/// Close acceptance and collect everything for requeue/release handling.
|
||||
/// Any in-flight batch is returned through `admitted_leases`; `/stop`
|
||||
/// uses this method to preserve its queue-clearing semantics for user
|
||||
/// input while still releasing durable events back to `pending`.
|
||||
pub fn close_and_take_pending(&self) -> MailboxTake {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.phase = MailboxPhase::Closed;
|
||||
// Cancellation is an explicit discard boundary. Any in-flight
|
||||
// messages that were already drained belong to this cancelled turn
|
||||
// and must not reserve capacity forever.
|
||||
take_pending_locked(&mut state)
|
||||
take_all_locked(&mut state)
|
||||
}
|
||||
|
||||
/// Restore all messages drained by AgentLoop since the last commit. This
|
||||
/// is useful when the AgentLoop completed but Session's durable write then
|
||||
/// failed: the next retry/queue operation can replay the exact accepted
|
||||
/// inputs instead of silently losing them.
|
||||
/// Take pending inputs without changing whether producers may still push.
|
||||
///
|
||||
/// Normally used after `close()`; keeping this method explicit makes it
|
||||
/// possible for Session to transfer accepted-but-unprocessed input to its
|
||||
/// FIFO queue without opening a race with a new turn.
|
||||
pub fn take_pending(&self) -> MailboxTake {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
take_all_locked(&mut state)
|
||||
}
|
||||
|
||||
/// Restore all inputs drained by AgentLoop since the last commit. This
|
||||
/// is useful when the AgentLoop completed but Session's durable write
|
||||
/// then failed: the next retry/queue operation can replay the exact
|
||||
/// accepted inputs instead of silently losing them.
|
||||
pub fn restore_drained(&self) {
|
||||
let mut state = self
|
||||
.state
|
||||
@ -240,33 +492,29 @@ impl SteeringMailbox {
|
||||
restore_in_flight_locked(&mut state);
|
||||
}
|
||||
|
||||
/// Restore messages drained by AgentLoop when a provider/tool error makes
|
||||
/// the current invocation retry from persisted history. The messages are
|
||||
/// Restore inputs drained by AgentLoop when a provider/tool error makes
|
||||
/// the current invocation retry from persisted history. The inputs are
|
||||
/// prepended in their original order and their reserved capacity is
|
||||
/// released. `drain()`/`drain_or_close()` reserve capacity while a batch is
|
||||
/// in-flight, so this operation cannot overflow a bounded mailbox due to a
|
||||
/// racing producer.
|
||||
pub fn restore_front(&self, messages: Vec<ChatMessage>) {
|
||||
if messages.is_empty() {
|
||||
/// released. `drain()`/`drain_or_close()` reserve capacity while a batch
|
||||
/// is in-flight, so this operation cannot overflow a bounded mailbox due
|
||||
/// to a racing producer.
|
||||
pub fn restore_front(&self, inputs: Vec<TurnInput>) {
|
||||
if inputs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let restored_bytes = messages.iter().map(message_size_bytes).sum::<usize>();
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.in_flight_messages = state.in_flight_messages.saturating_sub(messages.len());
|
||||
state.in_flight_bytes = state.in_flight_bytes.saturating_sub(restored_bytes);
|
||||
for _ in 0..messages.len() {
|
||||
state.in_flight.pop_front();
|
||||
for _ in 0..inputs.len() {
|
||||
state.in_flight.pop_back();
|
||||
}
|
||||
for message in messages.into_iter().rev() {
|
||||
state.pending.push_front(message);
|
||||
for input in inputs.into_iter().rev() {
|
||||
state.pending.push_front(input);
|
||||
}
|
||||
state.pending_bytes = state.pending_bytes.saturating_add(restored_bytes);
|
||||
}
|
||||
|
||||
/// Mark all previously drained messages as durably committed. Session
|
||||
/// Mark all previously drained inputs as durably committed. Session
|
||||
/// calls this only after the complete Turn persistence transaction
|
||||
/// succeeds. It is a no-op when no steering batch was consumed.
|
||||
pub fn commit_drained(&self) {
|
||||
@ -274,22 +522,21 @@ impl SteeringMailbox {
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.in_flight_messages = 0;
|
||||
state.in_flight_bytes = 0;
|
||||
state.in_flight.clear();
|
||||
}
|
||||
|
||||
/// Take pending inputs without changing whether producers may still push.
|
||||
///
|
||||
/// Normally used after `close()`; keeping this method explicit makes it
|
||||
/// possible for Session to transfer accepted-but-unprocessed input to its
|
||||
/// FIFO queue without opening a race with a new turn.
|
||||
pub fn take_pending(&self) -> Vec<ChatMessage> {
|
||||
let mut state = self
|
||||
/// Durable event ids currently drained into this Turn but not yet
|
||||
/// committed. Session consumes them atomically with the Turn commit.
|
||||
pub fn durable_in_flight_ids(&self) -> Vec<String> {
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
take_pending_locked(&mut state)
|
||||
state
|
||||
.in_flight
|
||||
.iter()
|
||||
.filter_map(|input| input.durable_event_id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
@ -312,77 +559,78 @@ impl SteeringMailbox {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
pub fn max_messages(&self) -> usize {
|
||||
self.max_messages
|
||||
pub fn max_user_messages(&self) -> usize {
|
||||
self.max_user_messages
|
||||
}
|
||||
|
||||
pub fn max_bytes(&self) -> usize {
|
||||
self.max_bytes
|
||||
pub fn max_agent_messages(&self) -> usize {
|
||||
self.max_agent_messages
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SteeringMailbox {
|
||||
impl Default for TurnMailbox {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Approximate the bounded payload size without serializing the complete
|
||||
/// message. Content, media paths/types, tool metadata and source fields are
|
||||
/// all untrusted input; counting their UTF-8 bytes gives a conservative enough
|
||||
/// guard while retaining the original message losslessly.
|
||||
fn message_size_bytes(message: &ChatMessage) -> usize {
|
||||
let mut bytes = message.id.len()
|
||||
+ message.role.len()
|
||||
+ message.content.len()
|
||||
+ message.reasoning_content.as_deref().map_or(0, str::len)
|
||||
+ message.turn_id.as_deref().map_or(0, str::len)
|
||||
+ message.tool_call_id.as_deref().map_or(0, str::len)
|
||||
+ message.tool_name.as_deref().map_or(0, str::len);
|
||||
for media in &message.media_refs {
|
||||
/// input. Content, media paths/types and source fields are all untrusted
|
||||
/// input; counting their UTF-8 bytes gives a conservative enough guard while
|
||||
/// retaining the original input losslessly.
|
||||
fn input_size_bytes(input: &TurnInput) -> usize {
|
||||
let mut bytes = input.id.len()
|
||||
+ input.content.len()
|
||||
+ input.durable_event_id.as_deref().map_or(0, str::len)
|
||||
+ input.lease_token.as_deref().map_or(0, str::len);
|
||||
for media in &input.media_refs {
|
||||
bytes = bytes.saturating_add(media.path.len() + media.media_type.len());
|
||||
}
|
||||
if let Some(tool_calls) = &message.tool_calls {
|
||||
for call in tool_calls {
|
||||
bytes = bytes
|
||||
.saturating_add(call.id.len())
|
||||
.saturating_add(call.name.len())
|
||||
.saturating_add(call.arguments.to_string().len());
|
||||
}
|
||||
if let Some(source) = input.message_source.as_ref() {
|
||||
bytes = bytes
|
||||
.saturating_add(source.from_channel.as_deref().map_or(0, str::len))
|
||||
.saturating_add(source.from_user_id.as_deref().map_or(0, str::len));
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
fn drain_pending_locked(state: &mut MailboxState) -> Vec<ChatMessage> {
|
||||
let messages: Vec<_> = state.pending.drain(..).collect();
|
||||
let bytes = messages.iter().map(message_size_bytes).sum::<usize>();
|
||||
state.pending_bytes = state.pending_bytes.saturating_sub(bytes);
|
||||
state.in_flight_messages = state.in_flight_messages.saturating_add(messages.len());
|
||||
state.in_flight_bytes = state.in_flight_bytes.saturating_add(bytes);
|
||||
state.in_flight.extend(messages.iter().cloned());
|
||||
messages
|
||||
fn drain_pending_locked(state: &mut MailboxState) -> Vec<TurnInput> {
|
||||
let inputs: Vec<_> = state.pending.drain(..).collect();
|
||||
state.in_flight.extend(inputs.iter().cloned());
|
||||
inputs
|
||||
}
|
||||
|
||||
fn take_pending_locked(state: &mut MailboxState) -> Vec<ChatMessage> {
|
||||
state.pending_bytes = 0;
|
||||
state.in_flight_messages = 0;
|
||||
state.in_flight_bytes = 0;
|
||||
state.in_flight.clear();
|
||||
state.pending.drain(..).collect()
|
||||
fn take_all_locked(state: &mut MailboxState) -> MailboxTake {
|
||||
let mut take = MailboxTake::default();
|
||||
for input in state.pending.drain(..).chain(state.in_flight.drain(..)) {
|
||||
if input.source.is_agent() {
|
||||
if let Some(token) = input.lease_token.clone()
|
||||
&& let Some(event_id) = input.durable_event_id.clone()
|
||||
{
|
||||
take.admitted_leases.push((event_id, token));
|
||||
}
|
||||
} else {
|
||||
take.user_inputs.push(input);
|
||||
}
|
||||
}
|
||||
for input in state.reserved.drain(..) {
|
||||
if let Some(token) = input.lease_token.clone()
|
||||
&& let Some(event_id) = input.durable_event_id.clone()
|
||||
{
|
||||
take.reserved_leases.push((event_id, token));
|
||||
}
|
||||
}
|
||||
take
|
||||
}
|
||||
|
||||
fn restore_in_flight_locked(state: &mut MailboxState) {
|
||||
if state.in_flight.is_empty() {
|
||||
return;
|
||||
}
|
||||
let messages: Vec<_> = state.in_flight.drain(..).collect();
|
||||
let bytes = messages.iter().map(message_size_bytes).sum::<usize>();
|
||||
state.in_flight_messages = 0;
|
||||
state.in_flight_bytes = 0;
|
||||
for message in messages.into_iter().rev() {
|
||||
state.pending.push_front(message);
|
||||
let inputs: Vec<_> = state.in_flight.drain(..).collect();
|
||||
for input in inputs.into_iter().rev() {
|
||||
state.pending.push_front(input);
|
||||
}
|
||||
state.pending_bytes = state.pending_bytes.saturating_add(bytes);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -391,99 +639,165 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
fn user_input(id: &str, content: &str) -> TurnInput {
|
||||
TurnInput::user(id, content, Vec::new(), None, 1_000)
|
||||
}
|
||||
|
||||
fn agent_signal_input(id: &str, event_id: &str) -> TurnInput {
|
||||
TurnInput {
|
||||
id: id.to_string(),
|
||||
sequence: 0,
|
||||
source: TurnInputSource::AgentSignal {
|
||||
run_id: "run-1".to_string(),
|
||||
agent_id: "researcher".to_string(),
|
||||
},
|
||||
delivery: InputDelivery::Steer,
|
||||
content: "signal summary".to_string(),
|
||||
media_refs: Vec::new(),
|
||||
durable_event_id: Some(event_id.to_string()),
|
||||
received_at: 1_000,
|
||||
message_source: None,
|
||||
lease_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_fifo_messages_and_clone_shares_state() {
|
||||
let mailbox = SteeringMailbox::with_limits(2, 100);
|
||||
fn accepts_fifo_inputs_and_clone_shares_state() {
|
||||
let mailbox = TurnMailbox::with_limits(2, 100, 8, 100);
|
||||
let clone = mailbox.clone();
|
||||
mailbox.try_push(ChatMessage::user("one")).unwrap();
|
||||
clone.try_push(ChatMessage::user("two")).unwrap();
|
||||
mailbox.try_push_user(user_input("a", "one")).unwrap();
|
||||
clone.try_push_user(user_input("b", "two")).unwrap();
|
||||
assert_eq!(mailbox.len(), 2);
|
||||
let messages = mailbox.drain();
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| m.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["one", "two"]
|
||||
);
|
||||
let inputs = mailbox.drain();
|
||||
assert_eq!(inputs.len(), 2);
|
||||
assert!(!mailbox.is_closed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_full_message_without_losing_it() {
|
||||
let mailbox = SteeringMailbox::with_limits(1, 10_000);
|
||||
mailbox.try_push(ChatMessage::user("first")).unwrap();
|
||||
let second = ChatMessage::user("second");
|
||||
let error = mailbox.try_push(second.clone()).unwrap_err();
|
||||
assert!(error.is_full());
|
||||
assert_eq!(error.into_message().content, second.content);
|
||||
fn user_and_agent_lanes_have_independent_capacity() {
|
||||
let mailbox = TurnMailbox::with_limits(1, 100_000, 1, 100_000);
|
||||
mailbox.try_push_user(user_input("a", "one")).unwrap();
|
||||
assert!(matches!(
|
||||
mailbox.try_push_user(user_input("b", "two")),
|
||||
Err(SteeringPushError::Full)
|
||||
));
|
||||
mailbox
|
||||
.try_reserve_steer(agent_signal_input("s1", "evt-1"), "token-1".to_string())
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
mailbox.try_reserve_steer(agent_signal_input("s2", "evt-2"), "token-2".to_string()),
|
||||
Err(SteeringPushError::Full)
|
||||
));
|
||||
assert_eq!(mailbox.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_limit_is_bounded() {
|
||||
let mailbox = SteeringMailbox::with_limits(8, 3);
|
||||
let message = ChatMessage::user("four");
|
||||
fn reserved_entries_are_not_drainable_until_activation() {
|
||||
let mailbox = TurnMailbox::new();
|
||||
mailbox
|
||||
.try_reserve_steer(agent_signal_input("s1", "evt-1"), "token-1".to_string())
|
||||
.unwrap();
|
||||
assert!(mailbox.drain().is_empty());
|
||||
assert_eq!(mailbox.activate_reserved(), 1);
|
||||
assert_eq!(mailbox.drain().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_reserved_returns_leases() {
|
||||
let mailbox = TurnMailbox::new();
|
||||
mailbox
|
||||
.try_reserve_steer(agent_signal_input("s1", "evt-1"), "token-1".to_string())
|
||||
.unwrap();
|
||||
let leases = mailbox.cancel_reserved();
|
||||
assert_eq!(leases, vec![("evt-1".to_string(), "token-1".to_string())]);
|
||||
assert!(mailbox.drain().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_take_releases_admitted_and_reserved_durable_events() {
|
||||
let mailbox = TurnMailbox::new();
|
||||
mailbox
|
||||
.try_reserve_steer(agent_signal_input("s1", "evt-1"), "token-1".to_string())
|
||||
.unwrap();
|
||||
mailbox.activate_reserved();
|
||||
mailbox
|
||||
.try_reserve_steer(agent_signal_input("s2", "evt-2"), "token-2".to_string())
|
||||
.unwrap();
|
||||
let drained = mailbox.drain();
|
||||
assert_eq!(drained.len(), 1);
|
||||
mailbox.try_push_user(user_input("u", "hello")).unwrap();
|
||||
|
||||
let take = mailbox.close_and_take_pending();
|
||||
// evt-1 was drained (admitted), evt-2 still reserved (leased), the
|
||||
// user input is returned separately.
|
||||
assert_eq!(take.user_inputs.len(), 1);
|
||||
assert_eq!(
|
||||
take.admitted_leases,
|
||||
vec![("evt-1".to_string(), "token-1".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
take.reserved_leases,
|
||||
vec![("evt-2".to_string(), "token-2".to_string())]
|
||||
);
|
||||
assert!(mailbox.try_push_user(user_input("late", "x")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drained_capacity_is_reserved_until_commit_or_restore() {
|
||||
let mailbox = TurnMailbox::with_limits(1, 100_000, 8, 100_000);
|
||||
mailbox.try_push_user(user_input("a", "first")).unwrap();
|
||||
let drained = mailbox.drain();
|
||||
assert_eq!(drained.len(), 1);
|
||||
assert!(matches!(
|
||||
mailbox.try_push(message),
|
||||
Err(SteeringPushError::Full(_))
|
||||
mailbox.try_push_user(user_input("b", "second")),
|
||||
Err(SteeringPushError::Full)
|
||||
));
|
||||
mailbox.restore_front(drained);
|
||||
let take = mailbox.take_pending();
|
||||
assert_eq!(take.user_inputs[0].content, "first");
|
||||
|
||||
mailbox.try_push_user(user_input("c", "committed")).unwrap();
|
||||
let _ = mailbox.drain();
|
||||
mailbox.commit_drained();
|
||||
mailbox
|
||||
.try_push_user(user_input("d", "after commit"))
|
||||
.unwrap();
|
||||
|
||||
let drained = mailbox.drain();
|
||||
assert_eq!(drained[0].content, "after commit");
|
||||
mailbox.restore_drained();
|
||||
let take = mailbox.take_pending();
|
||||
assert_eq!(take.user_inputs[0].content, "after commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_or_close_is_atomic_and_preserves_close_race_semantics() {
|
||||
let mailbox = Arc::new(SteeringMailbox::new());
|
||||
let mailbox = Arc::new(TurnMailbox::new());
|
||||
let producer = mailbox.clone();
|
||||
let close_result = thread::spawn(move || producer.drain_or_close())
|
||||
.join()
|
||||
.unwrap();
|
||||
assert!(matches!(close_result, SteeringDrain::Closed));
|
||||
let message = ChatMessage::user("late");
|
||||
assert!(matches!(
|
||||
mailbox.try_push(message),
|
||||
Err(SteeringPushError::Closed(_))
|
||||
mailbox.try_push_user(user_input("late", "x")),
|
||||
Err(SteeringPushError::Closed)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_or_close_drains_but_keeps_accepting_when_non_empty() {
|
||||
let mailbox = SteeringMailbox::new();
|
||||
mailbox.try_push(ChatMessage::user("first")).unwrap();
|
||||
let result = mailbox.drain_or_close();
|
||||
assert!(matches!(result, SteeringDrain::Messages(_)));
|
||||
assert!(!mailbox.is_closed());
|
||||
mailbox.try_push(ChatMessage::user("second")).unwrap();
|
||||
assert_eq!(mailbox.drain()[0].content, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_keeps_pending_for_next_turn_fallback() {
|
||||
let mailbox = SteeringMailbox::new();
|
||||
mailbox.try_push(ChatMessage::user("defer")).unwrap();
|
||||
mailbox.close();
|
||||
assert!(mailbox.try_push(ChatMessage::user("late")).is_err());
|
||||
assert_eq!(mailbox.take_pending()[0].content, "defer");
|
||||
assert!(mailbox.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drained_capacity_is_reserved_until_commit_or_restore() {
|
||||
let mailbox = SteeringMailbox::with_limits(1, 10_000);
|
||||
mailbox.try_push(ChatMessage::user("first")).unwrap();
|
||||
let drained = mailbox.drain();
|
||||
assert_eq!(drained.len(), 1);
|
||||
assert!(mailbox.try_push(ChatMessage::user("second")).is_err());
|
||||
mailbox.restore_front(drained);
|
||||
assert_eq!(mailbox.take_pending()[0].content, "first");
|
||||
|
||||
mailbox.try_push(ChatMessage::user("committed")).unwrap();
|
||||
let _ = mailbox.drain();
|
||||
mailbox.commit_drained();
|
||||
mailbox.try_push(ChatMessage::user("after commit")).unwrap();
|
||||
|
||||
let drained = mailbox.drain();
|
||||
assert_eq!(drained[0].content, "after commit");
|
||||
mailbox.restore_drained();
|
||||
assert_eq!(mailbox.take_pending()[0].content, "after commit");
|
||||
fn turn_input_projects_to_hidden_agent_message() {
|
||||
let input = agent_signal_input("id", "evt-1");
|
||||
let message = input.into_chat_message("turn-1".to_string(), 2);
|
||||
assert_eq!(message.role, "user");
|
||||
assert_eq!(message.turn_id.as_deref(), Some("turn-1"));
|
||||
assert_eq!(message.iteration, Some(2));
|
||||
assert_eq!(
|
||||
message.client_visibility,
|
||||
crate::bus::ClientVisibility::Hidden
|
||||
);
|
||||
let source = message.source.unwrap();
|
||||
assert!(matches!(source.kind, crate::bus::SourceKind::AgentSignal));
|
||||
assert_eq!(source.from_run_id.as_deref(), Some("run-1"));
|
||||
assert_eq!(source.task_id.as_deref(), Some("evt-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,7 +42,9 @@ const DEFAULT_READONLY_TOOLS: &[&str] = &[
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubAgentConfig {
|
||||
pub target: Option<String>,
|
||||
pub prompt: String,
|
||||
pub context: Option<String>,
|
||||
pub mode: ExecutionMode,
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
pub max_iterations: Option<usize>,
|
||||
@ -53,16 +55,18 @@ pub struct SubAgentConfig {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExecutionMode {
|
||||
Inline,
|
||||
Foreground,
|
||||
Background,
|
||||
Parallel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubAgentResult {
|
||||
pub task_id: String,
|
||||
/// Bounded projection returned to the model; may carry a truncation note.
|
||||
pub content: String,
|
||||
pub content_truncated: bool,
|
||||
/// Untruncated final text, persisted as the durable run result.
|
||||
pub full_content: String,
|
||||
pub status: TaskStatus,
|
||||
pub tool_calls_count: usize,
|
||||
pub iterations: usize,
|
||||
@ -127,6 +131,29 @@ pub struct SubAgentManager {
|
||||
work_manager: Option<Arc<crate::work::WorkManager>>,
|
||||
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
||||
admission: crate::gateway::reload::RuntimeAdmission,
|
||||
catalog: Arc<crate::agent::AgentCatalog>,
|
||||
execution_gate: Arc<crate::agent::gate::ExecutionGate>,
|
||||
/// Late-bound durable Coordinator. Signals are only available to runs
|
||||
/// whose definition carries a signal contract AND the runtime has an
|
||||
/// active Coordinator; resolution happens at delegate time.
|
||||
coordinator: std::sync::RwLock<Option<std::sync::Weak<super::coordinator::AgentCoordinator>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ResolvedAgentRun {
|
||||
pub provider_config: Arc<LLMProviderConfig>,
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub timeout_secs: u64,
|
||||
pub max_iterations: usize,
|
||||
pub max_result_chars: usize,
|
||||
pub role_prompt: Option<String>,
|
||||
pub skills_prompt: Option<String>,
|
||||
pub tool_context: ToolExecutionContext,
|
||||
/// Named-definition metadata used by the durable Coordinator; `None` for
|
||||
/// the legacy transient general Agent.
|
||||
pub agent_id: Option<String>,
|
||||
pub definition_hash: Option<String>,
|
||||
pub llm_profile: Option<String>,
|
||||
}
|
||||
|
||||
impl SubAgentManager {
|
||||
@ -151,9 +178,27 @@ impl SubAgentManager {
|
||||
work_manager: None,
|
||||
task_supervisor,
|
||||
admission: crate::gateway::reload::RuntimeAdmission::open(),
|
||||
catalog: Arc::new(crate::agent::AgentCatalog::legacy()),
|
||||
execution_gate: crate::agent::gate::ExecutionGate::unbounded(),
|
||||
coordinator: std::sync::RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind the durable Coordinator so named runs can resolve the
|
||||
/// contract-bound `emit_signal` tool. Kept as a weak reference: the
|
||||
/// Coordinator owns this manager, so a strong cycle must never exist.
|
||||
pub fn bind_coordinator(&self, coordinator: &Arc<super::coordinator::AgentCoordinator>) {
|
||||
*self.coordinator.write().unwrap() = Some(Arc::downgrade(coordinator));
|
||||
}
|
||||
|
||||
fn coordinator(&self) -> Option<Arc<super::coordinator::AgentCoordinator>> {
|
||||
self.coordinator
|
||||
.read()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.and_then(std::sync::Weak::upgrade)
|
||||
}
|
||||
|
||||
pub(crate) fn with_admission(
|
||||
mut self,
|
||||
admission: crate::gateway::reload::RuntimeAdmission,
|
||||
@ -162,6 +207,23 @@ impl SubAgentManager {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_catalog(mut self, catalog: Arc<crate::agent::AgentCatalog>) -> Self {
|
||||
self.catalog = catalog;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_execution_gate(
|
||||
mut self,
|
||||
execution_gate: Arc<crate::agent::gate::ExecutionGate>,
|
||||
) -> Self {
|
||||
self.execution_gate = execution_gate;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn catalog(&self) -> Arc<crate::agent::AgentCatalog> {
|
||||
self.catalog.clone()
|
||||
}
|
||||
|
||||
pub fn with_work_manager(mut self, work_manager: Arc<crate::work::WorkManager>) -> Self {
|
||||
self.work_manager = Some(work_manager);
|
||||
self
|
||||
@ -175,9 +237,7 @@ impl SubAgentManager {
|
||||
let filtered = ToolRegistry::new();
|
||||
for (name, tool) in self.full_tools.iter() {
|
||||
if allowed_set.contains(name.as_str())
|
||||
&& name != "delegate"
|
||||
&& name != "todo"
|
||||
&& name != "reload_config"
|
||||
&& tool.delegation_policy() == crate::tools::DelegationPolicy::Delegatable
|
||||
{
|
||||
filtered.register_raw(name, tool);
|
||||
}
|
||||
@ -196,12 +256,247 @@ impl SubAgentManager {
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_agent(
|
||||
&self,
|
||||
config: &SubAgentConfig,
|
||||
caller: &ToolExecutionContext,
|
||||
task_id: &str,
|
||||
) -> Result<ResolvedAgentRun, SubAgentError> {
|
||||
let Some(target) = config.target.as_deref() else {
|
||||
if caller.agent.is_some() {
|
||||
return Err(SubAgentError::Other(
|
||||
"named child Agents cannot use the legacy general Agent".to_string(),
|
||||
));
|
||||
}
|
||||
let browser_session_id = config
|
||||
.session_id
|
||||
.clone()
|
||||
.or_else(|| caller.session_id.clone())
|
||||
.or_else(|| {
|
||||
get_delegate_context()
|
||||
.ok()
|
||||
.map(|context| context.session_id)
|
||||
})
|
||||
.unwrap_or_else(|| format!("sub-agent:{task_id}"));
|
||||
let tools = self.filter_tools(&config.allowed_tools);
|
||||
return Ok(ResolvedAgentRun {
|
||||
provider_config: Arc::new(self.provider_config.clone()),
|
||||
skills_prompt: self.get_skills_prompt(&tools),
|
||||
tools,
|
||||
timeout_secs: config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS),
|
||||
max_iterations: config.max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS),
|
||||
max_result_chars: MAX_INLINE_RESULT_CHARS,
|
||||
role_prompt: None,
|
||||
tool_context: ToolExecutionContext::for_session(browser_session_id)
|
||||
.with_cancellation(caller.cancellation.child_token())
|
||||
.with_execution_gate(self.execution_gate.clone()),
|
||||
agent_id: None,
|
||||
definition_hash: None,
|
||||
llm_profile: None,
|
||||
});
|
||||
};
|
||||
|
||||
if !self.catalog.enabled() {
|
||||
return Err(SubAgentError::Other(format!(
|
||||
"named Agent '{target}' requested while agent_orchestration is disabled"
|
||||
)));
|
||||
}
|
||||
let definition = self
|
||||
.catalog
|
||||
.get(target)
|
||||
.ok_or_else(|| SubAgentError::Other(format!("unknown Agent target '{target}'")))?;
|
||||
let root_session_id = caller
|
||||
.agent
|
||||
.as_ref()
|
||||
.map(|context| context.root_session_id.clone())
|
||||
.or_else(|| caller.session_id.clone())
|
||||
.ok_or_else(|| {
|
||||
SubAgentError::Other(
|
||||
"delegate requires a session-bound ToolExecutionContext".to_string(),
|
||||
)
|
||||
})?;
|
||||
let cancellation = caller.cancellation.child_token();
|
||||
let execution = if let Some(parent) = caller.agent.as_ref() {
|
||||
if !self.catalog.can_delegate(&parent.current_agent_id, target) {
|
||||
return Err(SubAgentError::Other(format!(
|
||||
"Agent '{}' is not allowed to delegate to '{target}'",
|
||||
parent.current_agent_id
|
||||
)));
|
||||
}
|
||||
if parent.ancestry.iter().any(|agent| agent == target) {
|
||||
return Err(SubAgentError::Other(format!(
|
||||
"delegation cycle rejected: '{target}' is already in the current ancestry"
|
||||
)));
|
||||
}
|
||||
if parent.budget.remaining_runs == 0 || parent.budget.remaining_depth == 0 {
|
||||
return Err(SubAgentError::Other(
|
||||
"delegation budget exhausted".to_string(),
|
||||
));
|
||||
}
|
||||
let next_depth = parent.depth.saturating_add(1);
|
||||
if next_depth > self.catalog.max_tree_depth() {
|
||||
return Err(SubAgentError::Other(format!(
|
||||
"delegation depth {next_depth} exceeds global limit {}",
|
||||
self.catalog.max_tree_depth()
|
||||
)));
|
||||
}
|
||||
if parent
|
||||
.reserve_tree_run(self.catalog.max_runs_per_tree())
|
||||
.is_none()
|
||||
{
|
||||
return Err(SubAgentError::Other(format!(
|
||||
"delegation tree already uses {} runs; max_runs_per_tree is {}",
|
||||
self.catalog.max_runs_per_tree(),
|
||||
self.catalog.max_runs_per_tree()
|
||||
)));
|
||||
}
|
||||
let mut child = crate::agent::AgentExecutionContext::child(
|
||||
parent,
|
||||
task_id.to_string(),
|
||||
target.to_string(),
|
||||
config.plan_item_id.clone(),
|
||||
cancellation.clone(),
|
||||
);
|
||||
child.budget.remaining_depth = child
|
||||
.budget
|
||||
.remaining_depth
|
||||
.min(definition.limits.max_depth);
|
||||
child.signal_contract = definition
|
||||
.signal_contract
|
||||
.as_ref()
|
||||
.map(|contract| Arc::new(contract.clone()));
|
||||
Arc::new(child)
|
||||
} else {
|
||||
if !self.catalog.root_can_delegate(target) {
|
||||
return Err(SubAgentError::Other(format!(
|
||||
"ROOT is not allowed to delegate to '{target}'"
|
||||
)));
|
||||
}
|
||||
Arc::new(crate::agent::AgentExecutionContext {
|
||||
root_session_id: root_session_id.clone(),
|
||||
root_turn_id: caller.turn_id.clone(),
|
||||
run_id: task_id.to_string(),
|
||||
execution_id: task_id.to_string(),
|
||||
group_id: None,
|
||||
parent_run_id: None,
|
||||
caller_agent_id: "ROOT".to_string(),
|
||||
current_agent_id: target.to_string(),
|
||||
ancestry: vec![target.to_string()],
|
||||
depth: 1,
|
||||
plan_item_id: config.plan_item_id.clone(),
|
||||
cancellation: cancellation.clone(),
|
||||
budget: crate::agent::AgentBudget {
|
||||
remaining_runs: self.catalog.max_runs_per_tree().saturating_sub(1),
|
||||
remaining_depth: self
|
||||
.catalog
|
||||
.max_tree_depth()
|
||||
.saturating_sub(1)
|
||||
.min(definition.limits.max_depth),
|
||||
},
|
||||
tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(1)),
|
||||
signal_contract: definition
|
||||
.signal_contract
|
||||
.as_ref()
|
||||
.map(|contract| Arc::new(contract.clone())),
|
||||
emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
})
|
||||
};
|
||||
|
||||
let mut effective_names = definition.tools.clone();
|
||||
if let Some(allowed) = config.allowed_tools.as_ref() {
|
||||
effective_names.retain(|name| allowed.iter().any(|allowed| allowed == name));
|
||||
}
|
||||
let has_get_skill = effective_names.iter().any(|name| name == "get_skill");
|
||||
let mut names = effective_names;
|
||||
names.retain(|name| name != "get_skill");
|
||||
let mut runtime_tools = Vec::new();
|
||||
let skills_prompt = if has_get_skill {
|
||||
let loader = self
|
||||
.skills_loader
|
||||
.as_ref()
|
||||
.ok_or_else(|| SubAgentError::Other("skills loader is unavailable".to_string()))?;
|
||||
runtime_tools.push(Arc::new(crate::tools::GetSkillTool::scoped(
|
||||
loader.clone(),
|
||||
&definition.skills,
|
||||
)) as Arc<dyn crate::tools::Tool>);
|
||||
let prompt = loader.build_scoped_skills_prompt(&definition.skills);
|
||||
(!prompt.is_empty()).then_some(prompt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !definition.delegates.is_empty() {
|
||||
let delegate = self.full_tools.get("delegate").ok_or_else(|| {
|
||||
SubAgentError::Other("delegate runtime tool is unavailable".to_string())
|
||||
})?;
|
||||
runtime_tools.push(Arc::new(crate::tools::delegate::ScopedDelegateTool::new(
|
||||
delegate,
|
||||
definition.delegates.clone(),
|
||||
)) as Arc<dyn crate::tools::Tool>);
|
||||
}
|
||||
// The signal tool is contract-bound: it exists only when the
|
||||
// definition declares a signal block and the durable Coordinator is
|
||||
// live. If either is missing the run cannot emit signals.
|
||||
if definition.signal_contract.is_some() {
|
||||
match self.coordinator() {
|
||||
Some(coordinator) => {
|
||||
let contract = definition.signal_contract.clone().unwrap();
|
||||
runtime_tools.push(Arc::new(crate::tools::EmitSignalTool::new(
|
||||
coordinator,
|
||||
Arc::new(contract),
|
||||
)) as Arc<dyn crate::tools::Tool>);
|
||||
}
|
||||
None => {
|
||||
return Err(SubAgentError::Other(
|
||||
"Agent '{}' declares a signal contract but the durable Coordinator is unavailable"
|
||||
.replace("{}", target),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let tools = self
|
||||
.full_tools
|
||||
.scoped_for_agent(&names, runtime_tools)
|
||||
.map_err(SubAgentError::Other)?;
|
||||
|
||||
Ok(ResolvedAgentRun {
|
||||
provider_config: definition.provider_config.clone(),
|
||||
tools,
|
||||
timeout_secs: definition.limits.timeout_secs,
|
||||
max_iterations: definition.limits.max_iterations,
|
||||
max_result_chars: definition.limits.max_result_chars,
|
||||
role_prompt: Some(definition.role_prompt.clone()),
|
||||
skills_prompt,
|
||||
agent_id: Some(target.to_string()),
|
||||
definition_hash: Some(definition.definition_hash.clone()),
|
||||
llm_profile: Some(definition.llm_profile.clone()),
|
||||
tool_context: ToolExecutionContext::for_session(format!("agent-run:{task_id}"))
|
||||
.with_turn_id(
|
||||
caller
|
||||
.turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| task_id.to_string()),
|
||||
)
|
||||
.with_agent(execution)
|
||||
.with_cancellation(cancellation)
|
||||
.with_execution_gate(self.execution_gate.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_sub_agent(
|
||||
&self,
|
||||
config: &SubAgentConfig,
|
||||
tools: Arc<ToolRegistry>,
|
||||
) -> Result<AgentLoop, AgentError> {
|
||||
let mut provider = create_provider(self.provider_config.clone())
|
||||
self.build_sub_agent_with_provider(config, tools, &self.provider_config)
|
||||
}
|
||||
|
||||
fn build_sub_agent_with_provider(
|
||||
&self,
|
||||
config: &SubAgentConfig,
|
||||
tools: Arc<ToolRegistry>,
|
||||
provider_config: &LLMProviderConfig,
|
||||
) -> Result<AgentLoop, AgentError> {
|
||||
let mut provider = create_provider(provider_config.clone())
|
||||
.map_err(|e| AgentError::ProviderCreation(e.to_string()))?;
|
||||
if let Some(ref s) = self.storage {
|
||||
provider.set_storage(s.clone());
|
||||
@ -209,9 +504,9 @@ impl SubAgentManager {
|
||||
let provider: Arc<dyn LLMProvider> = Arc::from(provider);
|
||||
|
||||
let max_iterations = config.max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS);
|
||||
let workspace_dir = self.provider_config.workspace_dir.clone();
|
||||
let model_name = self.provider_config.model_id.clone();
|
||||
let input_types = self.provider_config.input_types.clone();
|
||||
let workspace_dir = provider_config.workspace_dir.clone();
|
||||
let model_name = provider_config.model_id.clone();
|
||||
let input_types = provider_config.input_types.clone();
|
||||
|
||||
let agent = AgentLoop::with_provider_and_tools(
|
||||
provider,
|
||||
@ -221,7 +516,7 @@ impl SubAgentManager {
|
||||
workspace_dir,
|
||||
input_types,
|
||||
)
|
||||
.with_context_window(self.provider_config.token_limit);
|
||||
.with_context_window(provider_config.token_limit);
|
||||
|
||||
Ok(agent)
|
||||
}
|
||||
@ -229,31 +524,67 @@ impl SubAgentManager {
|
||||
pub async fn run_inline(
|
||||
&self,
|
||||
config: SubAgentConfig,
|
||||
) -> Result<SubAgentResult, SubAgentError> {
|
||||
let mut caller = ToolExecutionContext::default();
|
||||
if let Some(session_id) = config.session_id.clone() {
|
||||
caller.session_id = Some(session_id);
|
||||
}
|
||||
self.run_foreground(config, &caller).await
|
||||
}
|
||||
|
||||
pub async fn run_foreground(
|
||||
&self,
|
||||
config: SubAgentConfig,
|
||||
caller: &ToolExecutionContext,
|
||||
) -> Result<SubAgentResult, SubAgentError> {
|
||||
let task_id = generate_task_id();
|
||||
let tools = self.filter_tools(&config.allowed_tools);
|
||||
let timeout_secs = config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
let resolved = self.resolve_agent(&config, caller, &task_id)?;
|
||||
self.assign_work_item(&config, &task_id).await?;
|
||||
let result = self.execute_resolved(&config, resolved, &task_id).await?;
|
||||
self.finish_work_item(&config, &result).await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Execute an already-resolved Agent without any plan-item side effects.
|
||||
/// The durable Coordinator owns plan admission/terminal updates itself;
|
||||
/// the legacy path wraps this with assign/finish hooks.
|
||||
pub(crate) async fn execute_resolved(
|
||||
&self,
|
||||
config: &SubAgentConfig,
|
||||
resolved: ResolvedAgentRun,
|
||||
task_id: &str,
|
||||
) -> Result<SubAgentResult, SubAgentError> {
|
||||
let tools = resolved.tools;
|
||||
let timeout_secs = resolved.timeout_secs;
|
||||
let timeout_human = format_duration(timeout_secs);
|
||||
let http_get_only = config.allowed_tools.is_none()
|
||||
|| config
|
||||
.allowed_tools
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.iter().any(|t| t == "http_request"));
|
||||
let skills_prompt = self.get_skills_prompt(&tools);
|
||||
let system_prompt = build_sub_agent_system_prompt(
|
||||
let mut system_prompt = build_sub_agent_system_prompt(
|
||||
&config.prompt,
|
||||
&timeout_human,
|
||||
&tools,
|
||||
&self.provider_config.workspace_dir,
|
||||
&self.provider_config.model_id,
|
||||
skills_prompt,
|
||||
http_get_only,
|
||||
&resolved.provider_config.workspace_dir,
|
||||
&resolved.provider_config.model_id,
|
||||
resolved.skills_prompt,
|
||||
false,
|
||||
);
|
||||
if let Some(role_prompt) = resolved.role_prompt {
|
||||
system_prompt.push_str("\n\n## Agent Definition\n\n");
|
||||
system_prompt.push_str(&role_prompt);
|
||||
}
|
||||
if let Some(context) = config
|
||||
.context
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
system_prompt.push_str("\n\n## 调用方提供的任务上下文\n\n");
|
||||
system_prompt.push_str(context);
|
||||
}
|
||||
|
||||
let mut effective_config = config.clone();
|
||||
effective_config.max_iterations = Some(resolved.max_iterations);
|
||||
let max_result_chars = resolved.max_result_chars;
|
||||
let agent = self
|
||||
.build_sub_agent(&config, tools)
|
||||
.build_sub_agent_with_provider(&effective_config, tools, &resolved.provider_config)
|
||||
.map_err(|e| SubAgentError::ProviderCreation(e.to_string()))?;
|
||||
self.assign_work_item(&config, &task_id).await?;
|
||||
|
||||
let history = vec![
|
||||
ChatMessage::system(system_prompt),
|
||||
@ -261,31 +592,35 @@ impl SubAgentManager {
|
||||
];
|
||||
|
||||
let start = Instant::now();
|
||||
let browser_session_id = config
|
||||
.session_id
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
get_delegate_context()
|
||||
.ok()
|
||||
.map(|context| context.session_id)
|
||||
})
|
||||
.unwrap_or_else(|| format!("sub-agent:{task_id}"));
|
||||
let tool_context = resolved.tool_context;
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
agent.process_with_context(
|
||||
history,
|
||||
ToolExecutionContext::for_session(browser_session_id),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let result = tokio::select! {
|
||||
result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
agent.process_with_context(history, tool_context.clone()),
|
||||
) => result,
|
||||
_ = tool_context.cancellation.cancelled() => {
|
||||
return Ok(SubAgentResult {
|
||||
task_id: task_id.to_string(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
full_content: String::new(),
|
||||
status: TaskStatus::Cancelled,
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
duration_ms: start.elapsed().as_millis() as u64,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let duration_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
let result = match result {
|
||||
Ok(match result {
|
||||
Ok(Ok(agent_result)) => {
|
||||
let (content, truncated) =
|
||||
truncate_sub_agent_result(&agent_result.final_response.content);
|
||||
let (content, truncated) = truncate_sub_agent_result_at(
|
||||
&agent_result.final_response.content,
|
||||
max_result_chars,
|
||||
);
|
||||
let tool_calls_count = agent_result
|
||||
.emitted_messages
|
||||
.iter()
|
||||
@ -297,52 +632,111 @@ impl SubAgentManager {
|
||||
.filter(|m| m.role == "assistant" && m.tool_calls.is_some())
|
||||
.count();
|
||||
SubAgentResult {
|
||||
task_id: task_id.clone(),
|
||||
task_id: task_id.to_string(),
|
||||
content,
|
||||
content_truncated: truncated,
|
||||
full_content: agent_result.final_response.content,
|
||||
status: TaskStatus::Completed,
|
||||
tool_calls_count,
|
||||
iterations,
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => SubAgentResult {
|
||||
task_id: task_id.clone(),
|
||||
Ok(Err(error)) => SubAgentResult {
|
||||
task_id: task_id.to_string(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
status: TaskStatus::Failed(e.to_string()),
|
||||
full_content: String::new(),
|
||||
status: terminal_status_from_error(error),
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
duration_ms,
|
||||
},
|
||||
Err(_elapsed) => SubAgentResult {
|
||||
task_id: task_id.clone(),
|
||||
task_id: task_id.to_string(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
full_content: String::new(),
|
||||
status: TaskStatus::TimedOut,
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
duration_ms,
|
||||
},
|
||||
};
|
||||
self.finish_work_item(&config, &result).await;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run_parallel(
|
||||
&self,
|
||||
configs: Vec<SubAgentConfig>,
|
||||
) -> Result<Vec<SubAgentResult>, SubAgentError> {
|
||||
self.run_foreground_batch(configs, &ToolExecutionContext::default())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn run_foreground_batch(
|
||||
&self,
|
||||
configs: Vec<SubAgentConfig>,
|
||||
caller: &ToolExecutionContext,
|
||||
) -> Result<Vec<SubAgentResult>, SubAgentError> {
|
||||
if configs.is_empty() {
|
||||
return Err(SubAgentError::Other(
|
||||
"foreground batch must contain at least one task".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(parent) = caller.agent.as_ref() {
|
||||
let definition = self.catalog.get(&parent.current_agent_id).ok_or_else(|| {
|
||||
SubAgentError::Other(format!(
|
||||
"caller Agent '{}' is not present in the active catalog",
|
||||
parent.current_agent_id
|
||||
))
|
||||
})?;
|
||||
if configs.len() > definition.limits.max_children {
|
||||
return Err(SubAgentError::Other(format!(
|
||||
"Agent '{}' may create at most {} children per delegate call",
|
||||
parent.current_agent_id, definition.limits.max_children
|
||||
)));
|
||||
}
|
||||
if configs.len() > parent.budget.remaining_runs {
|
||||
return Err(SubAgentError::Other(
|
||||
"delegation run budget is smaller than the requested batch".to_string(),
|
||||
));
|
||||
}
|
||||
if configs.len() > parent.remaining_tree_runs(self.catalog.max_runs_per_tree()) {
|
||||
return Err(SubAgentError::Other(
|
||||
"delegation tree capacity is smaller than the requested batch".to_string(),
|
||||
));
|
||||
}
|
||||
} else if self.catalog.enabled() && configs.len() > self.catalog.max_runs_per_tree() {
|
||||
return Err(SubAgentError::Other(format!(
|
||||
"ROOT batch exceeds max_runs_per_tree ({})",
|
||||
self.catalog.max_runs_per_tree()
|
||||
)));
|
||||
}
|
||||
let futures: Vec<_> = configs
|
||||
.into_iter()
|
||||
.map(|config| {
|
||||
let mgr = self; // &self borrow, all tasks share the same manager
|
||||
async move { mgr.run_inline(config).await }
|
||||
let caller = caller.clone();
|
||||
async move { self.run_foreground(config, &caller).await }
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = futures_util::future::join_all(futures).await;
|
||||
results.into_iter().collect::<Result<Vec<_>, _>>()
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, result)| {
|
||||
result.unwrap_or_else(|error| SubAgentResult {
|
||||
task_id: format!("rejected-{}-{}", index + 1, generate_task_id()),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
full_content: String::new(),
|
||||
status: TaskStatus::Failed(error.to_string()),
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
duration_ms: 0,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn run_background(
|
||||
@ -442,6 +836,7 @@ impl SubAgentManager {
|
||||
let notify_tx = self.notify_tx.clone();
|
||||
let active_tasks = Arc::clone(&self.active_tasks);
|
||||
let shutdown = self.task_supervisor.cancellation_token();
|
||||
let execution_gate = self.execution_gate.clone();
|
||||
|
||||
let tid = task_id.clone();
|
||||
let sess_id = ctx.session_id.clone();
|
||||
@ -499,13 +894,13 @@ impl SubAgentManager {
|
||||
ChatMessage::user(&prompt),
|
||||
];
|
||||
|
||||
tokio::select! {
|
||||
let tool_context = ToolExecutionContext::for_session(&sess_id)
|
||||
.with_cancellation(cancel_token.clone())
|
||||
.with_execution_gate(execution_gate.clone());
|
||||
tokio::select! {
|
||||
r = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
agent.process_with_context(
|
||||
history,
|
||||
ToolExecutionContext::for_session(&sess_id),
|
||||
),
|
||||
agent.process_with_context(history, tool_context),
|
||||
) => {
|
||||
match r {
|
||||
Ok(Ok(agent_result)) => {
|
||||
@ -515,19 +910,21 @@ impl SubAgentManager {
|
||||
.iter().filter(|m| m.role == "assistant" && m.tool_calls.is_some()).count();
|
||||
SubAgentResult {
|
||||
task_id: tid.clone(),
|
||||
content: agent_result.final_response.content,
|
||||
content: agent_result.final_response.content.clone(),
|
||||
content_truncated: false,
|
||||
full_content: agent_result.final_response.content,
|
||||
status: TaskStatus::Completed,
|
||||
tool_calls_count,
|
||||
iterations,
|
||||
duration_ms: 0,
|
||||
}
|
||||
},
|
||||
Ok(Err(e)) => SubAgentResult {
|
||||
Ok(Err(error)) => SubAgentResult {
|
||||
task_id: tid.clone(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
status: TaskStatus::Failed(e.to_string()),
|
||||
full_content: String::new(),
|
||||
status: terminal_status_from_error(error),
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
duration_ms: 0,
|
||||
@ -536,6 +933,7 @@ impl SubAgentManager {
|
||||
task_id: tid.clone(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
full_content: String::new(),
|
||||
status: TaskStatus::TimedOut,
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
@ -547,6 +945,7 @@ impl SubAgentManager {
|
||||
task_id: tid.clone(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
full_content: String::new(),
|
||||
status: TaskStatus::Cancelled,
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
@ -556,6 +955,7 @@ impl SubAgentManager {
|
||||
task_id: tid.clone(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
full_content: String::new(),
|
||||
status: TaskStatus::Cancelled,
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
@ -567,6 +967,7 @@ impl SubAgentManager {
|
||||
task_id: tid.clone(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
full_content: String::new(),
|
||||
status: TaskStatus::Failed("provider creation failed".into()),
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
@ -785,8 +1186,16 @@ impl SubAgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_status_from_error(error: AgentError) -> TaskStatus {
|
||||
match error {
|
||||
AgentError::Cancelled => TaskStatus::Cancelled,
|
||||
AgentError::TimedOut => TaskStatus::TimedOut,
|
||||
other => TaskStatus::Failed(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_task_id() -> String {
|
||||
Uuid::new_v4().to_string()[..8].to_string()
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
fn format_duration(seconds: u64) -> String {
|
||||
@ -799,11 +1208,11 @@ fn format_duration(seconds: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_sub_agent_result(content: &str) -> (String, bool) {
|
||||
if content.len() <= MAX_INLINE_RESULT_CHARS {
|
||||
fn truncate_sub_agent_result_at(content: &str, max_chars: usize) -> (String, bool) {
|
||||
if content.len() <= max_chars {
|
||||
(content.to_string(), false)
|
||||
} else {
|
||||
let truncate_at = content.floor_char_boundary(MAX_INLINE_RESULT_CHARS);
|
||||
let truncate_at = content.floor_char_boundary(max_chars);
|
||||
(
|
||||
format!(
|
||||
"{}\n\n[... 结果已截断,共 {} 字符,完整结果请使用 check_task 查看 ...]",
|
||||
@ -870,7 +1279,9 @@ mod tests {
|
||||
let error = manager
|
||||
.run_background(
|
||||
SubAgentConfig {
|
||||
target: None,
|
||||
prompt: "test".into(),
|
||||
context: None,
|
||||
mode: ExecutionMode::Background,
|
||||
allowed_tools: None,
|
||||
max_iterations: None,
|
||||
@ -902,4 +1313,149 @@ mod tests {
|
||||
let filtered = manager.filter_tools(&Some(vec!["reload_config".to_string()]));
|
||||
assert!(filtered.get("reload_config").is_none());
|
||||
}
|
||||
|
||||
fn catalog_with_agents(
|
||||
root: &std::path::Path,
|
||||
max_runs_per_tree: usize,
|
||||
) -> crate::agent::AgentCatalog {
|
||||
std::fs::create_dir_all(root.join("agents")).unwrap();
|
||||
let write = |id: &str, delegates: &[&str]| {
|
||||
let delegates = (!delegates.is_empty()).then(|| {
|
||||
format!(
|
||||
"delegates:\n{}\n",
|
||||
delegates
|
||||
.iter()
|
||||
.map(|name| format!(" - {name}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
)
|
||||
});
|
||||
std::fs::write(
|
||||
root.join("agents").join(format!("{id}.md")),
|
||||
format!(
|
||||
"---\nid: {id}\ndescription: {id} role\nllm_profile: research\n{}---\n# Role\n\nDo the assigned work.\n",
|
||||
delegates.unwrap_or_default()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
write("researcher", &["reviewer"]);
|
||||
write("reviewer", &[]);
|
||||
let tools = ToolRegistry::new();
|
||||
let loader = crate::skills::SkillsLoader::new_for_testing(
|
||||
root.join("skills"),
|
||||
root.join("external-skills"),
|
||||
);
|
||||
let profiles = HashMap::from([(
|
||||
"research".to_string(),
|
||||
LLMProviderConfig {
|
||||
provider_type: "openai".into(),
|
||||
name: "test".into(),
|
||||
base_url: "http://localhost".into(),
|
||||
api_key: "test".into(),
|
||||
extra_headers: HashMap::new(),
|
||||
model_id: "test".into(),
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
model_extra: HashMap::new(),
|
||||
max_tool_iterations: 1,
|
||||
token_limit: 4096,
|
||||
workspace_dir: std::env::temp_dir(),
|
||||
input_types: vec!["text".into()],
|
||||
price_input_per_million: None,
|
||||
price_output_per_million: None,
|
||||
},
|
||||
)]);
|
||||
let config = crate::config::AgentOrchestrationConfig {
|
||||
enabled: true,
|
||||
definitions_dir: "agents".to_string(),
|
||||
root_delegates: vec!["researcher".to_string()],
|
||||
max_runs_per_tree,
|
||||
..Default::default()
|
||||
};
|
||||
crate::agent::AgentCatalog::load(&config, root, &profiles, &tools, &loader, 1).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreground_batch_rejects_when_tree_capacity_exhausted() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let catalog = catalog_with_agents(root.path(), 2);
|
||||
let manager = manager(1).with_catalog(Arc::new(catalog));
|
||||
|
||||
let caller_context = Arc::new(crate::agent::AgentExecutionContext {
|
||||
root_session_id: "cli:test:dialog".to_string(),
|
||||
root_turn_id: None,
|
||||
run_id: "run-root".to_string(),
|
||||
execution_id: "run-root".to_string(),
|
||||
group_id: None,
|
||||
parent_run_id: None,
|
||||
caller_agent_id: "ROOT".to_string(),
|
||||
current_agent_id: "researcher".to_string(),
|
||||
ancestry: vec!["researcher".to_string()],
|
||||
depth: 1,
|
||||
plan_item_id: None,
|
||||
cancellation: CancellationToken::new(),
|
||||
budget: crate::agent::AgentBudget {
|
||||
remaining_runs: 15,
|
||||
remaining_depth: 3,
|
||||
},
|
||||
tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(2)),
|
||||
signal_contract: None,
|
||||
emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
});
|
||||
let caller =
|
||||
ToolExecutionContext::for_session("cli:test:dialog").with_agent(caller_context);
|
||||
|
||||
let error = manager
|
||||
.run_foreground_batch(
|
||||
vec![SubAgentConfig {
|
||||
target: Some("reviewer".to_string()),
|
||||
prompt: "test".to_string(),
|
||||
context: None,
|
||||
mode: ExecutionMode::Foreground,
|
||||
allowed_tools: None,
|
||||
max_iterations: None,
|
||||
timeout_secs: None,
|
||||
plan_item_id: None,
|
||||
session_id: Some("cli:test:dialog".to_string()),
|
||||
}],
|
||||
&caller,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, SubAgentError::Other(message) if message.contains("tree capacity"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreground_batch_preserves_per_task_rejections() {
|
||||
let manager = manager(1);
|
||||
let config = |target: &str| SubAgentConfig {
|
||||
target: Some(target.to_string()),
|
||||
prompt: "test".to_string(),
|
||||
context: None,
|
||||
mode: ExecutionMode::Foreground,
|
||||
allowed_tools: None,
|
||||
max_iterations: None,
|
||||
timeout_secs: None,
|
||||
plan_item_id: None,
|
||||
session_id: Some("cli:test:dialog".to_string()),
|
||||
};
|
||||
|
||||
let results = manager
|
||||
.run_foreground_batch(
|
||||
vec![config("missing-a"), config("missing-b")],
|
||||
&ToolExecutionContext::for_session("cli:test:dialog"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.all(|result| matches!(result.status, TaskStatus::Failed(_)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -380,7 +380,7 @@ impl PromptSection for SubAgentIdentitySection {
|
||||
## 规则\n\
|
||||
- 只专注于这个任务,不要扩展到无关话题\n\
|
||||
- 只在必要时使用工具\n\
|
||||
- 不要使用 delegate 工具\n\
|
||||
- 只有运行时明确提供 delegate 工具时才可继续委托,并遵守已配置的目标白名单\n\
|
||||
- 无法完成时,直接说明原因\n\
|
||||
- 只返回最终结果,不要描述过程\n\
|
||||
- 超时:{},接近时限时返回部分结果",
|
||||
|
||||
@ -2,7 +2,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::agent::steering::SteeringMailbox;
|
||||
use crate::agent::steering::TurnMailbox;
|
||||
use crate::providers::ToolCall;
|
||||
|
||||
/// Presentation facts emitted while AgentLoop processes one model turn.
|
||||
@ -61,7 +61,7 @@ pub struct AgentTurnContext {
|
||||
pub emitter: TurnEmitter,
|
||||
/// Same-turn user input accepted while this turn is active. Session owns
|
||||
/// the mailbox lifecycle; AgentLoop only drains it at safe boundaries.
|
||||
pub steering: Option<Arc<SteeringMailbox>>,
|
||||
pub steering: Option<Arc<TurnMailbox>>,
|
||||
}
|
||||
|
||||
impl AgentTurnContext {
|
||||
@ -83,7 +83,7 @@ impl AgentTurnContext {
|
||||
turn_id: impl Into<String>,
|
||||
message_id: impl Into<String>,
|
||||
emitter: TurnEmitter,
|
||||
steering: Arc<SteeringMailbox>,
|
||||
steering: Arc<TurnMailbox>,
|
||||
) -> Self {
|
||||
Self {
|
||||
turn_id: turn_id.into(),
|
||||
@ -95,13 +95,13 @@ impl AgentTurnContext {
|
||||
|
||||
/// Attach a mailbox to an existing context. This builder keeps the old
|
||||
/// `AgentTurnContext::new` call sites source-compatible.
|
||||
pub fn with_steering(mut self, steering: Arc<SteeringMailbox>) -> Self {
|
||||
pub fn with_steering(mut self, steering: Arc<TurnMailbox>) -> Self {
|
||||
self.steering = Some(steering);
|
||||
self
|
||||
}
|
||||
|
||||
/// Return a clone of the shared mailbox, if steering is enabled.
|
||||
pub fn steering(&self) -> Option<Arc<SteeringMailbox>> {
|
||||
pub fn steering(&self) -> Option<Arc<TurnMailbox>> {
|
||||
self.steering.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,6 +129,48 @@ impl MediaItem {
|
||||
// ChatMessage - Used by AgentLoop for LLM conversation history
|
||||
// ============================================================================
|
||||
|
||||
/// Whether a message may be surfaced to clients. Hidden messages exist only
|
||||
/// for model replay (internal triggers) and must never appear in history,
|
||||
/// projections or delivery.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClientVisibility {
|
||||
#[default]
|
||||
Visible,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
impl ClientVisibility {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Visible => "visible",
|
||||
Self::Hidden => "hidden",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a message Turn originated. Persisted alongside the message so
|
||||
/// clients can render agent-driven continuation output without treating it as
|
||||
/// a user bubble.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TurnOrigin {
|
||||
#[default]
|
||||
User,
|
||||
AgentContinuation,
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
impl TurnOrigin {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::AgentContinuation => "agent_continuation",
|
||||
Self::Scheduled => "scheduled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub id: String,
|
||||
@ -144,6 +186,10 @@ pub struct ChatMessage {
|
||||
pub iteration: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub completion_status: CompletionStatus,
|
||||
#[serde(default)]
|
||||
pub client_visibility: ClientVisibility,
|
||||
#[serde(default)]
|
||||
pub turn_origin: TurnOrigin,
|
||||
pub media_refs: Vec<MediaRef>,
|
||||
pub timestamp: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@ -166,6 +212,15 @@ pub enum SourceKind {
|
||||
CrossChannel,
|
||||
#[serde(rename = "external_trigger")]
|
||||
ExternalTrigger,
|
||||
/// A durable signal emitted by a background Agent via `emit_signal`.
|
||||
#[serde(rename = "agent_signal")]
|
||||
AgentSignal,
|
||||
/// A durable background run completion outcome.
|
||||
#[serde(rename = "agent_result")]
|
||||
AgentCompletion,
|
||||
/// A durable background group completion outcome.
|
||||
#[serde(rename = "agent_group_result")]
|
||||
AgentGroupCompletion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -176,6 +231,15 @@ pub struct MessageSource {
|
||||
pub from_user_id: Option<String>,
|
||||
pub system_name: Option<String>,
|
||||
pub task_id: Option<String>,
|
||||
/// Durable Agent run identity for `agent_signal`/`agent_result` sources.
|
||||
#[serde(default)]
|
||||
pub from_run_id: Option<String>,
|
||||
/// Agent definition id for `agent_signal`/`agent_result` sources.
|
||||
#[serde(default)]
|
||||
pub from_agent_id: Option<String>,
|
||||
/// Durable group identity for `agent_group_result` sources.
|
||||
#[serde(default)]
|
||||
pub group_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
@ -195,6 +259,8 @@ impl ChatMessage {
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
source: None,
|
||||
client_visibility: ClientVisibility::Visible,
|
||||
turn_origin: TurnOrigin::User,
|
||||
}
|
||||
}
|
||||
|
||||
@ -214,6 +280,8 @@ impl ChatMessage {
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
source: None,
|
||||
client_visibility: ClientVisibility::Visible,
|
||||
turn_origin: TurnOrigin::User,
|
||||
}
|
||||
}
|
||||
|
||||
@ -233,6 +301,8 @@ impl ChatMessage {
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
source: None,
|
||||
client_visibility: ClientVisibility::Visible,
|
||||
turn_origin: TurnOrigin::User,
|
||||
}
|
||||
}
|
||||
|
||||
@ -255,6 +325,8 @@ impl ChatMessage {
|
||||
tool_name: None,
|
||||
tool_calls: Some(tool_calls),
|
||||
source: None,
|
||||
client_visibility: ClientVisibility::Visible,
|
||||
turn_origin: TurnOrigin::User,
|
||||
}
|
||||
}
|
||||
|
||||
@ -274,6 +346,8 @@ impl ChatMessage {
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
source: Some(source),
|
||||
client_visibility: ClientVisibility::Visible,
|
||||
turn_origin: TurnOrigin::User,
|
||||
}
|
||||
}
|
||||
|
||||
@ -293,6 +367,8 @@ impl ChatMessage {
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
source: None,
|
||||
client_visibility: ClientVisibility::Visible,
|
||||
turn_origin: TurnOrigin::User,
|
||||
}
|
||||
}
|
||||
|
||||
@ -325,6 +401,8 @@ impl ChatMessage {
|
||||
tool_name: Some(tool_name.into()),
|
||||
tool_calls: None,
|
||||
source: None,
|
||||
client_visibility: ClientVisibility::Visible,
|
||||
turn_origin: TurnOrigin::User,
|
||||
}
|
||||
}
|
||||
|
||||
@ -344,6 +422,8 @@ impl ChatMessage {
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
source: Some(source),
|
||||
client_visibility: ClientVisibility::Visible,
|
||||
turn_origin: TurnOrigin::User,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -371,11 +451,15 @@ mod conversation_message_tests {
|
||||
// ============================================================================
|
||||
|
||||
/// Opaque channel-owned context that may be carried to the corresponding reply.
|
||||
/// Core routing understands `reply_to`; all other platform data remains private.
|
||||
/// Core routing understands `reply_to`; all other platform data remains
|
||||
/// private. `durable_private` holds only values the channel declares safe to
|
||||
/// reuse across turns (thread/root identity); one-shot message/reaction ids
|
||||
/// belong in `private`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ChannelContext {
|
||||
pub reply_to: Option<String>,
|
||||
pub private: HashMap<String, String>,
|
||||
pub durable_private: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Public, durable projection of a newly committed conversation message.
|
||||
@ -393,6 +477,7 @@ pub struct CommittedMessage {
|
||||
pub tool_call_id: Option<String>,
|
||||
pub tool_name: Option<String>,
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
pub turn_origin: TurnOrigin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@ -3,9 +3,9 @@ pub mod message;
|
||||
|
||||
pub use dispatcher::OutboundDispatcher;
|
||||
pub use message::{
|
||||
ChannelContext, ChatMessage, CommittedMessage, CommittedTurnDelta, CompletionStatus,
|
||||
ContentBlock, ControlMessage, InboundMessage, MediaItem, MediaRef, MessageSource,
|
||||
OutboundMessage, ProviderReasoningState, SourceKind,
|
||||
ChannelContext, ChatMessage, ClientVisibility, CommittedMessage, CommittedTurnDelta,
|
||||
CompletionStatus, ContentBlock, ControlMessage, InboundMessage, MediaItem, MediaRef,
|
||||
MessageSource, OutboundMessage, ProviderReasoningState, SourceKind, TurnOrigin,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -132,6 +132,40 @@ impl CliChatChannel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a durable Agent run/event projection update to the owning
|
||||
/// WebSocket client. Clients recalibrate with `GetAgentRuns` when a
|
||||
/// broadcast is lagged or lost.
|
||||
pub async fn publish_agent_projection(
|
||||
&self,
|
||||
projection: crate::agent::projection::AgentProjection,
|
||||
) {
|
||||
let Some(session_id) = UnifiedSessionId::parse(&projection.session_id) else {
|
||||
return;
|
||||
};
|
||||
if session_id.channel != "cli_chat" {
|
||||
return;
|
||||
}
|
||||
let client = self.clients.lock().await.get(&session_id.chat_id).cloned();
|
||||
if let Some(client) = client {
|
||||
let message = if let Some(run) = projection.run {
|
||||
WsOutbound::AgentRunUpdated {
|
||||
session_id: projection.session_id,
|
||||
revision: projection.revision,
|
||||
run,
|
||||
}
|
||||
} else if let Some(event) = projection.event {
|
||||
WsOutbound::AgentEventUpdated {
|
||||
session_id: projection.session_id,
|
||||
revision: projection.revision,
|
||||
event,
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let _ = client.sender.send(message).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an inbound message from a client
|
||||
pub(crate) async fn handle_inbound(&self, client: Arc<Client>, raw_msg: &str) {
|
||||
match parse_inbound(raw_msg) {
|
||||
@ -515,6 +549,77 @@ impl CliChatChannel {
|
||||
None => return Err(ChannelError::Other("Control channel closed".to_string())),
|
||||
}
|
||||
}
|
||||
WsInbound::GetAgentRuns {
|
||||
session_id,
|
||||
cursor,
|
||||
limit,
|
||||
} => {
|
||||
let unified_id = Self::parse_client_session(&client, &session_id)?;
|
||||
let (reply_tx, mut reply_rx) = mpsc::channel(1);
|
||||
bus.publish_control(ControlMessage {
|
||||
op: SessionCommand::GetAgentRuns {
|
||||
session_id: unified_id,
|
||||
cursor,
|
||||
limit: limit.unwrap_or(100).clamp(1, 200),
|
||||
},
|
||||
reply_tx,
|
||||
})
|
||||
.await?;
|
||||
match reply_rx.recv().await {
|
||||
Some(Ok(SessionEvent::AgentRuns {
|
||||
session_id,
|
||||
revision,
|
||||
runs,
|
||||
next_cursor,
|
||||
})) => {
|
||||
let _ = client
|
||||
.sender
|
||||
.send(WsOutbound::SessionAgentRuns {
|
||||
session_id: session_id.to_string(),
|
||||
revision,
|
||||
runs,
|
||||
next_cursor,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => return Err(error),
|
||||
None => return Err(ChannelError::Other("Control channel closed".to_string())),
|
||||
}
|
||||
}
|
||||
WsInbound::GetAgentRun { session_id, run_id } => {
|
||||
let unified_id = Self::parse_client_session(&client, &session_id)?;
|
||||
let (reply_tx, mut reply_rx) = mpsc::channel(1);
|
||||
bus.publish_control(ControlMessage {
|
||||
op: SessionCommand::GetAgentRun {
|
||||
session_id: unified_id,
|
||||
run_id,
|
||||
},
|
||||
reply_tx,
|
||||
})
|
||||
.await?;
|
||||
match reply_rx.recv().await {
|
||||
Some(Ok(SessionEvent::AgentRun {
|
||||
session_id,
|
||||
revision,
|
||||
run,
|
||||
})) => {
|
||||
let _ = client
|
||||
.sender
|
||||
.send(WsOutbound::AgentRunUpdated {
|
||||
session_id: session_id.to_string(),
|
||||
revision,
|
||||
run: run.ok_or_else(|| {
|
||||
ChannelError::Other("run not found".to_string())
|
||||
})?,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => return Err(error),
|
||||
None => return Err(ChannelError::Other("Control channel closed".to_string())),
|
||||
}
|
||||
}
|
||||
WsInbound::RenameSession { session_id, title } => {
|
||||
let target = session_id
|
||||
.or(current_session_guard.clone())
|
||||
@ -1255,6 +1360,7 @@ mod tests {
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
turn_origin: crate::bus::TurnOrigin::User,
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
@ -1415,6 +1415,16 @@ impl FeishuChannel {
|
||||
private_context.insert("feishu.reaction_id".to_string(), reaction_id);
|
||||
}
|
||||
|
||||
// Durable context only carries values safe to reuse across Turns:
|
||||
// thread/root identity. Message and reaction ids are one-shot and
|
||||
// stay in `private`/`reply_to`.
|
||||
let mut durable_context = HashMap::new();
|
||||
for key in ["feishu.chat_type", "feishu.thread_id", "feishu.root_id"] {
|
||||
if let Some(value) = private_context.get(key) {
|
||||
durable_context.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let msg = crate::bus::InboundMessage {
|
||||
channel: "feishu".to_string(),
|
||||
sender_id: parsed.open_id.clone(),
|
||||
@ -1426,6 +1436,7 @@ impl FeishuChannel {
|
||||
channel_context: crate::bus::ChannelContext {
|
||||
reply_to: Some(message_id),
|
||||
private: private_context,
|
||||
durable_private: durable_context,
|
||||
},
|
||||
};
|
||||
if let Err(error) = self.handle_and_publish(bus, &msg).await {
|
||||
|
||||
@ -382,7 +382,10 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
|
||||
// notifications and may inspect plans through /todo.
|
||||
WsOutbound::SessionPlan { .. }
|
||||
| WsOutbound::SessionStats { .. }
|
||||
| WsOutbound::PlanUpdated { .. } => {}
|
||||
| WsOutbound::PlanUpdated { .. }
|
||||
| WsOutbound::SessionAgentRuns { .. }
|
||||
| WsOutbound::AgentRunUpdated { .. }
|
||||
| WsOutbound::AgentEventUpdated { .. } => {}
|
||||
WsOutbound::SessionRenamed { session_id, title } => {
|
||||
if let Some(session) = app
|
||||
.sessions
|
||||
|
||||
@ -552,6 +552,7 @@ mod tests {
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
attachments: Vec::new(),
|
||||
turn_origin: crate::bus::TurnOrigin::User,
|
||||
}],
|
||||
);
|
||||
assert!(app.active_turn.is_none());
|
||||
@ -578,6 +579,7 @@ mod tests {
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
attachments: Vec::new(),
|
||||
turn_origin: crate::bus::TurnOrigin::User,
|
||||
}],
|
||||
));
|
||||
assert!(app.active_turn.is_none());
|
||||
@ -604,6 +606,7 @@ mod tests {
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
attachments: Vec::new(),
|
||||
turn_origin: crate::bus::TurnOrigin::User,
|
||||
}],
|
||||
));
|
||||
|
||||
|
||||
@ -57,6 +57,8 @@ pub struct Config {
|
||||
pub mcp: McpConfig,
|
||||
#[serde(default)]
|
||||
pub browser: BrowserConfig,
|
||||
#[serde(default)]
|
||||
pub agent_orchestration: AgentOrchestrationConfig,
|
||||
}
|
||||
|
||||
fn default_workspace_dir() -> String {
|
||||
@ -176,6 +178,126 @@ fn default_token_limit() -> usize {
|
||||
128_000
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct AgentOrchestrationConfig {
|
||||
pub enabled: bool,
|
||||
pub definitions_dir: String,
|
||||
pub root_delegates: Vec<String>,
|
||||
pub max_tree_depth: u16,
|
||||
pub max_runs_per_tree: usize,
|
||||
pub max_concurrent_runs: usize,
|
||||
pub max_concurrent_runs_per_session: usize,
|
||||
pub max_concurrent_provider_steps: usize,
|
||||
pub max_concurrent_provider_steps_per_session: usize,
|
||||
pub max_concurrent_tool_steps: usize,
|
||||
pub max_concurrent_tool_steps_per_session: usize,
|
||||
pub max_pending_inbox_events_per_session: usize,
|
||||
pub inbox_event_ttl_hours: u64,
|
||||
pub max_inbox_delivery_attempts: u32,
|
||||
pub max_user_turn_burst_before_inbox: usize,
|
||||
pub max_inbox_wait_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for AgentOrchestrationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
definitions_dir: "agents".to_string(),
|
||||
root_delegates: Vec::new(),
|
||||
max_tree_depth: 4,
|
||||
max_runs_per_tree: 16,
|
||||
max_concurrent_runs: 6,
|
||||
max_concurrent_runs_per_session: 4,
|
||||
max_concurrent_provider_steps: 8,
|
||||
max_concurrent_provider_steps_per_session: 4,
|
||||
max_concurrent_tool_steps: 16,
|
||||
max_concurrent_tool_steps_per_session: 8,
|
||||
max_pending_inbox_events_per_session: 128,
|
||||
inbox_event_ttl_hours: 168,
|
||||
max_inbox_delivery_attempts: 8,
|
||||
max_user_turn_burst_before_inbox: 4,
|
||||
max_inbox_wait_secs: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentOrchestrationConfig {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let positive = [
|
||||
("max_tree_depth", usize::from(self.max_tree_depth)),
|
||||
("max_runs_per_tree", self.max_runs_per_tree),
|
||||
("max_concurrent_runs", self.max_concurrent_runs),
|
||||
(
|
||||
"max_concurrent_runs_per_session",
|
||||
self.max_concurrent_runs_per_session,
|
||||
),
|
||||
(
|
||||
"max_concurrent_provider_steps",
|
||||
self.max_concurrent_provider_steps,
|
||||
),
|
||||
(
|
||||
"max_concurrent_provider_steps_per_session",
|
||||
self.max_concurrent_provider_steps_per_session,
|
||||
),
|
||||
("max_concurrent_tool_steps", self.max_concurrent_tool_steps),
|
||||
(
|
||||
"max_concurrent_tool_steps_per_session",
|
||||
self.max_concurrent_tool_steps_per_session,
|
||||
),
|
||||
(
|
||||
"max_pending_inbox_events_per_session",
|
||||
self.max_pending_inbox_events_per_session,
|
||||
),
|
||||
(
|
||||
"max_inbox_delivery_attempts",
|
||||
self.max_inbox_delivery_attempts as usize,
|
||||
),
|
||||
(
|
||||
"max_user_turn_burst_before_inbox",
|
||||
self.max_user_turn_burst_before_inbox,
|
||||
),
|
||||
];
|
||||
if let Some((name, _)) = positive.into_iter().find(|(_, value)| *value == 0) {
|
||||
return Err(format!(
|
||||
"agent_orchestration.{name} must be greater than zero"
|
||||
));
|
||||
}
|
||||
if self.definitions_dir.trim().is_empty() {
|
||||
return Err("agent_orchestration.definitions_dir must not be empty".to_string());
|
||||
}
|
||||
if self.max_concurrent_runs_per_session > self.max_concurrent_runs {
|
||||
return Err("agent_orchestration.max_concurrent_runs_per_session cannot exceed max_concurrent_runs".to_string());
|
||||
}
|
||||
if self.max_concurrent_provider_steps_per_session > self.max_concurrent_provider_steps {
|
||||
return Err("agent_orchestration.max_concurrent_provider_steps_per_session cannot exceed max_concurrent_provider_steps".to_string());
|
||||
}
|
||||
if self.max_concurrent_tool_steps_per_session > self.max_concurrent_tool_steps {
|
||||
return Err("agent_orchestration.max_concurrent_tool_steps_per_session cannot exceed max_concurrent_tool_steps".to_string());
|
||||
}
|
||||
if self.max_tree_depth > 32 {
|
||||
return Err("agent_orchestration.max_tree_depth cannot exceed 32".to_string());
|
||||
}
|
||||
if self.max_runs_per_tree > 1024 {
|
||||
return Err("agent_orchestration.max_runs_per_tree cannot exceed 1024".to_string());
|
||||
}
|
||||
if self.inbox_event_ttl_hours == 0 || self.inbox_event_ttl_hours > 24 * 365 {
|
||||
return Err(
|
||||
"agent_orchestration.inbox_event_ttl_hours must be between 1 and 8760".to_string(),
|
||||
);
|
||||
}
|
||||
if self.max_inbox_wait_secs == 0 || self.max_inbox_wait_secs > 3600 {
|
||||
return Err(
|
||||
"agent_orchestration.max_inbox_wait_secs must be between 1 and 3600".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GatewayConfig {
|
||||
#[serde(default = "default_gateway_host")]
|
||||
@ -1029,6 +1151,7 @@ mod tests {
|
||||
25 * 1024 * 1024
|
||||
);
|
||||
assert!(config.browser.enabled);
|
||||
assert!(!config.agent_orchestration.enabled);
|
||||
let browser: BrowserConfig = serde_json::from_str("{}").unwrap();
|
||||
assert!(browser.enabled);
|
||||
assert!(
|
||||
@ -1039,6 +1162,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestration_config_enforces_hierarchical_limits() {
|
||||
let valid = AgentOrchestrationConfig {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(valid.validate().is_ok());
|
||||
|
||||
let invalid = AgentOrchestrationConfig {
|
||||
enabled: true,
|
||||
max_concurrent_runs: 1,
|
||||
max_concurrent_runs_per_session: 2,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_persistence_config_is_strict_and_explicit() {
|
||||
let browser: BrowserConfig = serde_json::from_str(
|
||||
|
||||
@ -699,6 +699,13 @@ pub struct LimitQuery {
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct AgentRunsQuery {
|
||||
session_id: String,
|
||||
cursor: Option<String>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
fn scheduler_snapshot(jobs: &[crate::storage::ScheduledJob]) -> Value {
|
||||
let enabled = jobs.iter().filter(|job| job.enabled).count();
|
||||
let failed_jobs = jobs
|
||||
@ -872,14 +879,159 @@ pub async fn get_tasks(
|
||||
Query(query): Query<LimitQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let limit = query.limit.unwrap_or(100).clamp(1, 500);
|
||||
let tasks = state
|
||||
// Unified projection: new durable agent_runs plus the legacy
|
||||
// background_tasks records (read-only). Merged by created_at.
|
||||
let runs = state
|
||||
.storage
|
||||
.list_all_agent_runs(None, limit as i64)
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
let legacy = state
|
||||
.storage
|
||||
.list_recent_background_tasks(limit)
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
let mut tasks: Vec<Value> = Vec::with_capacity(runs.len() + legacy.len());
|
||||
for run in runs {
|
||||
tasks.push(json!({
|
||||
"source": "agent_run",
|
||||
"id": run.id,
|
||||
"group_id": run.group_id,
|
||||
"parent_run_id": run.parent_run_id,
|
||||
"session_id": run.root_session_id,
|
||||
"channel": null,
|
||||
"chat_id": null,
|
||||
"agent_id": run.agent_id,
|
||||
"mode": run.mode.as_str(),
|
||||
"depth": run.depth,
|
||||
"prompt": run.task,
|
||||
"status": run.status.as_str(),
|
||||
"result": run.result,
|
||||
"error": run.error,
|
||||
"tool_calls_count": run.tool_calls_count,
|
||||
"iterations": run.iterations,
|
||||
"started_at": run.started_at,
|
||||
"finished_at": run.finished_at,
|
||||
"created_at": run.created_at,
|
||||
}));
|
||||
}
|
||||
for task in legacy {
|
||||
tasks.push(json!({
|
||||
"source": "legacy_background_task",
|
||||
"id": task.id,
|
||||
"session_id": task.session_id,
|
||||
"channel": task.channel,
|
||||
"chat_id": task.chat_id,
|
||||
"prompt": task.prompt,
|
||||
"status": task.status,
|
||||
"result": task.result,
|
||||
"error": task.error,
|
||||
"tool_calls_count": task.tool_calls_count,
|
||||
"iterations": task.iterations,
|
||||
"started_at": task.started_at,
|
||||
"finished_at": task.finished_at,
|
||||
"created_at": task.created_at,
|
||||
}));
|
||||
}
|
||||
tasks.sort_by(|left, right| {
|
||||
right
|
||||
.get("created_at")
|
||||
.and_then(Value::as_i64)
|
||||
.cmp(&left.get("created_at").and_then(Value::as_i64))
|
||||
});
|
||||
tasks.truncate(limit);
|
||||
Ok(Json(json!({ "tasks": tasks })))
|
||||
}
|
||||
|
||||
/// Session-scoped durable run listing with `(created_at,id)` cursor paging.
|
||||
pub async fn get_agent_runs(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Query(query): Query<AgentRunsQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let Some(coordinator) = state.session_manager.agent_coordinator() else {
|
||||
return Ok(Json(json!({
|
||||
"revision": 0,
|
||||
"runs": [],
|
||||
"next_cursor": Value::Null,
|
||||
})));
|
||||
};
|
||||
let cursor = query.cursor.as_deref().and_then(|cursor| {
|
||||
let (created_at, id) = cursor.split_once(':')?;
|
||||
Some((created_at.parse::<i64>().ok()?, id.to_string()))
|
||||
});
|
||||
let limit = query.limit.unwrap_or(100).clamp(1, 200) as i64;
|
||||
let (revision, runs, next_cursor) = coordinator
|
||||
.list_runs_for_session(&query.session_id, cursor, limit)
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
Ok(Json(json!({
|
||||
"revision": revision,
|
||||
"runs": runs,
|
||||
"next_cursor": next_cursor,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_agent_run(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let Some(_coordinator) = state.session_manager.agent_coordinator() else {
|
||||
return Err(ApiError::not_found("run not found".to_string()));
|
||||
};
|
||||
let run = state
|
||||
.storage
|
||||
.get_agent_run(&id)
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
let Some(run) = run else {
|
||||
return Err(ApiError::not_found(format!("run {id} not found")));
|
||||
};
|
||||
Ok(Json(
|
||||
json!({ "run": crate::protocol::AgentRunView::from_record(&run, 100_000) }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_agent_run_events(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<LimitQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let Some(coordinator) = state.session_manager.agent_coordinator() else {
|
||||
return Ok(Json(json!({ "events": [] })));
|
||||
};
|
||||
let limit = query.limit.unwrap_or(100).clamp(1, 200) as i64;
|
||||
let events = coordinator
|
||||
.list_run_events(&id, limit)
|
||||
.await
|
||||
.map_err(ApiError::internal)?
|
||||
.iter()
|
||||
.map(crate::protocol::AgentEventView::from_record)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({ "events": events })))
|
||||
}
|
||||
|
||||
pub async fn cancel_agent_run(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let Some(coordinator) = state.session_manager.agent_coordinator() else {
|
||||
return Err(ApiError::not_found("run not found".to_string()));
|
||||
};
|
||||
let Some(run) = state
|
||||
.storage
|
||||
.get_agent_run(&id)
|
||||
.await
|
||||
.map_err(ApiError::internal)?
|
||||
else {
|
||||
return Err(ApiError::not_found(format!("run {id} not found")));
|
||||
};
|
||||
let cancelled = coordinator
|
||||
.cancel_run_for_session(&run.root_session_id, &id, "cancelled from management UI")
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
Ok(Json(json!({ "cancelled": cancelled, "run_id": id })))
|
||||
}
|
||||
|
||||
pub async fn get_jobs(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
|
||||
let jobs = state
|
||||
.storage
|
||||
|
||||
@ -19,7 +19,7 @@ use crate::logging;
|
||||
use crate::mcp;
|
||||
use crate::memory::MemoryManager;
|
||||
use crate::scheduler::Scheduler;
|
||||
use crate::session::{SessionManager, SessionManagerServices};
|
||||
use crate::session::{AgentCatalogPreparation, SessionManager, SessionManagerServices};
|
||||
use crate::task_supervisor::TaskSupervisor;
|
||||
|
||||
/// Process boot clock. A process-level static so uptime survives config reload,
|
||||
@ -52,6 +52,7 @@ pub struct GatewayState {
|
||||
pub outbound_lanes: Arc<AtomicUsize>,
|
||||
pub(crate) reload: reload::ReloadHandle,
|
||||
pub(crate) admission: reload::RuntimeAdmission,
|
||||
pub agent_catalog: Arc<crate::agent::AgentCatalog>,
|
||||
}
|
||||
|
||||
impl GatewayState {
|
||||
@ -65,6 +66,7 @@ impl GatewayState {
|
||||
config_path,
|
||||
reload::ReloadHandle::unavailable(),
|
||||
true,
|
||||
1,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@ -74,6 +76,7 @@ impl GatewayState {
|
||||
config_path: std::path::PathBuf,
|
||||
reload: reload::ReloadHandle,
|
||||
initialize_process: bool,
|
||||
runtime_generation: u64,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let task_supervisor = TaskSupervisor::new();
|
||||
let admission = reload::RuntimeAdmission::open();
|
||||
@ -168,10 +171,34 @@ impl GatewayState {
|
||||
None
|
||||
};
|
||||
let health = Arc::new(crate::health::HealthService::new(config.clone()));
|
||||
let provider_profiles = if config.agent_orchestration.enabled {
|
||||
config
|
||||
.agents
|
||||
.keys()
|
||||
.filter_map(|name| {
|
||||
config
|
||||
.get_provider_config(name)
|
||||
.ok()
|
||||
.map(|profile| (name.clone(), profile))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
let config_dir = config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.to_path_buf();
|
||||
|
||||
// Create SessionManager with bus injection
|
||||
let session_manager = SessionManager::new(
|
||||
provider_config.clone(),
|
||||
AgentCatalogPreparation {
|
||||
provider_profiles,
|
||||
config: config.agent_orchestration.clone(),
|
||||
config_dir,
|
||||
runtime_generation,
|
||||
},
|
||||
storage.clone(),
|
||||
SessionManagerServices::new(
|
||||
bus.clone(),
|
||||
@ -186,6 +213,8 @@ impl GatewayState {
|
||||
config.gateway.max_concurrent_background_tasks,
|
||||
)?;
|
||||
let session_manager = Arc::new(session_manager);
|
||||
session_manager.bind_inbox_wake();
|
||||
let agent_catalog = session_manager.agent_catalog();
|
||||
|
||||
// Register send_message tool with available channel names
|
||||
let available_channels = channel_manager.list_channel_names().await;
|
||||
@ -254,6 +283,7 @@ impl GatewayState {
|
||||
outbound_lanes: Arc::new(AtomicUsize::new(0)),
|
||||
reload,
|
||||
admission,
|
||||
agent_catalog,
|
||||
})
|
||||
}
|
||||
|
||||
@ -269,6 +299,31 @@ impl GatewayState {
|
||||
|
||||
/// Start the message processing loops
|
||||
pub async fn start_message_processing(&self) {
|
||||
// Recover durable Agent state for this runtime generation: interrupt
|
||||
// runs of older generations, expire stale inbox leases, converge
|
||||
// group counters and reconcile capacity rows. Runs never recover
|
||||
// while the generation is still a candidate.
|
||||
if let Some(coordinator) = self.session_manager.agent_coordinator() {
|
||||
match coordinator.recover_on_activation().await {
|
||||
Ok(report) => {
|
||||
if report.interrupted_runs > 0 || report.leases_expired > 0 {
|
||||
tracing::warn!(
|
||||
interrupted = report.interrupted_runs,
|
||||
completion_events = report.completion_events_generated,
|
||||
leases_expired = report.leases_expired,
|
||||
dead_lettered = report.dead_lettered,
|
||||
sessions_reconciled = report.sessions_reconciled,
|
||||
groups_converged = report.groups_converged,
|
||||
"Agent state recovered on activation"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(error = %error, "Agent state recovery failed on activation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MCP connections have external/process-wide side effects. Activate
|
||||
// them only after this generation becomes current, never while it is
|
||||
// merely a reload candidate.
|
||||
@ -320,6 +375,23 @@ impl GatewayState {
|
||||
}
|
||||
});
|
||||
|
||||
// Relay durable Agent run/event projections. A lagged or lost
|
||||
// broadcast is non-fatal: the client recalibrates with GetAgentRuns.
|
||||
let mut agent_projection_events = self.session_manager.projection_hub().subscribe();
|
||||
let cli_chat = self.cli_chat_channel();
|
||||
self.task_supervisor
|
||||
.spawn("agent-projection-events", async move {
|
||||
loop {
|
||||
match agent_projection_events.recv().await {
|
||||
Ok(event) => cli_chat.publish_agent_projection(event).await,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
tracing::warn!(skipped, "Agent projection relay lagged");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router::spawn_message_routers(
|
||||
bus.clone(),
|
||||
session_manager,
|
||||
@ -380,6 +452,7 @@ pub async fn run(
|
||||
config_path.clone(),
|
||||
reload_controller.handle.clone(),
|
||||
true,
|
||||
1,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
@ -460,6 +533,7 @@ pub async fn run(
|
||||
config_path.clone(),
|
||||
reload_controller.handle.clone(),
|
||||
false,
|
||||
requested_generation,
|
||||
);
|
||||
tokio::pin!(preparation);
|
||||
let prepared = match tokio::select! {
|
||||
@ -600,6 +674,15 @@ fn build_router(state: Arc<GatewayState>) -> Router {
|
||||
.route("/api/skills", routing::get(http::get_skills))
|
||||
.route("/api/jobs", routing::get(http::get_jobs))
|
||||
.route("/api/jobs/{id}/runs", routing::get(http::get_job_runs))
|
||||
.route("/api/agent-runs", routing::get(http::get_agent_runs))
|
||||
.route(
|
||||
"/api/agent-runs/{id}",
|
||||
routing::get(http::get_agent_run).post(http::cancel_agent_run),
|
||||
)
|
||||
.route(
|
||||
"/api/agent-runs/{id}/events",
|
||||
routing::get(http::get_agent_run_events),
|
||||
)
|
||||
.route("/api/memories", routing::get(http::get_memories))
|
||||
.route(
|
||||
"/api/memories/{key}",
|
||||
|
||||
@ -330,6 +330,18 @@ async fn handle_control_message(session_manager: &SessionManager, message: Contr
|
||||
.await
|
||||
.map(|stats| SessionEvent::SessionStats { stats })
|
||||
.map_err(|error| ChannelError::Other(error.to_string())),
|
||||
GetAgentRuns {
|
||||
session_id,
|
||||
cursor,
|
||||
limit,
|
||||
} => session_manager
|
||||
.get_agent_runs(&session_id, cursor, limit)
|
||||
.await
|
||||
.map_err(|error| ChannelError::Other(error.to_string())),
|
||||
GetAgentRun { session_id, run_id } => session_manager
|
||||
.get_agent_run(&session_id, &run_id)
|
||||
.await
|
||||
.map_err(|error| ChannelError::Other(error.to_string())),
|
||||
RenameDialog { session_id, title } => session_manager
|
||||
.rename_dialog(&session_id, &title)
|
||||
.await
|
||||
@ -427,6 +439,7 @@ mod tests {
|
||||
channel_context: ChannelContext {
|
||||
reply_to: Some("parent".to_string()),
|
||||
private: HashMap::from([("opaque".to_string(), "value".to_string())]),
|
||||
durable_private: HashMap::new(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
248
src/protocol.rs
248
src/protocol.rs
@ -37,6 +37,137 @@ pub struct MessageAttachment {
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
/// Bounded client projection of a durable Agent run. Sensitive internals
|
||||
/// (budget, signal contract, delivery context, provider state) never leave
|
||||
/// the gateway; the full result is available through the HTTP API.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRunView {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_run_id: Option<String>,
|
||||
pub agent_id: String,
|
||||
pub provider_name: String,
|
||||
pub model_id: String,
|
||||
pub mode: String,
|
||||
pub depth: u16,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plan_item_id: Option<String>,
|
||||
pub status: String,
|
||||
/// Task prompt (bounded for client delivery).
|
||||
pub task: String,
|
||||
/// Bounded result excerpt; full content is loaded on demand.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
pub tool_calls_count: i64,
|
||||
pub iterations: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub deadline_at: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub finished_at: Option<i64>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// Bounded client projection of a durable inbox event.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentEventView {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub run_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group_id: Option<String>,
|
||||
pub event_type: String,
|
||||
pub delivery: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub severity: Option<String>,
|
||||
/// Structured payload, bounded; signal summaries and completion status
|
||||
/// only. The client must treat the payload as untrusted data.
|
||||
pub payload_json: String,
|
||||
pub status: String,
|
||||
pub attempt_count: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_error: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub consumed_at: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub superseded_at: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dead_lettered_at: Option<i64>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl AgentRunView {
|
||||
pub fn from_record(
|
||||
record: &crate::storage::agent_run::AgentRunRecord,
|
||||
max_result_chars: usize,
|
||||
) -> Self {
|
||||
let task = truncate(&record.task, 2_000);
|
||||
let result = record
|
||||
.result
|
||||
.as_deref()
|
||||
.map(|result| truncate(result, max_result_chars));
|
||||
Self {
|
||||
id: record.id.clone(),
|
||||
group_id: record.group_id.clone(),
|
||||
parent_run_id: record.parent_run_id.clone(),
|
||||
agent_id: record.agent_id.clone(),
|
||||
provider_name: record.provider_name.clone(),
|
||||
model_id: record.model_id.clone(),
|
||||
mode: record.mode.as_str().to_string(),
|
||||
depth: u16::try_from(record.depth).unwrap_or(u16::MAX),
|
||||
plan_item_id: record.plan_item_id.clone(),
|
||||
status: record.status.as_str().to_string(),
|
||||
task,
|
||||
result,
|
||||
error: record.error.clone(),
|
||||
tool_calls_count: record.tool_calls_count,
|
||||
iterations: record.iterations,
|
||||
deadline_at: Some(record.deadline_at),
|
||||
started_at: record.started_at,
|
||||
finished_at: record.finished_at,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentEventView {
|
||||
pub fn from_record(record: &crate::storage::agent_inbox::AgentInboxEventRecord) -> Self {
|
||||
Self {
|
||||
id: record.id.clone(),
|
||||
run_id: record.run_id.clone(),
|
||||
group_id: record.group_id.clone(),
|
||||
event_type: record.event_type.as_str().to_string(),
|
||||
delivery: record.delivery.as_str().to_string(),
|
||||
severity: record.severity.clone(),
|
||||
payload_json: record.payload_json.clone(),
|
||||
status: record.status.as_str().to_string(),
|
||||
attempt_count: record.attempt_count,
|
||||
last_error: record.last_error.clone(),
|
||||
consumed_at: record.consumed_at,
|
||||
superseded_at: record.superseded_at,
|
||||
dead_lettered_at: record.dead_lettered_at,
|
||||
created_at: record.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(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
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageAttachment {
|
||||
pub fn from_media_ref(index: usize, media_ref: &crate::bus::MediaRef) -> Self {
|
||||
let name = std::path::Path::new(&media_ref.path)
|
||||
@ -75,6 +206,8 @@ pub struct HistoryMessage {
|
||||
pub tool_calls: Option<Vec<crate::providers::ToolCall>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub attachments: Vec<MessageAttachment>,
|
||||
#[serde(default)]
|
||||
pub turn_origin: crate::bus::TurnOrigin,
|
||||
}
|
||||
|
||||
impl From<crate::bus::CommittedMessage> for HistoryMessage {
|
||||
@ -97,6 +230,7 @@ impl From<crate::bus::CommittedMessage> for HistoryMessage {
|
||||
tool_name: message.tool_name,
|
||||
tool_calls: message.tool_calls,
|
||||
attachments,
|
||||
turn_origin: message.turn_origin,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -126,6 +260,7 @@ impl HistoryMessage {
|
||||
.tool_calls
|
||||
.and_then(|calls| serde_json::from_str(&calls).ok()),
|
||||
attachments,
|
||||
turn_origin: message.turn_origin,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -196,6 +331,16 @@ pub enum WsInbound {
|
||||
},
|
||||
#[serde(rename = "get_slash_commands")]
|
||||
GetSlashCommands,
|
||||
#[serde(rename = "get_agent_runs")]
|
||||
GetAgentRuns {
|
||||
session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
cursor: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
#[serde(rename = "get_agent_run")]
|
||||
GetAgentRun { session_id: String, run_id: String },
|
||||
#[serde(rename = "ping")]
|
||||
Ping,
|
||||
}
|
||||
@ -274,6 +419,26 @@ pub enum WsOutbound {
|
||||
HistoryCleared { session_id: String },
|
||||
#[serde(rename = "slash_commands_list")]
|
||||
SlashCommandsList { commands: Vec<SlashCommandInfo> },
|
||||
#[serde(rename = "session_agent_runs")]
|
||||
SessionAgentRuns {
|
||||
session_id: String,
|
||||
revision: i64,
|
||||
runs: Vec<AgentRunView>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
next_cursor: Option<String>,
|
||||
},
|
||||
#[serde(rename = "agent_run_updated")]
|
||||
AgentRunUpdated {
|
||||
session_id: String,
|
||||
revision: i64,
|
||||
run: AgentRunView,
|
||||
},
|
||||
#[serde(rename = "agent_event_updated")]
|
||||
AgentEventUpdated {
|
||||
session_id: String,
|
||||
revision: i64,
|
||||
event: AgentEventView,
|
||||
},
|
||||
#[serde(rename = "pong")]
|
||||
Pong,
|
||||
#[serde(rename = "command_executed")]
|
||||
@ -343,6 +508,7 @@ mod tests {
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
attachments: Vec::new(),
|
||||
turn_origin: crate::bus::TurnOrigin::User,
|
||||
}],
|
||||
};
|
||||
let value = serde_json::to_value(frame).unwrap();
|
||||
@ -443,4 +609,86 @@ mod tests {
|
||||
assert_eq!(value["stats"]["context"]["source"], "hybrid");
|
||||
assert_eq!(value["stats"]["lifetime_usage"]["input_tokens"], 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_run_and_event_views_serialize_without_sensitive_fields() {
|
||||
let run = crate::storage::agent_run::AgentRunRecord {
|
||||
id: "run-1".to_string(),
|
||||
group_id: None,
|
||||
root_session_id: "cli:test:d1".to_string(),
|
||||
root_turn_id: None,
|
||||
parent_run_id: None,
|
||||
caller_agent_id: "ROOT".to_string(),
|
||||
caller_scope_id: "turn-1".to_string(),
|
||||
idempotency_key: None,
|
||||
agent_id: "researcher".to_string(),
|
||||
definition_hash: "hash".to_string(),
|
||||
provider_profile: "research".to_string(),
|
||||
provider_name: "openai-test".to_string(),
|
||||
model_id: "model-x".to_string(),
|
||||
mode: crate::storage::agent_run::AgentRunMode::Background,
|
||||
depth: 1,
|
||||
plan_item_id: None,
|
||||
execution_id: "run-1".to_string(),
|
||||
task: "analyze the logs".to_string(),
|
||||
context_json: Some("sensitive caller context".to_string()),
|
||||
budget_json: r#"{"remaining_runs":3}"#.to_string(),
|
||||
signal_contract_json: Some("secret contract".to_string()),
|
||||
signal_delivery: None,
|
||||
completion_delivery: None,
|
||||
failure_delivery: None,
|
||||
status: crate::storage::agent_run::AgentRunStatus::Completed,
|
||||
result: Some("r".repeat(10_000)),
|
||||
error: None,
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
cost: None,
|
||||
tool_calls_count: 3,
|
||||
iterations: 2,
|
||||
runtime_generation: 1,
|
||||
attempt: 1,
|
||||
completion_slot_reserved: false,
|
||||
deadline_at: 1000,
|
||||
revision: 7,
|
||||
started_at: Some(10),
|
||||
finished_at: Some(20),
|
||||
created_at: 5,
|
||||
updated_at: 20,
|
||||
};
|
||||
let view = AgentRunView::from_record(&run, 2_000);
|
||||
let value = serde_json::to_value(&view).unwrap();
|
||||
assert_eq!(value["id"], "run-1");
|
||||
assert_eq!(value["status"], "completed");
|
||||
assert!(!value.as_object().unwrap().contains_key("context_json"));
|
||||
assert!(!value.as_object().unwrap().contains_key("budget_json"));
|
||||
assert!(
|
||||
!value
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("signal_contract_json")
|
||||
);
|
||||
assert!(!value.as_object().unwrap().contains_key("execution_id"));
|
||||
// The result is bounded for client delivery.
|
||||
assert!(value["result"].as_str().unwrap().chars().count() <= 2_001);
|
||||
|
||||
let message = WsOutbound::AgentRunUpdated {
|
||||
session_id: "cli:test:d1".to_string(),
|
||||
revision: 7,
|
||||
run: view,
|
||||
};
|
||||
let value = serde_json::to_value(message).unwrap();
|
||||
assert_eq!(value["type"], "agent_run_updated");
|
||||
assert_eq!(value["revision"], 7);
|
||||
|
||||
let inbound: WsInbound =
|
||||
parse_inbound(r#"{"type":"get_agent_runs","session_id":"cli:test:d1","limit":10}"#)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
inbound,
|
||||
WsInbound::GetAgentRuns {
|
||||
limit: Some(10),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,17 @@ pub enum SessionCommand {
|
||||
GetTaskPlan { session_id: UnifiedSessionId },
|
||||
/// Load token totals and context-window state for a dialog.
|
||||
GetSessionStats { session_id: UnifiedSessionId },
|
||||
/// Load the durable Agent run projection for a dialog.
|
||||
GetAgentRuns {
|
||||
session_id: UnifiedSessionId,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
},
|
||||
/// Load one durable Agent run projection.
|
||||
GetAgentRun {
|
||||
session_id: UnifiedSessionId,
|
||||
run_id: String,
|
||||
},
|
||||
/// Get the current dialog for a chat
|
||||
GetCurrentDialog { channel: String, chat_id: String },
|
||||
/// Rename a dialog
|
||||
|
||||
@ -43,6 +43,19 @@ pub enum SessionEvent {
|
||||
},
|
||||
/// Provider usage totals and current context-window state.
|
||||
SessionStats { stats: crate::session::SessionStats },
|
||||
/// Durable Agent run projection page for a dialog.
|
||||
AgentRuns {
|
||||
session_id: UnifiedSessionId,
|
||||
revision: i64,
|
||||
runs: Vec<crate::protocol::AgentRunView>,
|
||||
next_cursor: Option<String>,
|
||||
},
|
||||
/// One durable Agent run projection.
|
||||
AgentRun {
|
||||
session_id: UnifiedSessionId,
|
||||
revision: i64,
|
||||
run: Option<crate::protocol::AgentRunView>,
|
||||
},
|
||||
/// Dialog renamed
|
||||
DialogRenamed {
|
||||
session_id: UnifiedSessionId,
|
||||
|
||||
@ -139,6 +139,9 @@ impl SessionManager {
|
||||
from_user_id: None,
|
||||
system_name: Some(job_name.to_string()),
|
||||
task_id: Some(job_id.to_string()),
|
||||
from_run_id: None,
|
||||
from_agent_id: None,
|
||||
group_id: None,
|
||||
},
|
||||
Vec::new(),
|
||||
)
|
||||
@ -171,6 +174,9 @@ mod tests {
|
||||
from_user_id: None,
|
||||
system_name: None,
|
||||
task_id: None,
|
||||
from_run_id: None,
|
||||
from_agent_id: None,
|
||||
group_id: None,
|
||||
};
|
||||
let media = vec![MediaItem::new("/tmp/report.pdf", "file")];
|
||||
|
||||
|
||||
@ -14,7 +14,10 @@ pub mod turn;
|
||||
pub use commands::SessionCommand;
|
||||
pub use error::SessionError;
|
||||
pub use events::{DialogInfo, SessionEvent};
|
||||
pub use session::{SLASH_COMMANDS, Session, SessionManager, SessionManagerServices, SlashCommand};
|
||||
pub use session::{
|
||||
AgentCatalogPreparation, SLASH_COMMANDS, Session, SessionManager, SessionManagerServices,
|
||||
SlashCommand,
|
||||
};
|
||||
pub use session_id::UnifiedSessionId;
|
||||
pub use stats::{ContextUsage, ContextUsageSource, LifetimeUsage, SessionStats};
|
||||
pub use turn::{
|
||||
|
||||
@ -11,6 +11,7 @@ use crate::{providers::Usage, session::TurnController};
|
||||
async fn persist_added_messages(
|
||||
snapshots: Vec<Option<MessagePersistSnapshot>>,
|
||||
usage: Option<&crate::storage::TurnUsageRecord>,
|
||||
steer: Option<&crate::storage::agent_inbox::SteerConsumption>,
|
||||
) -> Result<(), StorageError> {
|
||||
let mut storage = None;
|
||||
let mut session_id = None;
|
||||
@ -36,7 +37,17 @@ async fn persist_added_messages(
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(usage) = usage {
|
||||
if let Some(steer) = steer {
|
||||
storage
|
||||
.persist_turn_with_steer_with_retry(
|
||||
&session_id,
|
||||
&messages,
|
||||
&final_meta,
|
||||
usage.unwrap(),
|
||||
steer,
|
||||
)
|
||||
.await
|
||||
} else if let Some(usage) = usage {
|
||||
storage
|
||||
.persist_turn_batch_with_retry(&session_id, &messages, &final_meta, usage)
|
||||
.await
|
||||
@ -77,6 +88,7 @@ pub(super) async fn append_active_turn_message(
|
||||
vec![message],
|
||||
VersionPolicy::PreserveForOwnedTurn(turn_id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
@ -86,7 +98,7 @@ pub(super) async fn append_persisted_messages_with_meta(
|
||||
session: &Arc<Mutex<Session>>,
|
||||
messages: Vec<ChatMessage>,
|
||||
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
||||
append_persisted_messages_inner(session, messages, VersionPolicy::Advance, None).await
|
||||
append_persisted_messages_inner(session, messages, VersionPolicy::Advance, None, None).await
|
||||
}
|
||||
|
||||
pub(super) async fn append_persisted_turn_messages(
|
||||
@ -94,7 +106,25 @@ pub(super) async fn append_persisted_turn_messages(
|
||||
messages: Vec<ChatMessage>,
|
||||
usage: crate::storage::TurnUsageRecord,
|
||||
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
||||
append_persisted_messages_inner(session, messages, VersionPolicy::Advance, Some(usage)).await
|
||||
append_persisted_messages_inner(session, messages, VersionPolicy::Advance, Some(usage), None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persist a Turn and consume its admitted steer events atomically.
|
||||
pub(super) async fn append_persisted_turn_messages_with_steer(
|
||||
session: &Arc<Mutex<Session>>,
|
||||
messages: Vec<ChatMessage>,
|
||||
usage: crate::storage::TurnUsageRecord,
|
||||
steer: crate::storage::agent_inbox::SteerConsumption,
|
||||
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
||||
append_persisted_messages_inner(
|
||||
session,
|
||||
messages,
|
||||
VersionPolicy::Advance,
|
||||
Some(usage),
|
||||
Some(steer),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn append_persisted_messages_inner(
|
||||
@ -102,6 +132,7 @@ async fn append_persisted_messages_inner(
|
||||
messages: Vec<ChatMessage>,
|
||||
version_policy: VersionPolicy,
|
||||
usage: Option<crate::storage::TurnUsageRecord>,
|
||||
steer: Option<crate::storage::agent_inbox::SteerConsumption>,
|
||||
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
||||
if messages.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@ -130,7 +161,7 @@ async fn append_persisted_messages_inner(
|
||||
.map(|(_, _, message, _)| message.clone())
|
||||
.collect();
|
||||
|
||||
if let Err(error) = persist_added_messages(snapshots, usage.as_ref()).await {
|
||||
if let Err(error) = persist_added_messages(snapshots, usage.as_ref(), steer.as_ref()).await {
|
||||
session
|
||||
.lock()
|
||||
.await
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -368,6 +368,29 @@ impl SkillsLoader {
|
||||
prompt
|
||||
}
|
||||
|
||||
/// Build the minimal prompt exposed to a named Agent. The Agent can only
|
||||
/// discover the allowlisted skills through its scoped get_skill tool.
|
||||
pub fn build_scoped_skills_prompt(&self, allowed: &[String]) -> String {
|
||||
if allowed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let allowed: std::collections::HashSet<_> = allowed.iter().map(String::as_str).collect();
|
||||
let skills: Vec<_> = self
|
||||
.get_loaded_skills()
|
||||
.into_iter()
|
||||
.filter(|skill| allowed.contains(skill.name.as_str()))
|
||||
.collect();
|
||||
if skills.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut prompt = String::from("## 可用 Skills\n\n");
|
||||
for skill in skills {
|
||||
prompt.push_str(&format!("- **{}**: {}\n", skill.name, skill.description));
|
||||
}
|
||||
prompt.push_str("\n需要详细说明时,使用 `get_skill` 读取上述 allowlist 中的 skill。");
|
||||
prompt
|
||||
}
|
||||
|
||||
fn sort_skills(mut skills: Vec<Skill>) -> Vec<Skill> {
|
||||
skills.sort_by(|a, b| {
|
||||
b.always
|
||||
|
||||
2031
src/storage/agent_inbox.rs
Normal file
2031
src/storage/agent_inbox.rs
Normal file
File diff suppressed because it is too large
Load Diff
1743
src/storage/agent_run.rs
Normal file
1743
src/storage/agent_run.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bus::CompletionStatus;
|
||||
use crate::bus::{ClientVisibility, CompletionStatus, TurnOrigin};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageMeta {
|
||||
@ -14,6 +14,8 @@ pub struct MessageMeta {
|
||||
pub turn_id: Option<String>,
|
||||
pub iteration: Option<i64>,
|
||||
pub completion_status: CompletionStatus,
|
||||
pub client_visibility: ClientVisibility,
|
||||
pub turn_origin: TurnOrigin,
|
||||
pub media_refs: Option<String>,
|
||||
pub tool_call_id: Option<String>,
|
||||
pub tool_name: Option<String>,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
pub mod agent_inbox;
|
||||
pub mod agent_run;
|
||||
pub mod background_task;
|
||||
pub mod error;
|
||||
pub mod memory;
|
||||
@ -18,17 +20,17 @@ use sqlx::{Pool, Row, Sqlite};
|
||||
use std::path::Path;
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 5;
|
||||
const SCHEMA_VERSION: i64 = 6;
|
||||
const INSERT_MESSAGE_SQL: &str = r#"
|
||||
INSERT INTO messages (
|
||||
id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||
turn_id, iteration, completion_status, media_refs, tool_call_id,
|
||||
tool_name, tool_calls, source, created_at
|
||||
turn_id, iteration, completion_status, client_visibility, turn_origin,
|
||||
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#;
|
||||
|
||||
fn insert_message_query<'a>(
|
||||
pub(crate) fn insert_message_query<'a>(
|
||||
session_id: &'a str,
|
||||
msg: &'a crate::storage::message::MessageMeta,
|
||||
) -> sqlx::query::Query<'a, Sqlite, sqlx::sqlite::SqliteArguments> {
|
||||
@ -43,6 +45,8 @@ fn insert_message_query<'a>(
|
||||
.bind(&msg.turn_id)
|
||||
.bind(msg.iteration)
|
||||
.bind(msg.completion_status.as_str())
|
||||
.bind(msg.client_visibility.as_str())
|
||||
.bind(msg.turn_origin.as_str())
|
||||
.bind(&msg.media_refs)
|
||||
.bind(&msg.tool_call_id)
|
||||
.bind(&msg.tool_name)
|
||||
@ -64,6 +68,8 @@ fn message_meta_from_row(row: SqliteRow) -> crate::storage::message::MessageMeta
|
||||
turn_id: row.get("turn_id"),
|
||||
iteration: row.get("iteration"),
|
||||
completion_status: crate::bus::CompletionStatus::from_storage(&completion_status),
|
||||
client_visibility: parse_visibility(row.get("client_visibility")),
|
||||
turn_origin: parse_turn_origin(row.get("turn_origin")),
|
||||
media_refs: row.get("media_refs"),
|
||||
tool_call_id: row.get("tool_call_id"),
|
||||
tool_name: row.get("tool_name"),
|
||||
@ -73,6 +79,22 @@ fn message_meta_from_row(row: SqliteRow) -> crate::storage::message::MessageMeta
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_visibility(value: String) -> crate::bus::ClientVisibility {
|
||||
if value == "hidden" {
|
||||
crate::bus::ClientVisibility::Hidden
|
||||
} else {
|
||||
crate::bus::ClientVisibility::Visible
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_turn_origin(value: String) -> crate::bus::TurnOrigin {
|
||||
match value.as_str() {
|
||||
"agent_continuation" => crate::bus::TurnOrigin::AgentContinuation,
|
||||
"scheduled" => crate::bus::TurnOrigin::Scheduled,
|
||||
_ => crate::bus::TurnOrigin::User,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Storage {
|
||||
pub(crate) pool: Pool<Sqlite>,
|
||||
}
|
||||
@ -115,6 +137,8 @@ impl Storage {
|
||||
deleted_at INTEGER,
|
||||
last_consolidated_at INTEGER,
|
||||
last_compressed_message_at INTEGER,
|
||||
delivery_context TEXT,
|
||||
delivery_context_updated_at INTEGER,
|
||||
UNIQUE(channel, chat_id, dialog_id)
|
||||
)
|
||||
"#,
|
||||
@ -149,6 +173,8 @@ impl Storage {
|
||||
turn_id TEXT,
|
||||
iteration INTEGER,
|
||||
completion_status TEXT NOT NULL DEFAULT 'completed',
|
||||
client_visibility TEXT NOT NULL DEFAULT 'visible',
|
||||
turn_origin TEXT NOT NULL DEFAULT 'user',
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
@ -424,6 +450,16 @@ impl Storage {
|
||||
"completion_status",
|
||||
"completion_status TEXT NOT NULL DEFAULT 'completed'",
|
||||
),
|
||||
(
|
||||
"messages",
|
||||
"client_visibility",
|
||||
"client_visibility TEXT NOT NULL DEFAULT 'visible'",
|
||||
),
|
||||
(
|
||||
"messages",
|
||||
"turn_origin",
|
||||
"turn_origin TEXT NOT NULL DEFAULT 'user'",
|
||||
),
|
||||
("sessions", "archived_at", "archived_at INTEGER"),
|
||||
(
|
||||
"sessions",
|
||||
@ -435,6 +471,12 @@ impl Storage {
|
||||
"last_compressed_message_at",
|
||||
"last_compressed_message_at INTEGER",
|
||||
),
|
||||
("sessions", "delivery_context", "delivery_context TEXT"),
|
||||
(
|
||||
"sessions",
|
||||
"delivery_context_updated_at",
|
||||
"delivery_context_updated_at INTEGER",
|
||||
),
|
||||
("scheduled_jobs", "locked_at", "locked_at INTEGER"),
|
||||
("scheduled_jobs", "lock_owner", "lock_owner TEXT"),
|
||||
("scheduled_jobs", "lease_until", "lease_until INTEGER"),
|
||||
@ -521,6 +563,14 @@ impl Storage {
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_messages_session_visibility_seq ON messages(session_id, client_visibility, seq)",
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for statement in agent_run::AGENT_SCHEMA_STATEMENTS {
|
||||
sqlx::query(*statement).execute(&mut *tx).await?;
|
||||
}
|
||||
sqlx::query(sqlx::AssertSqlSafe(format!(
|
||||
"PRAGMA user_version = {SCHEMA_VERSION}"
|
||||
)))
|
||||
@ -863,7 +913,9 @@ impl Storage {
|
||||
msgs: &[crate::storage::message::MessageMeta],
|
||||
meta: &crate::storage::session::SessionMeta,
|
||||
usage: Option<&crate::storage::TurnUsageRecord>,
|
||||
steer: Option<&crate::storage::agent_inbox::SteerConsumption>,
|
||||
) -> Result<(), StorageError> {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
for msg in msgs {
|
||||
@ -935,6 +987,38 @@ impl Storage {
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(steer) = steer {
|
||||
let mut consumed = 0i64;
|
||||
for event_id in &steer.event_ids {
|
||||
let rows = sqlx::query(
|
||||
"UPDATE agent_inbox_events SET status = 'consumed', consumed_at = ?, \
|
||||
updated_at = ? \
|
||||
WHERE id = ? AND status = 'admitted' AND admitted_turn_id = ?",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(event_id)
|
||||
.bind(&steer.admitted_turn_id)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
consumed += rows as i64;
|
||||
}
|
||||
if consumed > 0 {
|
||||
sqlx::query(
|
||||
"UPDATE agent_session_state \
|
||||
SET pending_event_count = MAX(pending_event_count - ?, 0), \
|
||||
revision = revision + 1, updated_at = ? \
|
||||
WHERE root_session_id = ?",
|
||||
)
|
||||
.bind(consumed)
|
||||
.bind(now)
|
||||
.bind(session_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
@ -945,7 +1029,7 @@ impl Storage {
|
||||
msgs: &[crate::storage::message::MessageMeta],
|
||||
meta: &crate::storage::session::SessionMeta,
|
||||
) -> Result<(), StorageError> {
|
||||
self.persist_message_batch_inner(session_id, msgs, meta, None)
|
||||
self.persist_message_batch_inner(session_id, msgs, meta, None, None)
|
||||
.await
|
||||
}
|
||||
|
||||
@ -956,7 +1040,22 @@ impl Storage {
|
||||
meta: &crate::storage::session::SessionMeta,
|
||||
usage: &crate::storage::TurnUsageRecord,
|
||||
) -> Result<(), StorageError> {
|
||||
self.persist_message_batch_inner(session_id, msgs, meta, Some(usage))
|
||||
self.persist_message_batch_inner(session_id, msgs, meta, Some(usage), None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persist a Turn and consume the admitted steer events of the same Turn
|
||||
/// in one transaction: the steer inputs appear in history exactly when
|
||||
/// their durable events become `consumed`.
|
||||
pub async fn persist_turn_batch_with_steer_consumption(
|
||||
&self,
|
||||
session_id: &str,
|
||||
msgs: &[crate::storage::message::MessageMeta],
|
||||
meta: &crate::storage::session::SessionMeta,
|
||||
usage: &crate::storage::TurnUsageRecord,
|
||||
steer: &crate::storage::agent_inbox::SteerConsumption,
|
||||
) -> Result<(), StorageError> {
|
||||
self.persist_message_batch_inner(session_id, msgs, meta, Some(usage), Some(steer))
|
||||
.await
|
||||
}
|
||||
|
||||
@ -1003,6 +1102,32 @@ impl Storage {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// Persist a Turn with steer consumption and bounded retry.
|
||||
pub async fn persist_turn_with_steer_with_retry(
|
||||
&self,
|
||||
session_id: &str,
|
||||
msgs: &[crate::storage::message::MessageMeta],
|
||||
meta: &crate::storage::session::SessionMeta,
|
||||
usage: &crate::storage::TurnUsageRecord,
|
||||
steer: &crate::storage::agent_inbox::SteerConsumption,
|
||||
) -> Result<(), StorageError> {
|
||||
let delays = [100, 200, 300];
|
||||
for (attempt, delay) in delays.iter().enumerate() {
|
||||
match self
|
||||
.persist_turn_batch_with_steer_consumption(session_id, msgs, meta, usage, steer)
|
||||
.await
|
||||
{
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) if attempt < delays.len() - 1 && error.is_transient() => {
|
||||
tracing::warn!(attempt = attempt + 1, error = %error, "Turn persistence failed; retrying");
|
||||
sleep(Duration::from_millis(*delay)).await;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub async fn get_session_usage_totals(
|
||||
&self,
|
||||
session_id: &str,
|
||||
@ -1064,8 +1189,8 @@ impl Storage {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||
turn_id, iteration, completion_status, media_refs, tool_call_id,
|
||||
tool_name, tool_calls, source, created_at
|
||||
turn_id, iteration, completion_status, client_visibility, turn_origin,
|
||||
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
|
||||
FROM messages
|
||||
WHERE session_id = ? AND seq >= ?
|
||||
ORDER BY seq ASC
|
||||
@ -1087,8 +1212,8 @@ impl Storage {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||
turn_id, iteration, completion_status, media_refs, tool_call_id,
|
||||
tool_name, tool_calls, source, created_at
|
||||
turn_id, iteration, completion_status, client_visibility, turn_origin,
|
||||
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
|
||||
FROM messages
|
||||
WHERE session_id = ? AND id = ?
|
||||
"#,
|
||||
@ -1121,14 +1246,14 @@ impl Storage {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||
turn_id, iteration, completion_status, media_refs, tool_call_id,
|
||||
tool_name, tool_calls, source, created_at
|
||||
turn_id, iteration, completion_status, client_visibility, turn_origin,
|
||||
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
|
||||
FROM (
|
||||
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||
turn_id, iteration, completion_status, media_refs, tool_call_id,
|
||||
tool_name, tool_calls, source, created_at
|
||||
turn_id, iteration, completion_status, client_visibility, turn_origin,
|
||||
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
WHERE session_id = ? AND client_visibility = 'visible'
|
||||
ORDER BY seq DESC
|
||||
LIMIT ?
|
||||
)
|
||||
@ -1151,8 +1276,8 @@ impl Storage {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||
turn_id, iteration, completion_status, media_refs, tool_call_id,
|
||||
tool_name, tool_calls, source, created_at
|
||||
turn_id, iteration, completion_status, client_visibility, turn_origin,
|
||||
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
|
||||
FROM messages
|
||||
WHERE session_id = ? AND created_at > ?
|
||||
ORDER BY seq ASC
|
||||
@ -1221,8 +1346,8 @@ impl Storage {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||
turn_id, iteration, completion_status, media_refs, tool_call_id,
|
||||
tool_name, tool_calls, source, created_at
|
||||
turn_id, iteration, completion_status, client_visibility, turn_origin,
|
||||
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY seq DESC
|
||||
@ -1256,16 +1381,16 @@ impl Storage {
|
||||
}
|
||||
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) as total FROM messages WHERE session_id = ?{}",
|
||||
"SELECT COUNT(*) as total FROM messages WHERE session_id = ? AND client_visibility = 'visible'{}",
|
||||
where_extra
|
||||
);
|
||||
let select_sql = format!(
|
||||
r#"
|
||||
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||
turn_id, iteration, completion_status, media_refs, tool_call_id,
|
||||
tool_name, tool_calls, source, created_at
|
||||
turn_id, iteration, completion_status, client_visibility, turn_origin,
|
||||
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
|
||||
FROM messages
|
||||
WHERE session_id = ?{}
|
||||
WHERE session_id = ? AND client_visibility = 'visible'{}
|
||||
ORDER BY seq ASC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
@ -1504,6 +1629,42 @@ impl Storage {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Persist the channel's durable delivery context for a session. Only
|
||||
/// channel-declared reusable values (thread/root identity) ever reach
|
||||
/// this column; one-shot reply/reaction ids never do.
|
||||
pub async fn update_session_delivery_context(
|
||||
&self,
|
||||
session_id: &str,
|
||||
context_json: &str,
|
||||
now: i64,
|
||||
) -> Result<(), StorageError> {
|
||||
sqlx::query(
|
||||
"UPDATE sessions SET delivery_context = ?, delivery_context_updated_at = ? \
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(context_json)
|
||||
.bind(now)
|
||||
.bind(session_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Durable delivery context previously saved for the session.
|
||||
pub async fn get_session_delivery_context(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Option<String>, StorageError> {
|
||||
let context: Option<String> = sqlx::query_scalar(
|
||||
"SELECT delivery_context FROM sessions \
|
||||
WHERE id = ? AND delivery_context IS NOT NULL",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
pub async fn cleanup_old_tasks(&self, ttl_ms: i64) -> Result<usize, StorageError> {
|
||||
let cutoff = chrono::Utc::now().timestamp_millis() - ttl_ms;
|
||||
let result = sqlx::query(
|
||||
@ -1839,6 +2000,18 @@ mod tests {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO sessions (id, channel, chat_id, dialog_id, created_at, last_active_at) VALUES ('cli:c:d', 'cli', 'c', 'd', 1, 1)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO messages (id, session_id, seq, role, content, created_at) VALUES ('m1', 'cli:c:d', 1, 'user', 'legacy message', 1)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(pool);
|
||||
|
||||
let storage = Storage::new(&db_path).await.unwrap();
|
||||
@ -1852,6 +2025,8 @@ mod tests {
|
||||
"turn_id",
|
||||
"iteration",
|
||||
"completion_status",
|
||||
"client_visibility",
|
||||
"turn_origin",
|
||||
],
|
||||
),
|
||||
(
|
||||
@ -1860,6 +2035,8 @@ mod tests {
|
||||
"archived_at",
|
||||
"last_consolidated_at",
|
||||
"last_compressed_message_at",
|
||||
"delivery_context",
|
||||
"delivery_context_updated_at",
|
||||
],
|
||||
),
|
||||
(
|
||||
@ -1885,7 +2062,15 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(schema_version, SCHEMA_VERSION);
|
||||
for table in ["task_plans", "task_items", "session_turn_usage"] {
|
||||
for table in [
|
||||
"task_plans",
|
||||
"task_items",
|
||||
"session_turn_usage",
|
||||
"agent_run_groups",
|
||||
"agent_runs",
|
||||
"agent_session_state",
|
||||
"agent_inbox_events",
|
||||
] {
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
)
|
||||
@ -1895,6 +2080,19 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(exists, 1, "missing migrated table {table}");
|
||||
}
|
||||
|
||||
let visibility: String =
|
||||
sqlx::query_scalar("SELECT client_visibility FROM messages ORDER BY seq LIMIT 1")
|
||||
.fetch_one(storage.pool())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
assert_eq!(visibility, "visible");
|
||||
let origin: String =
|
||||
sqlx::query_scalar("SELECT turn_origin FROM messages ORDER BY seq LIMIT 1")
|
||||
.fetch_one(storage.pool())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
assert_eq!(origin, "user");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -2100,6 +2298,8 @@ mod tests {
|
||||
turn_id: None,
|
||||
iteration: None,
|
||||
completion_status: crate::bus::CompletionStatus::Completed,
|
||||
client_visibility: crate::bus::ClientVisibility::Visible,
|
||||
turn_origin: crate::bus::TurnOrigin::User,
|
||||
media_refs: None,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
@ -2171,6 +2371,8 @@ mod tests {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
iteration: Some(2),
|
||||
completion_status: crate::bus::CompletionStatus::Interrupted,
|
||||
client_visibility: crate::bus::ClientVisibility::Visible,
|
||||
turn_origin: crate::bus::TurnOrigin::User,
|
||||
media_refs: None,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
@ -2229,6 +2431,8 @@ mod tests {
|
||||
turn_id: None,
|
||||
iteration: None,
|
||||
completion_status: crate::bus::CompletionStatus::Completed,
|
||||
client_visibility: crate::bus::ClientVisibility::Visible,
|
||||
turn_origin: crate::bus::TurnOrigin::User,
|
||||
media_refs: None,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
@ -2277,4 +2481,51 @@ mod tests {
|
||||
assert_eq!(loaded.message_count, 5);
|
||||
assert_eq!(loaded.last_active_at, 2000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn durable_delivery_context_round_trips() {
|
||||
let (storage, _dir) = create_test_storage().await;
|
||||
let meta = crate::storage::session::SessionMeta {
|
||||
id: "feishu:chat-1:dialog".to_string(),
|
||||
channel: "feishu".to_string(),
|
||||
chat_id: "chat-1".to_string(),
|
||||
dialog_id: "dialog".to_string(),
|
||||
title: "t".to_string(),
|
||||
created_at: 1,
|
||||
last_active_at: 2,
|
||||
message_count: 0,
|
||||
routing_info: None,
|
||||
archived_at: None,
|
||||
deleted_at: None,
|
||||
last_consolidated_at: None,
|
||||
last_compressed_message_at: None,
|
||||
};
|
||||
storage.upsert_session(&meta).await.unwrap();
|
||||
|
||||
assert!(
|
||||
storage
|
||||
.get_session_delivery_context("feishu:chat-1:dialog")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let context = serde_json::json!({
|
||||
"feishu.thread_id": "thread-9",
|
||||
"feishu.chat_type": "group",
|
||||
})
|
||||
.to_string();
|
||||
storage
|
||||
.update_session_delivery_context("feishu:chat-1:dialog", &context, 50)
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = storage
|
||||
.get_session_delivery_context("feishu:chat-1:dialog")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let parsed: std::collections::HashMap<String, String> =
|
||||
serde_json::from_str(&loaded).unwrap();
|
||||
assert_eq!(parsed.get("feishu.thread_id").unwrap(), "thread-9");
|
||||
}
|
||||
}
|
||||
|
||||
251
src/tools/agent_task.rs
Normal file
251
src/tools/agent_task.rs
Normal file
@ -0,0 +1,251 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::agent::AgentCoordinator;
|
||||
use crate::storage::agent_run::AgentRunRecord;
|
||||
use crate::tools::traits::{DelegationPolicy, Tool, ToolExecutionContext, ToolOutput, ToolResult};
|
||||
|
||||
const RESULT_PREVIEW_CHARS: usize = 2_000;
|
||||
|
||||
/// Scoped inspection and control of durable Agent runs. Authorization is
|
||||
/// derived from the caller's ToolExecutionContext (session for ROOT, tree
|
||||
/// position for named Agents); run IDs are never credentials.
|
||||
pub struct AgentTaskTool {
|
||||
coordinator: Arc<AgentCoordinator>,
|
||||
}
|
||||
|
||||
impl AgentTaskTool {
|
||||
pub fn new(coordinator: Arc<AgentCoordinator>) -> Self {
|
||||
Self { coordinator }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for AgentTaskTool {
|
||||
fn name(&self) -> &str {
|
||||
"agent_task"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Inspect or control delegated Agent runs: get reads one run, list shows the session's runs, get_result returns the full terminal result, cancel stops a non-terminal run."
|
||||
}
|
||||
|
||||
fn delegation_policy(&self) -> DelegationPolicy {
|
||||
DelegationPolicy::RuntimeInjected
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get", "list", "get_result", "cancel"],
|
||||
"description": "Operation to perform on Agent runs"
|
||||
},
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"description": "Target run identifier for get/get_result/cancel"
|
||||
},
|
||||
"cursor_created_at": {
|
||||
"type": "integer",
|
||||
"description": "Pagination cursor: created_at of the last run seen"
|
||||
},
|
||||
"cursor_id": {
|
||||
"type": "string",
|
||||
"description": "Pagination cursor: id of the last run seen"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "Maximum number of runs to list (default 20)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
fn read_only(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
self.execute_with_context(&ToolExecutionContext::default(), args)
|
||||
.await
|
||||
.map(|output| output.result)
|
||||
}
|
||||
|
||||
async fn execute_with_context(
|
||||
&self,
|
||||
context: &ToolExecutionContext,
|
||||
args: Value,
|
||||
) -> anyhow::Result<ToolOutput> {
|
||||
let action = args
|
||||
.get("action")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let result = match action {
|
||||
"get" => self.handle_get(context, &args).await,
|
||||
"list" => self.handle_list(context, &args).await,
|
||||
"get_result" => self.handle_get_result(context, &args).await,
|
||||
"cancel" => self.handle_cancel(context, &args).await,
|
||||
other => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"unknown agent_task action '{other}'; supported: get, list, get_result, cancel"
|
||||
)),
|
||||
}),
|
||||
};
|
||||
Ok(result?.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentTaskTool {
|
||||
fn run_id<'a>(&self, args: &'a Value) -> anyhow::Result<&'a str> {
|
||||
args.get("run_id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required parameter: run_id"))
|
||||
}
|
||||
|
||||
async fn handle_get(
|
||||
&self,
|
||||
context: &ToolExecutionContext,
|
||||
args: &Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
let run_id = self.run_id(args)?;
|
||||
match self.coordinator.get_run(context, run_id).await {
|
||||
Ok(Some(run)) => Ok(success(run_projection(&run, true))),
|
||||
Ok(None) => Ok(failure(format!("run not found: {run_id}"))),
|
||||
Err(error) => Ok(failure(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
&self,
|
||||
context: &ToolExecutionContext,
|
||||
args: &Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
let cursor = match (
|
||||
args.get("cursor_created_at").and_then(Value::as_i64),
|
||||
args.get("cursor_id").and_then(Value::as_str),
|
||||
) {
|
||||
(Some(created_at), Some(id)) => Some((created_at, id.to_string())),
|
||||
(None, None) => None,
|
||||
_ => {
|
||||
return Ok(failure(
|
||||
"cursor requires both cursor_created_at and cursor_id",
|
||||
));
|
||||
}
|
||||
};
|
||||
let limit = args.get("limit").and_then(Value::as_i64).unwrap_or(20);
|
||||
match self.coordinator.list_runs(context, cursor, limit).await {
|
||||
Ok(runs) => {
|
||||
let payload: Vec<Value> =
|
||||
runs.iter().map(|run| run_projection(run, false)).collect();
|
||||
Ok(success(json!({ "runs": payload })))
|
||||
}
|
||||
Err(error) => Ok(failure(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_get_result(
|
||||
&self,
|
||||
context: &ToolExecutionContext,
|
||||
args: &Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
let run_id = self.run_id(args)?;
|
||||
match self.coordinator.get_result(context, run_id).await {
|
||||
Ok(Some(run)) => Ok(success(json!({
|
||||
"run_id": run.id,
|
||||
"status": run.status.as_str(),
|
||||
"result": run.result,
|
||||
"error": run.error,
|
||||
"tool_calls": run.tool_calls_count,
|
||||
"iterations": run.iterations,
|
||||
"finished_at": run.finished_at
|
||||
}))),
|
||||
Ok(None) => Ok(failure(format!(
|
||||
"run {run_id} is not terminal or does not exist"
|
||||
))),
|
||||
Err(error) => Ok(failure(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_cancel(
|
||||
&self,
|
||||
context: &ToolExecutionContext,
|
||||
args: &Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
let run_id = self.run_id(args)?;
|
||||
match self
|
||||
.coordinator
|
||||
.cancel_run(context, run_id, "cancelled via agent_task")
|
||||
.await
|
||||
{
|
||||
Ok(true) => Ok(success(json!({ "run_id": run_id, "status": "cancelled" }))),
|
||||
Ok(false) => Ok(failure(format!(
|
||||
"cannot cancel run {run_id}; it is terminal or does not exist"
|
||||
))),
|
||||
Err(error) => Ok(failure(error.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_projection(run: &AgentRunRecord, include_result_preview: bool) -> Value {
|
||||
let mut value = json!({
|
||||
"run_id": run.id,
|
||||
"group_id": run.group_id,
|
||||
"agent_id": run.agent_id,
|
||||
"status": run.status.as_str(),
|
||||
"mode": run.mode.as_str(),
|
||||
"depth": run.depth,
|
||||
"parent_run_id": run.parent_run_id,
|
||||
"provider_profile": run.provider_profile,
|
||||
"model_id": run.model_id,
|
||||
"tool_calls": run.tool_calls_count,
|
||||
"iterations": run.iterations,
|
||||
"created_at": run.created_at,
|
||||
"started_at": run.started_at,
|
||||
"finished_at": run.finished_at,
|
||||
"error": run.error,
|
||||
});
|
||||
if include_result_preview {
|
||||
value["result_preview"] = json!(run.result.as_deref().map(preview));
|
||||
value["result_truncated"] = json!(
|
||||
run.result
|
||||
.as_deref()
|
||||
.is_some_and(|result| result.chars().count() > RESULT_PREVIEW_CHARS)
|
||||
);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn preview(value: &str) -> String {
|
||||
if value.chars().count() <= RESULT_PREVIEW_CHARS {
|
||||
value.to_string()
|
||||
} else {
|
||||
let cut = value.floor_char_boundary(RESULT_PREVIEW_CHARS);
|
||||
format!("{}...", &value[..cut])
|
||||
}
|
||||
}
|
||||
|
||||
fn success(value: Value) -> ToolResult {
|
||||
ToolResult {
|
||||
success: true,
|
||||
output: value.to_string(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(error: impl Into<String>) -> ToolResult {
|
||||
ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error.into()),
|
||||
}
|
||||
}
|
||||
@ -51,6 +51,10 @@ impl BrowserTool {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for BrowserTool {
|
||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
||||
crate::tools::DelegationPolicy::Delegatable
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"browser"
|
||||
}
|
||||
|
||||
@ -18,6 +18,10 @@ impl Default for CalculatorTool {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CalculatorTool {
|
||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
||||
crate::tools::DelegationPolicy::Delegatable
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"calculator"
|
||||
}
|
||||
|
||||
@ -362,6 +362,8 @@ mod tests {
|
||||
turn_id: None,
|
||||
iteration: None,
|
||||
completion_status: crate::bus::CompletionStatus::Completed,
|
||||
client_visibility: crate::bus::ClientVisibility::Visible,
|
||||
turn_origin: crate::bus::TurnOrigin::User,
|
||||
media_refs: None,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
@ -428,6 +430,8 @@ mod tests {
|
||||
turn_id: None,
|
||||
iteration: None,
|
||||
completion_status: crate::bus::CompletionStatus::Completed,
|
||||
client_visibility: crate::bus::ClientVisibility::Visible,
|
||||
turn_origin: crate::bus::TurnOrigin::User,
|
||||
media_refs: None,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
@ -488,6 +492,8 @@ mod tests {
|
||||
turn_id: None,
|
||||
iteration: None,
|
||||
completion_status: crate::bus::CompletionStatus::Completed,
|
||||
client_visibility: crate::bus::ClientVisibility::Visible,
|
||||
turn_origin: crate::bus::TurnOrigin::User,
|
||||
media_refs: None,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
|
||||
@ -51,6 +51,10 @@ impl Default for ContentSearchTool {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ContentSearchTool {
|
||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
||||
crate::tools::DelegationPolicy::Delegatable
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"content_search"
|
||||
}
|
||||
|
||||
@ -1,18 +1,371 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::agent::{ExecutionMode, SubAgentConfig, SubAgentManager, TaskStatus};
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use crate::agent::{AgentCoordinator, ExecutionMode, SubAgentConfig, SubAgentManager, TaskStatus};
|
||||
use crate::tools::traits::{Tool, ToolExecutionContext, ToolOutput, ToolResult};
|
||||
|
||||
pub struct DelegateTool {
|
||||
sub_agent_manager: Arc<SubAgentManager>,
|
||||
coordinator: Option<Arc<AgentCoordinator>>,
|
||||
}
|
||||
|
||||
/// Per-run schema view for a child Agent. Authorization still happens in the
|
||||
/// manager from ToolExecutionContext; this wrapper keeps the model-visible
|
||||
/// target enum aligned with that Agent's configured outgoing edges.
|
||||
pub(crate) struct ScopedDelegateTool {
|
||||
inner: Arc<dyn Tool>,
|
||||
targets: Vec<String>,
|
||||
}
|
||||
|
||||
impl ScopedDelegateTool {
|
||||
pub(crate) fn new(inner: Arc<dyn Tool>, targets: Vec<String>) -> Self {
|
||||
Self { inner, targets }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ScopedDelegateTool {
|
||||
fn name(&self) -> &str {
|
||||
self.inner.name()
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
self.inner.description()
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
let mut schema = self.inner.parameters_schema();
|
||||
schema["properties"]["target"]["enum"] = json!(self.targets);
|
||||
schema["properties"]["tasks"]["items"]["properties"]["target"]["enum"] =
|
||||
json!(self.targets);
|
||||
schema
|
||||
}
|
||||
|
||||
fn read_only(&self) -> bool {
|
||||
self.inner.read_only()
|
||||
}
|
||||
|
||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
||||
crate::tools::DelegationPolicy::RuntimeInjected
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
self.inner.execute(args).await
|
||||
}
|
||||
|
||||
async fn execute_with_context(
|
||||
&self,
|
||||
context: &ToolExecutionContext,
|
||||
args: Value,
|
||||
) -> anyhow::Result<ToolOutput> {
|
||||
self.inner.execute_with_context(context, args).await
|
||||
}
|
||||
}
|
||||
|
||||
impl DelegateTool {
|
||||
pub fn new(sub_agent_manager: Arc<SubAgentManager>) -> Self {
|
||||
Self { sub_agent_manager }
|
||||
Self {
|
||||
sub_agent_manager,
|
||||
coordinator: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_coordinator(mut self, coordinator: Arc<AgentCoordinator>) -> Self {
|
||||
self.coordinator = Some(coordinator);
|
||||
self
|
||||
}
|
||||
|
||||
fn task_schema(&self) -> Value {
|
||||
let targets: Vec<_> = self
|
||||
.sub_agent_manager
|
||||
.catalog()
|
||||
.root_targets()
|
||||
.into_iter()
|
||||
.map(|definition| definition.id.clone())
|
||||
.collect();
|
||||
let mut target = json!({
|
||||
"type": "string",
|
||||
"description": "目标 Agent ID。省略时仅使用兼容 general Agent"
|
||||
});
|
||||
if !targets.is_empty() {
|
||||
target["enum"] = json!(targets);
|
||||
}
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": target,
|
||||
"task": { "type": "string", "description": "明确、独立、可验收的子任务" },
|
||||
"context": { "type": "string", "description": "完成任务所需的显式上下文;不会继承主会话历史" },
|
||||
"plan_item_id": { "type": "string", "description": "可选的当前计划子项 ID" }
|
||||
},
|
||||
"required": ["task"]
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_config(&self, value: &Value, mode: ExecutionMode) -> anyhow::Result<SubAgentConfig> {
|
||||
let prompt = value
|
||||
.get("task")
|
||||
.or_else(|| value.get("prompt"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required parameter: task"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if prompt.is_empty() {
|
||||
anyhow::bail!("task must not be empty");
|
||||
}
|
||||
let allowed_tools = value
|
||||
.get("allowed_tools")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
});
|
||||
Ok(SubAgentConfig {
|
||||
target: value
|
||||
.get("target")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
prompt,
|
||||
context: value
|
||||
.get("context")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
mode,
|
||||
allowed_tools,
|
||||
max_iterations: value
|
||||
.get("max_iterations")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|v| v as usize),
|
||||
timeout_secs: value.get("timeout_secs").and_then(Value::as_u64),
|
||||
plan_item_id: value
|
||||
.get("plan_item_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
session_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_run(
|
||||
&self,
|
||||
args: &Value,
|
||||
context: &ToolExecutionContext,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
let requested_mode = args
|
||||
.get("mode")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("foreground");
|
||||
if matches!(requested_mode, "inline" | "parallel") {
|
||||
tracing::warn!(
|
||||
mode = requested_mode,
|
||||
"deprecated delegate mode used; migrate to foreground with an optional tasks array"
|
||||
);
|
||||
}
|
||||
let (mode, legacy_parallel) = match requested_mode {
|
||||
"foreground" | "inline" => (ExecutionMode::Foreground, false),
|
||||
"background" => (ExecutionMode::Background, false),
|
||||
"parallel" => (ExecutionMode::Foreground, true),
|
||||
other => {
|
||||
return Ok(failure(format!(
|
||||
"unknown mode '{other}'; supported modes are foreground and background"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let task_values: Vec<&Value> = match args.get("tasks").and_then(Value::as_array) {
|
||||
Some(tasks) if !tasks.is_empty() => tasks.iter().collect(),
|
||||
Some(_) => return Ok(failure("tasks must not be empty")),
|
||||
None if legacy_parallel => {
|
||||
return Ok(failure("legacy parallel mode requires a tasks array"));
|
||||
}
|
||||
None => vec![args],
|
||||
};
|
||||
let mut configs = Vec::with_capacity(task_values.len());
|
||||
for task in task_values {
|
||||
let mut config = self.parse_config(task, mode.clone())?;
|
||||
if config.target.is_none() {
|
||||
config.target = args
|
||||
.get("target")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
}
|
||||
if config.context.is_none() {
|
||||
config.context = args
|
||||
.get("context")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
}
|
||||
if config.allowed_tools.is_none() {
|
||||
config.allowed_tools =
|
||||
args.get("allowed_tools")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
});
|
||||
}
|
||||
config.max_iterations = config.max_iterations.or_else(|| {
|
||||
args.get("max_iterations")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|v| v as usize)
|
||||
});
|
||||
config.timeout_secs = config
|
||||
.timeout_secs
|
||||
.or_else(|| args.get("timeout_secs").and_then(Value::as_u64));
|
||||
config.session_id = context
|
||||
.agent
|
||||
.as_ref()
|
||||
.map(|agent| agent.root_session_id.clone())
|
||||
.or_else(|| context.session_id.clone());
|
||||
configs.push(config);
|
||||
}
|
||||
|
||||
match mode {
|
||||
ExecutionMode::Foreground => {
|
||||
let all_named = configs.iter().all(|config| config.target.is_some());
|
||||
let results = if all_named && let Some(coordinator) = self.coordinator.as_ref() {
|
||||
coordinator
|
||||
.delegate_foreground(context, configs)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!(error.to_string()))?
|
||||
} else {
|
||||
if configs.iter().any(|config| config.target.is_some()) {
|
||||
return Ok(failure(
|
||||
"mixed named and legacy general batches are not supported",
|
||||
));
|
||||
}
|
||||
self.sub_agent_manager
|
||||
.run_foreground_batch(configs, context)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!(error.to_string()))?
|
||||
};
|
||||
let payload: Vec<_> = results
|
||||
.into_iter()
|
||||
.map(|result| {
|
||||
let (status, error) = status_projection(&result.status);
|
||||
json!({
|
||||
"run_id": result.task_id,
|
||||
"status": status,
|
||||
"result": result.content,
|
||||
"result_truncated": result.content_truncated,
|
||||
"error": error,
|
||||
"tool_calls": result.tool_calls_count,
|
||||
"iterations": result.iterations,
|
||||
"duration_ms": result.duration_ms
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let all_completed = payload.iter().all(|value| value["status"] == "completed");
|
||||
Ok(ToolResult {
|
||||
success: all_completed,
|
||||
output: serde_json::to_string(&json!({
|
||||
"status": if all_completed { "completed" } else { "partial" },
|
||||
"results": payload
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
ExecutionMode::Background => {
|
||||
if configs.len() != 1 {
|
||||
return Ok(failure(
|
||||
"background batches require durable group admission and are not available yet",
|
||||
));
|
||||
}
|
||||
if context.agent.is_some() {
|
||||
return Ok(failure(
|
||||
"child Agents cannot create background runs in the current implementation",
|
||||
));
|
||||
}
|
||||
let mut config = configs.into_iter().next().expect("checked non-empty");
|
||||
if config.target.is_some() {
|
||||
let Some(coordinator) = self.coordinator.as_ref() else {
|
||||
return Ok(failure(
|
||||
"named background Agents require agent_orchestration to be enabled",
|
||||
));
|
||||
};
|
||||
config.session_id = context
|
||||
.agent
|
||||
.as_ref()
|
||||
.map(|agent| agent.root_session_id.clone())
|
||||
.or_else(|| context.session_id.clone());
|
||||
return match coordinator.delegate_background(context, config).await {
|
||||
Ok(run_id) => Ok(success(json!({
|
||||
"status": "accepted",
|
||||
"runs": vec![json!({ "run_id": run_id, "status": "queued" })]
|
||||
}))),
|
||||
Err(error) => Ok(failure(error.to_string())),
|
||||
};
|
||||
}
|
||||
let routing = crate::agent::sub_agent::get_delegate_context().map_err(|_| {
|
||||
anyhow::anyhow!("background delegate requires an active root Agent worker")
|
||||
})?;
|
||||
let task_id = self
|
||||
.sub_agent_manager
|
||||
.run_background(config, routing)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
|
||||
Ok(success(json!({
|
||||
"status": "accepted",
|
||||
"runs": vec![json!({ "run_id": task_id, "status": "queued" })]
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_check_task(&self, args: &Value) -> anyhow::Result<ToolResult> {
|
||||
let task_id = required_task_id(args)?;
|
||||
let Some(task) = self.sub_agent_manager.check_task(task_id).await else {
|
||||
return Ok(failure(format!("task not found: {task_id}")));
|
||||
};
|
||||
Ok(success(json!({
|
||||
"task_id": task.id,
|
||||
"status": task.status,
|
||||
"task": task.prompt,
|
||||
"result": task.result,
|
||||
"error": task.error,
|
||||
"started_at": task.started_at,
|
||||
"finished_at": task.finished_at
|
||||
})))
|
||||
}
|
||||
|
||||
async fn handle_cancel_task(&self, args: &Value) -> anyhow::Result<ToolResult> {
|
||||
let task_id = required_task_id(args)?;
|
||||
match self.sub_agent_manager.cancel_task(task_id).await {
|
||||
Ok(true) => Ok(success(
|
||||
json!({ "task_id": task_id, "status": "cancelled" }),
|
||||
)),
|
||||
Ok(false) => Ok(failure(format!(
|
||||
"cannot cancel task {task_id}; it is terminal or does not exist"
|
||||
))),
|
||||
Err(error) => Ok(failure(format!("cancel failed: {error}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list_tasks(
|
||||
&self,
|
||||
context: &ToolExecutionContext,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
let session_id = context
|
||||
.agent
|
||||
.as_ref()
|
||||
.map(|agent| agent.root_session_id.as_str())
|
||||
.or(context.session_id.as_deref())
|
||||
.ok_or_else(|| anyhow::anyhow!("delegate context is not session-bound"))?;
|
||||
let tasks = self.sub_agent_manager.list_tasks(session_id).await;
|
||||
Ok(success(
|
||||
json!({ "tasks": tasks.into_iter().map(|task| json!({
|
||||
"task_id": task.id,
|
||||
"status": task.status,
|
||||
"task": task.prompt,
|
||||
"created_at": task.created_at
|
||||
})).collect::<Vec<_>>() }),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,73 +376,47 @@ impl Tool for DelegateTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"子任务委托工具。创建子 Agent 处理独立任务,支持三种模式:\
|
||||
inline (阻塞返回结果)、background (异步执行,完成后通知)、\
|
||||
parallel (多个子 Agent 并发执行,聚合结果)。\
|
||||
也可用于查询、取消和列出后台任务。"
|
||||
"Delegate one or more independent tasks to configured Agents. foreground waits for all results; background returns accepted run IDs. Multiple tasks execute concurrently."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["run", "check_task", "cancel_task", "list_tasks"],
|
||||
"description": "操作类型: run 创建子Agent执行任务, check_task 查询后台任务, cancel_task 取消后台任务, list_tasks 列出后台任务"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "子任务描述(可内含额外约束,如:跳过 .tmp 文件)。action=run 时必填"
|
||||
"description": "Compatibility task-management actions remain available during migration; omit for run"
|
||||
},
|
||||
"target": self.task_schema()["properties"]["target"].clone(),
|
||||
"task": { "type": "string", "description": "Single delegated task" },
|
||||
"context": { "type": "string", "description": "Explicit context for the child Agent" },
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["inline", "background", "parallel"],
|
||||
"description": "执行模式: inline=阻塞返回结果, background=异步执行+通知, parallel=多子Agent并发。默认 inline"
|
||||
},
|
||||
"allowed_tools": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "允许子Agent使用的工具列表。不填使用默认只读集: file_read,file_search,content_search,web_fetch,http_request,calculator"
|
||||
},
|
||||
"max_iterations": {
|
||||
"type": "integer",
|
||||
"description": "最大迭代次数,默认 99"
|
||||
},
|
||||
"timeout_secs": {
|
||||
"type": "integer",
|
||||
"description": "超时秒数,默认 3600(1小时)"
|
||||
"enum": ["foreground", "background"],
|
||||
"description": "foreground waits; background returns after acceptance"
|
||||
},
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"description": "并行模式下的多个子任务(仅 mode=parallel 时使用)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": { "type": "string", "description": "子任务描述" },
|
||||
"allowed_tools": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "该子任务的工具列表"
|
||||
},
|
||||
"plan_item_id": {
|
||||
"type": "string",
|
||||
"description": "可选,绑定当前计划中的子项 ID(如 T2)"
|
||||
}
|
||||
},
|
||||
"required": ["prompt"]
|
||||
}
|
||||
"minItems": 1,
|
||||
"items": self.task_schema(),
|
||||
"description": "Independent tasks; execution is concurrent and results preserve request order"
|
||||
},
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "后台任务ID(action=check_task/cancel_task 时必填)"
|
||||
"plan_item_id": { "type": "string" },
|
||||
"task_id": { "type": "string", "description": "Legacy task-management action target" },
|
||||
"allowed_tools": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Deprecated; only narrows the legacy general Agent and never expands named Agent permissions"
|
||||
},
|
||||
"plan_item_id": {
|
||||
"type": "string",
|
||||
"description": "inline/background 模式可选,绑定当前计划中的子项 ID"
|
||||
}
|
||||
"max_iterations": { "type": "integer", "minimum": 1 },
|
||||
"timeout_secs": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["action"]
|
||||
"anyOf": [
|
||||
{ "required": ["task"] },
|
||||
{ "required": ["tasks"] },
|
||||
{ "required": ["action"] }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
@ -97,315 +424,116 @@ impl Tool for DelegateTool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let action = args["action"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required parameter: action"))?;
|
||||
|
||||
match action {
|
||||
"run" => self.handle_run(&args).await,
|
||||
"check_task" => self.handle_check_task(&args).await,
|
||||
"cancel_task" => self.handle_cancel_task(&args).await,
|
||||
"list_tasks" => self.handle_list_tasks(&args).await,
|
||||
_ => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"Unknown action: {}. Supported: run, check_task, cancel_task, list_tasks",
|
||||
action
|
||||
)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DelegateTool {
|
||||
fn parse_config_from_args(&self, args: &serde_json::Value) -> anyhow::Result<SubAgentConfig> {
|
||||
let prompt = args["prompt"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required parameter: prompt"))?
|
||||
.to_string();
|
||||
|
||||
let allowed_tools: Option<Vec<String>> = args["allowed_tools"].as_array().map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
});
|
||||
|
||||
let max_iterations = args["max_iterations"].as_u64().map(|v| v as usize);
|
||||
let timeout_secs = args["timeout_secs"].as_u64();
|
||||
|
||||
Ok(SubAgentConfig {
|
||||
prompt,
|
||||
mode: ExecutionMode::Inline,
|
||||
allowed_tools,
|
||||
max_iterations,
|
||||
timeout_secs,
|
||||
plan_item_id: args["plan_item_id"].as_str().map(str::to_string),
|
||||
session_id: None,
|
||||
})
|
||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
||||
crate::tools::DelegationPolicy::RuntimeInjected
|
||||
}
|
||||
|
||||
async fn handle_run(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let mode_str = args["mode"].as_str().unwrap_or("inline");
|
||||
let mode = match mode_str {
|
||||
"inline" => ExecutionMode::Inline,
|
||||
"background" => ExecutionMode::Background,
|
||||
"parallel" => ExecutionMode::Parallel,
|
||||
_ => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"unknown mode: {}. Supported: inline, background, parallel",
|
||||
mode_str
|
||||
)),
|
||||
});
|
||||
}
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
self.execute_with_context(&ToolExecutionContext::default(), args)
|
||||
.await
|
||||
.map(|output| output.result)
|
||||
}
|
||||
|
||||
async fn execute_with_context(
|
||||
&self,
|
||||
context: &ToolExecutionContext,
|
||||
args: Value,
|
||||
) -> anyhow::Result<ToolOutput> {
|
||||
let action = args.get("action").and_then(Value::as_str).unwrap_or("run");
|
||||
let result = match action {
|
||||
"run" => self.handle_run(&args, context).await?,
|
||||
"check_task" => self.handle_check_task(&args).await?,
|
||||
"cancel_task" => self.handle_cancel_task(&args).await?,
|
||||
"list_tasks" => self.handle_list_tasks(context).await?,
|
||||
other => failure(format!("unknown delegate action '{other}'")),
|
||||
};
|
||||
|
||||
match mode {
|
||||
ExecutionMode::Inline => {
|
||||
let mut config = self.parse_config_from_args(args)?;
|
||||
if config.plan_item_id.is_some() {
|
||||
config.session_id = Some(
|
||||
crate::agent::sub_agent::get_delegate_context()
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.session_id,
|
||||
);
|
||||
}
|
||||
let result = self
|
||||
.sub_agent_manager
|
||||
.run_inline(config)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
match result.status {
|
||||
TaskStatus::Completed => Ok(ToolResult {
|
||||
success: true,
|
||||
output: result.content,
|
||||
error: None,
|
||||
}),
|
||||
TaskStatus::Failed(err) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: result.content,
|
||||
error: Some(err),
|
||||
}),
|
||||
TaskStatus::TimedOut => Ok(ToolResult {
|
||||
success: false,
|
||||
output: result.content,
|
||||
error: Some("sub-agent timed out".into()),
|
||||
}),
|
||||
TaskStatus::Cancelled => Ok(ToolResult {
|
||||
success: false,
|
||||
output: result.content,
|
||||
error: Some("sub-agent cancelled".into()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
ExecutionMode::Background => {
|
||||
let mut config = self.parse_config_from_args(args)?;
|
||||
let ctx = crate::agent::sub_agent::get_delegate_context().map_err(|_| {
|
||||
anyhow::anyhow!("delegate context not available: not in an agent worker")
|
||||
})?;
|
||||
config.session_id = Some(ctx.session_id.clone());
|
||||
|
||||
let task_id = self
|
||||
.sub_agent_manager
|
||||
.run_background(config, ctx)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("后台任务已启动。\ntask_id: {}", task_id),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
ExecutionMode::Parallel => {
|
||||
let tasks = args["tasks"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("parallel mode requires 'tasks' array"))?;
|
||||
|
||||
let ctx = crate::agent::sub_agent::get_delegate_context().ok();
|
||||
let mut configs = Vec::new();
|
||||
for task in tasks {
|
||||
let prompt = task["prompt"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("each parallel task requires 'prompt'"))?
|
||||
.to_string();
|
||||
let allowed_tools: Option<Vec<String>> =
|
||||
task["allowed_tools"].as_array().map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
});
|
||||
|
||||
configs.push(SubAgentConfig {
|
||||
prompt,
|
||||
mode: ExecutionMode::Inline,
|
||||
allowed_tools,
|
||||
max_iterations: args["max_iterations"].as_u64().map(|v| v as usize),
|
||||
timeout_secs: args["timeout_secs"].as_u64(),
|
||||
plan_item_id: task["plan_item_id"].as_str().map(str::to_string),
|
||||
session_id: ctx.as_ref().map(|ctx| ctx.session_id.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
let has_args_allowed = args["allowed_tools"].as_array().is_some();
|
||||
for c in &mut configs {
|
||||
if c.allowed_tools.is_none() && has_args_allowed {
|
||||
c.allowed_tools = args["allowed_tools"].as_array().map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let results = self
|
||||
.sub_agent_manager
|
||||
.run_parallel(configs)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
let mut output = String::new();
|
||||
for (i, r) in results.iter().enumerate() {
|
||||
let status_icon = match r.status {
|
||||
TaskStatus::Completed => "✅",
|
||||
TaskStatus::Failed(_) => "❌",
|
||||
TaskStatus::TimedOut => "⏱️ 超时",
|
||||
TaskStatus::Cancelled => "🚫 已取消",
|
||||
};
|
||||
output.push_str(&format!("[task_{}] {}\n", i + 1, status_icon));
|
||||
if !r.content.is_empty() {
|
||||
output.push_str(&r.content);
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
if let TaskStatus::Failed(ref err) = r.status {
|
||||
output.push_str(&format!("错误: {}\n\n", err));
|
||||
}
|
||||
}
|
||||
|
||||
let all_success = results
|
||||
.iter()
|
||||
.all(|r| matches!(r.status, TaskStatus::Completed));
|
||||
Ok(ToolResult {
|
||||
success: all_success,
|
||||
output: output.trim().to_string(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_check_task(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let task_id = args["task_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required parameter: task_id"))?;
|
||||
|
||||
match self.sub_agent_manager.check_task(task_id).await {
|
||||
Some(task) => {
|
||||
let status_icon = match task.status.as_str() {
|
||||
"completed" => "✅ 已完成",
|
||||
"failed" => "❌ 失败",
|
||||
"cancelled" => "🚫 已取消",
|
||||
"running" => "🔄 运行中",
|
||||
"pending" => "⏳ 等待中",
|
||||
_ => task.status.as_str(),
|
||||
};
|
||||
let mut output = format!(
|
||||
"任务 ID: {}\n状态: {}\n任务: {}",
|
||||
task.id, status_icon, task.prompt
|
||||
);
|
||||
if let Some(ref result) = task.result {
|
||||
output.push_str(&format!("\n\n结果:\n{}", result));
|
||||
}
|
||||
if let Some(ref error) = task.error {
|
||||
output.push_str(&format!("\n错误: {}", error));
|
||||
}
|
||||
if let Some(started) = task.started_at
|
||||
&& let Some(finished) = task.finished_at
|
||||
{
|
||||
let duration = (finished - started) as f64 / 1000.0;
|
||||
output.push_str(&format!("\n耗时: {:.1}s", duration));
|
||||
}
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
None => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("task not found: {}", task_id)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_cancel_task(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let task_id = args["task_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required parameter: task_id"))?;
|
||||
|
||||
match self.sub_agent_manager.cancel_task(task_id).await {
|
||||
Ok(true) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("后台任务 {} 已取消", task_id),
|
||||
error: None,
|
||||
}),
|
||||
Ok(false) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("无法取消任务 {}(可能已完成或不存在)", task_id)),
|
||||
}),
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("取消失败: {}", e)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list_tasks(&self, _args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let ctx = crate::agent::sub_agent::get_delegate_context()
|
||||
.map_err(|_| anyhow::anyhow!("delegate context not available"))?;
|
||||
let tasks = self.sub_agent_manager.list_tasks(&ctx.session_id).await;
|
||||
|
||||
if tasks.is_empty() {
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output: "没有后台任务".to_string(),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut output = String::from("后台任务列表:\n\n");
|
||||
for task in &tasks {
|
||||
let status_icon = match task.status.as_str() {
|
||||
"completed" => "✅",
|
||||
"failed" => "❌",
|
||||
"cancelled" => "🚫",
|
||||
"running" => "🔄",
|
||||
"pending" => "⏳",
|
||||
_ => "❓",
|
||||
};
|
||||
output.push_str(&format!(
|
||||
"{} {} - {} - {} (created: {})\n",
|
||||
status_icon,
|
||||
&task.id[..std::cmp::min(8, task.id.len())],
|
||||
task.prompt.chars().take(60).collect::<String>(),
|
||||
task.status,
|
||||
task.created_at,
|
||||
));
|
||||
}
|
||||
output.push_str(&format!("\n共 {} 个任务", tasks.len()));
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
error: None,
|
||||
})
|
||||
Ok(result.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn required_task_id(args: &Value) -> anyhow::Result<&str> {
|
||||
args.get("task_id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required parameter: task_id"))
|
||||
}
|
||||
|
||||
fn status_projection(status: &TaskStatus) -> (&'static str, Option<&str>) {
|
||||
match status {
|
||||
TaskStatus::Completed => ("completed", None),
|
||||
TaskStatus::Failed(error) => ("failed", Some(error.as_str())),
|
||||
TaskStatus::Cancelled => ("cancelled", None),
|
||||
TaskStatus::TimedOut => ("timed_out", None),
|
||||
}
|
||||
}
|
||||
|
||||
fn success(value: Value) -> ToolResult {
|
||||
ToolResult {
|
||||
success: true,
|
||||
output: value.to_string(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(error: impl Into<String>) -> ToolResult {
|
||||
ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn manager() -> Arc<SubAgentManager> {
|
||||
let (notify_tx, _) = tokio::sync::mpsc::unbounded_channel();
|
||||
Arc::new(SubAgentManager::new(
|
||||
crate::config::LLMProviderConfig {
|
||||
provider_type: "openai".to_string(),
|
||||
name: "test".to_string(),
|
||||
base_url: "https://example.invalid/v1".to_string(),
|
||||
api_key: "test".to_string(),
|
||||
extra_headers: HashMap::new(),
|
||||
model_id: "test-model".to_string(),
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
model_extra: HashMap::new(),
|
||||
max_tool_iterations: 1,
|
||||
token_limit: 4096,
|
||||
workspace_dir: std::env::temp_dir(),
|
||||
input_types: vec!["text".to_string()],
|
||||
price_input_per_million: None,
|
||||
price_output_per_million: None,
|
||||
},
|
||||
Arc::new(crate::tools::ToolRegistry::new()),
|
||||
None,
|
||||
notify_tx,
|
||||
1,
|
||||
None,
|
||||
crate::task_supervisor::TaskSupervisor::new(),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_exposes_only_canonical_lifecycle_modes() {
|
||||
let schema = DelegateTool::new(manager()).parameters_schema();
|
||||
assert_eq!(
|
||||
schema["properties"]["mode"]["enum"],
|
||||
json!(["foreground", "background"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_schema_replaces_root_targets_for_child() {
|
||||
let inner: Arc<dyn Tool> = Arc::new(DelegateTool::new(manager()));
|
||||
let scoped = ScopedDelegateTool::new(inner, vec!["reviewer".to_string()]);
|
||||
let schema = scoped.parameters_schema();
|
||||
assert_eq!(schema["properties"]["target"]["enum"], json!(["reviewer"]));
|
||||
assert_eq!(
|
||||
schema["properties"]["tasks"]["items"]["properties"]["target"]["enum"],
|
||||
json!(["reviewer"])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
354
src/tools/emit_signal.rs
Normal file
354
src/tools/emit_signal.rs
Normal file
@ -0,0 +1,354 @@
|
||||
//! Contract-bound `emit_signal` tool for background Agents.
|
||||
//!
|
||||
//! The tool only exists inside a run whose definition declares a `signal`
|
||||
//! contract. Every limit (total count, rate, burst, severity allowlist,
|
||||
//! payload size/depth, dedupe cooldown) is enforced here and in the
|
||||
//! Coordinator; the model only supplies `key`, `severity`, `summary`,
|
||||
//! `details` and an optional `dedupe_key`. Signals are durable inbox events
|
||||
//! delivered to the run's root session lane (queue or steer per contract).
|
||||
//! The tool returns only after the event is persisted.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::agent::coordinator::AgentCoordinator;
|
||||
use crate::agent::definition::SignalContract;
|
||||
use crate::agent::run::{AgentExecutionContext, EmittedSignal};
|
||||
use crate::storage::agent_inbox::{AgentEventDelivery, AgentEventType, NewInboxEvent};
|
||||
use crate::tools::{DelegationPolicy, Tool, ToolExecutionContext, ToolOutput, ToolResult};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignalInput {
|
||||
pub key: String,
|
||||
pub severity: String,
|
||||
pub summary: String,
|
||||
pub details: Option<Value>,
|
||||
pub dedupe_key: Option<String>,
|
||||
/// Durable event key computed by the tool (dedupe window included).
|
||||
pub event_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SignalAcceptedStatus {
|
||||
Accepted,
|
||||
Deduplicated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignalAccepted {
|
||||
pub signal_id: String,
|
||||
pub status: SignalAcceptedStatus,
|
||||
pub delivery: AgentEventDelivery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct SignalRateState {
|
||||
total: u32,
|
||||
last_at_ms: i64,
|
||||
burst_times: VecDeque<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EmitSignalArgs {
|
||||
pub key: String,
|
||||
pub severity: String,
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
pub details: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub dedupe_key: Option<String>,
|
||||
}
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct EmitSignalTool {
|
||||
coordinator: Arc<AgentCoordinator>,
|
||||
contract: Arc<SignalContract>,
|
||||
rate: Mutex<SignalRateState>,
|
||||
}
|
||||
|
||||
impl EmitSignalTool {
|
||||
pub fn new(coordinator: Arc<AgentCoordinator>, contract: Arc<SignalContract>) -> Self {
|
||||
Self {
|
||||
coordinator,
|
||||
contract,
|
||||
rate: Mutex::new(SignalRateState::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_KEY_CHARS: usize = 128;
|
||||
const MAX_DEDUPE_KEY_CHARS: usize = 128;
|
||||
const MAX_SUMMARY_CHARS: usize = 1024;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for EmitSignalTool {
|
||||
fn name(&self) -> &str {
|
||||
"emit_signal"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"向主 Agent 发送一条结构化内部信号,用于重要中间状态或监控告警;普通进度请留在工具调用记录里,不要滥用信号。"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": { "type": "string", "description": "信号键,用于区分不同信号" },
|
||||
"severity": { "type": "string", "description": "严重级别" },
|
||||
"summary": { "type": "string", "description": "简短摘要" },
|
||||
"details": { "type": "object", "description": "结构化详情" },
|
||||
"dedupe_key": { "type": "string", "description": "去重键(可选)" }
|
||||
},
|
||||
"required": ["key", "severity", "summary"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("emit_signal requires a run-bound context".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn delegation_policy(&self) -> DelegationPolicy {
|
||||
DelegationPolicy::RuntimeInjected
|
||||
}
|
||||
|
||||
async fn execute_with_context(
|
||||
&self,
|
||||
context: &ToolExecutionContext,
|
||||
args: Value,
|
||||
) -> anyhow::Result<ToolOutput> {
|
||||
let args: EmitSignalArgs = match serde_json::from_value(args) {
|
||||
Ok(args) => args,
|
||||
Err(error) => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error.to_string()),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
};
|
||||
let contract = self.contract.clone();
|
||||
let coordinator = self.coordinator.clone();
|
||||
let rate = &self.rate;
|
||||
let context = context.clone();
|
||||
let result: Result<ToolOutput, String> = async move {
|
||||
let Some(agent) = context.agent.as_deref() else {
|
||||
return Err("emit_signal requires a run-bound Agent context".to_string());
|
||||
};
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
// Structural validation against the contract, independent of the
|
||||
// model's cooperation.
|
||||
if args.key.trim().is_empty() || args.key.len() > MAX_KEY_CHARS {
|
||||
return Err(format!("key must contain 1..={MAX_KEY_CHARS} characters"));
|
||||
}
|
||||
if let Some(dedupe) = args.dedupe_key.as_deref()
|
||||
&& (dedupe.trim().is_empty() || dedupe.len() > MAX_DEDUPE_KEY_CHARS)
|
||||
{
|
||||
return Err(format!(
|
||||
"dedupe_key must contain 1..={MAX_DEDUPE_KEY_CHARS} characters"
|
||||
));
|
||||
}
|
||||
if !contract
|
||||
.severity_allowlist
|
||||
.iter()
|
||||
.any(|allowed| allowed == &args.severity)
|
||||
{
|
||||
return Err(format!(
|
||||
"severity '{0}' is not allowed; allowlist: {1}",
|
||||
args.severity,
|
||||
contract.severity_allowlist.join(", ")
|
||||
));
|
||||
}
|
||||
if args.summary.trim().is_empty() || args.summary.chars().count() > MAX_SUMMARY_CHARS {
|
||||
return Err(format!(
|
||||
"summary must contain 1..={MAX_SUMMARY_CHARS} characters"
|
||||
));
|
||||
}
|
||||
if let Some(details) = args.details.as_ref() {
|
||||
let bytes = details.to_string().len();
|
||||
if bytes > contract.max_details_bytes {
|
||||
return Err(format!(
|
||||
"details exceed the {} byte contract limit",
|
||||
contract.max_details_bytes
|
||||
));
|
||||
}
|
||||
if json_depth(details) > contract.max_payload_depth {
|
||||
return Err(format!(
|
||||
"details exceed the {} level depth limit",
|
||||
contract.max_payload_depth
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Per-run rate limits. Rate state is in-memory and per-run: the
|
||||
// tool instance is created for exactly one run's registry.
|
||||
{
|
||||
let mut state = rate.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if state.total >= contract.max_total {
|
||||
return Err(format!(
|
||||
"signal limit reached: {} signals for this run",
|
||||
contract.max_total
|
||||
));
|
||||
}
|
||||
if state.total > 0 && now_ms - state.last_at_ms < contract.min_interval_ms as i64 {
|
||||
return Err(format!(
|
||||
"signal rate limited: wait at least {}ms between signals",
|
||||
contract.min_interval_ms
|
||||
));
|
||||
}
|
||||
let window_start = now_ms - contract.burst_window_ms as i64;
|
||||
while state
|
||||
.burst_times
|
||||
.front()
|
||||
.is_some_and(|time| *time < window_start)
|
||||
{
|
||||
state.burst_times.pop_front();
|
||||
}
|
||||
if state.burst_times.len() as u32 >= contract.max_burst {
|
||||
return Err(format!(
|
||||
"signal burst limited: at most {} signals per {}ms",
|
||||
contract.max_burst, contract.burst_window_ms
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let event_key = match args.dedupe_key.as_deref() {
|
||||
Some(key) => {
|
||||
let window = now_ms / contract.dedupe_cooldown_ms as i64;
|
||||
format!("signal:{key}:{window}")
|
||||
}
|
||||
None => format!("signal:{}", uuid::Uuid::new_v4()),
|
||||
};
|
||||
let accepted = coordinator
|
||||
.emit_signal(
|
||||
agent,
|
||||
SignalInput {
|
||||
key: args.key.clone(),
|
||||
severity: args.severity.clone(),
|
||||
summary: args.summary.clone(),
|
||||
details: args.details.clone(),
|
||||
dedupe_key: args.dedupe_key.clone(),
|
||||
event_key,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if accepted.status == SignalAcceptedStatus::Accepted {
|
||||
if let Ok(mut state) = rate.lock() {
|
||||
state.total = state.total.saturating_add(1);
|
||||
state.last_at_ms = now_ms;
|
||||
state.burst_times.push_back(now_ms);
|
||||
}
|
||||
if let Ok(mut signals) = agent.emitted_signals.lock() {
|
||||
signals.push(EmittedSignal {
|
||||
signal_id: accepted.signal_id.clone(),
|
||||
severity: args.severity.clone(),
|
||||
summary: args.summary.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let output = serde_json::json!({
|
||||
"signal_id": accepted.signal_id,
|
||||
"status": if accepted.status == SignalAcceptedStatus::Accepted {
|
||||
"accepted"
|
||||
} else {
|
||||
"deduplicated"
|
||||
},
|
||||
"delivery": accepted.delivery.as_str(),
|
||||
})
|
||||
.to_string();
|
||||
Ok(ToolOutput {
|
||||
result: ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
error: None,
|
||||
},
|
||||
artifacts: Vec::new(),
|
||||
})
|
||||
}
|
||||
.await;
|
||||
result.map_err(|error| anyhow::anyhow!(error))
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum JSON nesting depth, counting objects/arrays.
|
||||
fn json_depth(value: &Value) -> usize {
|
||||
match value {
|
||||
Value::Object(map) => 1 + map.values().map(json_depth).max().unwrap_or(0),
|
||||
Value::Array(items) => 1 + items.iter().map(json_depth).max().unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for tests and non-coordinator embedders: build the durable inbox
|
||||
/// event descriptor for a signal without enforcing runtime state.
|
||||
pub fn build_signal_event(
|
||||
context: &AgentExecutionContext,
|
||||
input: &SignalInput,
|
||||
event_id: String,
|
||||
delivery: AgentEventDelivery,
|
||||
) -> NewInboxEvent {
|
||||
NewInboxEvent {
|
||||
id: event_id,
|
||||
root_session_id: context.root_session_id.clone(),
|
||||
scope_kind: "run".to_string(),
|
||||
scope_id: context.run_id.clone(),
|
||||
run_id: Some(context.run_id.clone()),
|
||||
group_id: context.group_id.clone(),
|
||||
event_type: AgentEventType::Signal,
|
||||
event_key: input.event_key.clone(),
|
||||
delivery,
|
||||
requires_continuation: true,
|
||||
severity: Some(input.severity.clone()),
|
||||
payload_json: serde_json::json!({
|
||||
"kind": "signal",
|
||||
"severity": input.severity,
|
||||
"summary": input.summary,
|
||||
"details": input.details,
|
||||
"dedupe_key": input.dedupe_key,
|
||||
"key": input.key,
|
||||
"run_id": context.run_id,
|
||||
"agent_id": context.current_agent_id,
|
||||
})
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn details_depth_is_measured() {
|
||||
assert_eq!(json_depth(&Value::Null), 0);
|
||||
assert_eq!(json_depth(&Value::String("x".into())), 0);
|
||||
assert_eq!(
|
||||
json_depth(&serde_json::json!({"a": {"b": [1, {"c": 2}]}})),
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_contract_limits_are_sane() {
|
||||
let contract = SignalContract::default();
|
||||
assert_eq!(contract.max_burst, 5);
|
||||
assert!(
|
||||
contract
|
||||
.severity_allowlist
|
||||
.contains(&"critical".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,10 @@ impl Default for FileReadTool {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FileReadTool {
|
||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
||||
crate::tools::DelegationPolicy::Delegatable
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"file_read"
|
||||
}
|
||||
|
||||
@ -51,6 +51,10 @@ impl Default for FileSearchTool {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FileSearchTool {
|
||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
||||
crate::tools::DelegationPolicy::Delegatable
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"file_search"
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@ -8,11 +9,22 @@ use crate::tools::traits::{Tool, ToolResult};
|
||||
|
||||
pub struct GetSkillTool {
|
||||
skills_loader: Arc<SkillsLoader>,
|
||||
allowed: Option<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl GetSkillTool {
|
||||
pub fn new(skills_loader: Arc<SkillsLoader>) -> Self {
|
||||
Self { skills_loader }
|
||||
Self {
|
||||
skills_loader,
|
||||
allowed: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scoped(skills_loader: Arc<SkillsLoader>, allowed: &[String]) -> Self {
|
||||
Self {
|
||||
skills_loader,
|
||||
allowed: Some(allowed.iter().cloned().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_skill(&self, skill: &Skill) -> String {
|
||||
@ -32,6 +44,10 @@ impl GetSkillTool {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GetSkillTool {
|
||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
||||
crate::tools::DelegationPolicy::RuntimeInjected
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"get_skill"
|
||||
}
|
||||
@ -85,6 +101,20 @@ impl GetSkillTool {
|
||||
}
|
||||
};
|
||||
|
||||
if self
|
||||
.allowed
|
||||
.as_ref()
|
||||
.is_some_and(|allowed| !allowed.contains(skill_name))
|
||||
{
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"Skill '{skill_name}' is not allowed for this Agent"
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
match self.skills_loader.get_skill(skill_name) {
|
||||
Some(skill) => {
|
||||
let formatted = self.format_skill(&skill);
|
||||
@ -118,7 +148,16 @@ impl GetSkillTool {
|
||||
}
|
||||
|
||||
fn list_skills_full(&self) -> anyhow::Result<ToolResult> {
|
||||
let skills = self.skills_loader.get_loaded_skills();
|
||||
let skills: Vec<_> = self
|
||||
.skills_loader
|
||||
.get_loaded_skills()
|
||||
.into_iter()
|
||||
.filter(|skill| {
|
||||
self.allowed
|
||||
.as_ref()
|
||||
.is_none_or(|allowed| allowed.contains(&skill.name))
|
||||
})
|
||||
.collect();
|
||||
if skills.is_empty() {
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod agent_task;
|
||||
pub mod bash;
|
||||
pub mod browser;
|
||||
pub mod calculator;
|
||||
@ -5,6 +6,7 @@ pub mod chat_manager;
|
||||
pub mod content_search;
|
||||
pub mod cron;
|
||||
pub mod delegate;
|
||||
pub mod emit_signal;
|
||||
mod expression;
|
||||
pub mod file_edit;
|
||||
pub mod file_read;
|
||||
@ -26,12 +28,14 @@ pub mod todo;
|
||||
pub mod traits;
|
||||
pub mod web_fetch;
|
||||
|
||||
pub use agent_task::AgentTaskTool;
|
||||
pub use bash::BashTool;
|
||||
pub use browser::{BrowserProfilesTool, BrowserTool};
|
||||
pub use calculator::CalculatorTool;
|
||||
pub use chat_manager::ChatManagerTool;
|
||||
pub use content_search::ContentSearchTool;
|
||||
pub use delegate::DelegateTool;
|
||||
pub use emit_signal::EmitSignalTool;
|
||||
pub use file_edit::FileEditTool;
|
||||
pub use file_read::FileReadTool;
|
||||
pub use file_search::FileSearchTool;
|
||||
@ -48,8 +52,9 @@ pub use send_message::SendMessageTool;
|
||||
pub use sleep::SleepTool;
|
||||
pub use todo::TodoTool;
|
||||
pub use traits::{
|
||||
OutboundDelivery, OutboundMessenger, ProcessedToolOutput, Tool, ToolArtifact,
|
||||
ToolArtifactAudience, ToolExecutionContext, ToolOutput, ToolOutputProcessor, ToolResult,
|
||||
DelegationPolicy, InputInterruptPolicy, OutboundDelivery, OutboundMessenger,
|
||||
ProcessedToolOutput, Tool, ToolArtifact, ToolArtifactAudience, ToolExecutionContext,
|
||||
ToolOutput, ToolOutputProcessor, ToolResult,
|
||||
};
|
||||
pub use web_fetch::WebFetchTool;
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::providers::{Tool, ToolFunction};
|
||||
|
||||
use super::traits::DelegationPolicy;
|
||||
use super::traits::Tool as ToolTrait;
|
||||
|
||||
pub struct ToolRegistry {
|
||||
@ -87,6 +88,36 @@ impl ToolRegistry {
|
||||
Arc::new(filtered)
|
||||
}
|
||||
|
||||
/// Build an immutable execution view for a named Agent. Definition tools
|
||||
/// must be explicitly delegatable; runtime tools are passed separately and
|
||||
/// can only carry the RuntimeInjected policy.
|
||||
pub fn scoped_for_agent(
|
||||
&self,
|
||||
tool_names: &[String],
|
||||
runtime_tools: Vec<Arc<dyn ToolTrait>>,
|
||||
) -> Result<Arc<Self>, String> {
|
||||
let scoped = Self::new();
|
||||
for name in tool_names {
|
||||
let tool = self
|
||||
.get(name)
|
||||
.ok_or_else(|| format!("tool '{name}' is not registered"))?;
|
||||
if tool.delegation_policy() != DelegationPolicy::Delegatable {
|
||||
return Err(format!("tool '{name}' is not delegatable"));
|
||||
}
|
||||
scoped.register_raw(name.clone(), tool);
|
||||
}
|
||||
for tool in runtime_tools {
|
||||
if tool.delegation_policy() != DelegationPolicy::RuntimeInjected {
|
||||
return Err(format!(
|
||||
"runtime tool '{}' is missing RuntimeInjected policy",
|
||||
tool.name()
|
||||
));
|
||||
}
|
||||
scoped.register_raw(tool.name().to_string(), tool);
|
||||
}
|
||||
Ok(Arc::new(scoped))
|
||||
}
|
||||
|
||||
/// 生成工具列表描述,用于子 Agent 系统提示词
|
||||
pub fn describe_for_prompt(&self) -> String {
|
||||
let mut entries: Vec<String> = self
|
||||
|
||||
@ -133,6 +133,9 @@ target_chat_id 支持两种格式:<channel>:<chat_id>(发送到该聊天下
|
||||
from_user_id: None,
|
||||
system_name: None,
|
||||
task_id: None,
|
||||
from_run_id: None,
|
||||
from_agent_id: None,
|
||||
group_id: None,
|
||||
};
|
||||
|
||||
// 3. Parse files into MediaItems
|
||||
|
||||
@ -34,6 +34,14 @@ fn parse_seconds(args: &serde_json::Value) -> Result<u64, String> {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SleepTool {
|
||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
||||
crate::tools::DelegationPolicy::Delegatable
|
||||
}
|
||||
|
||||
fn input_interrupt_policy(&self) -> crate::tools::InputInterruptPolicy {
|
||||
crate::tools::InputInterruptPolicy::WakeOnly
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"sleep"
|
||||
}
|
||||
@ -58,6 +66,16 @@ impl Tool for SleepTool {
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
self.execute_with_context(&crate::tools::ToolExecutionContext::default(), args)
|
||||
.await
|
||||
.map(|output| output.result)
|
||||
}
|
||||
|
||||
async fn execute_with_context(
|
||||
&self,
|
||||
context: &crate::tools::ToolExecutionContext,
|
||||
args: serde_json::Value,
|
||||
) -> anyhow::Result<crate::tools::ToolOutput> {
|
||||
let seconds = match parse_seconds(&args) {
|
||||
Ok(seconds) => seconds,
|
||||
Err(error) => {
|
||||
@ -65,17 +83,25 @@ impl Tool for SleepTool {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error),
|
||||
});
|
||||
}
|
||||
.into());
|
||||
}
|
||||
};
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(seconds)).await;
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = context.cancellation.cancelled() => {
|
||||
anyhow::bail!("sleep cancelled");
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_secs(seconds)) => {}
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Slept for {seconds} second(s)."),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
@ -235,4 +261,32 @@ mod tests {
|
||||
} if id == "sleep-call"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancellation_token_ends_sleep_before_timer() {
|
||||
let context = crate::tools::ToolExecutionContext::default();
|
||||
let token = context.cancellation.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
SleepTool::new()
|
||||
.execute_with_context(&context, json!({"seconds": MAX_SLEEP_SECONDS}))
|
||||
.await
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!handle.is_finished());
|
||||
token.cancel();
|
||||
tokio::task::yield_now().await;
|
||||
let error = handle.await.unwrap().unwrap_err();
|
||||
assert!(error.to_string().contains("cancelled"));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn pre_cancelled_context_never_enters_sleep() {
|
||||
let context = crate::tools::ToolExecutionContext::default();
|
||||
context.cancellation.cancel();
|
||||
let error = SleepTool::new()
|
||||
.execute_with_context(&context, json!({"seconds": 60}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("cancelled"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,10 +3,25 @@ use async_trait::async_trait;
|
||||
|
||||
/// Session identity supplied by the runtime for tools that own external state.
|
||||
/// Ordinary stateless tools can ignore it through the default trait method.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolExecutionContext {
|
||||
pub session_id: Option<String>,
|
||||
pub turn_id: Option<String>,
|
||||
pub agent: Option<std::sync::Arc<crate::agent::AgentExecutionContext>>,
|
||||
pub cancellation: tokio_util::sync::CancellationToken,
|
||||
pub execution_gate: Option<std::sync::Arc<crate::agent::gate::ExecutionGate>>,
|
||||
}
|
||||
|
||||
impl Default for ToolExecutionContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
session_id: None,
|
||||
turn_id: None,
|
||||
agent: None,
|
||||
cancellation: tokio_util::sync::CancellationToken::new(),
|
||||
execution_gate: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolExecutionContext {
|
||||
@ -14,6 +29,9 @@ impl ToolExecutionContext {
|
||||
Self {
|
||||
session_id: Some(session_id.into()),
|
||||
turn_id: None,
|
||||
agent: None,
|
||||
cancellation: tokio_util::sync::CancellationToken::new(),
|
||||
execution_gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +39,41 @@ impl ToolExecutionContext {
|
||||
self.turn_id = Some(turn_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_agent(
|
||||
mut self,
|
||||
agent: std::sync::Arc<crate::agent::AgentExecutionContext>,
|
||||
) -> Self {
|
||||
self.agent = Some(agent);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cancellation(mut self, cancellation: tokio_util::sync::CancellationToken) -> Self {
|
||||
self.cancellation = cancellation;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_execution_gate(
|
||||
mut self,
|
||||
gate: std::sync::Arc<crate::agent::gate::ExecutionGate>,
|
||||
) -> Self {
|
||||
self.execution_gate = Some(gate);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DelegationPolicy {
|
||||
RootOnly,
|
||||
Delegatable,
|
||||
RuntimeInjected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InputInterruptPolicy {
|
||||
Never,
|
||||
WakeOnly,
|
||||
CancelSafe,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@ -154,6 +207,17 @@ pub trait Tool: Send + Sync + 'static {
|
||||
fn parameters_schema(&self) -> serde_json::Value;
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
|
||||
|
||||
/// Whether a named Agent definition may receive this tool. New tools fail
|
||||
/// closed until their delegated behavior has been reviewed explicitly.
|
||||
fn delegation_policy(&self) -> DelegationPolicy {
|
||||
DelegationPolicy::RootOnly
|
||||
}
|
||||
|
||||
/// Whether new Turn input may interrupt an in-flight invocation.
|
||||
fn input_interrupt_policy(&self) -> InputInterruptPolicy {
|
||||
InputInterruptPolicy::Never
|
||||
}
|
||||
|
||||
/// Execute the tool through the unified output envelope. Most tools return
|
||||
/// only text and use this default conversion.
|
||||
async fn execute_output(&self, args: serde_json::Value) -> anyhow::Result<ToolOutput> {
|
||||
|
||||
@ -331,6 +331,10 @@ fn is_private_ip(ip: &std::net::IpAddr) -> bool {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WebFetchTool {
|
||||
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
||||
crate::tools::DelegationPolicy::Delegatable
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"web_fetch"
|
||||
}
|
||||
|
||||
@ -455,6 +455,32 @@ impl WorkManager {
|
||||
Ok(Some(updated))
|
||||
}
|
||||
|
||||
/// Re-read the active plan after an external transaction (agent run
|
||||
/// admission or terminal commit) already mutated plan items, then refresh
|
||||
/// the cache and broadcast. This path never writes to the database.
|
||||
pub async fn refresh_after_external_commit(
|
||||
&self,
|
||||
session_id: &str,
|
||||
reason: &str,
|
||||
item_ids: Vec<String>,
|
||||
) -> Result<Option<TaskPlan>, StorageError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, session_id, objective, status, version, created_at, updated_at, closed_at \
|
||||
FROM task_plans WHERE session_id = ? AND status = 'active' LIMIT 1",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(self.storage.pool())
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
self.active_cache.insert(session_id.to_string(), None);
|
||||
return Ok(None);
|
||||
};
|
||||
let plan = self.plan_from_row(row).await?;
|
||||
self.cache_active_plan(&plan);
|
||||
self.emit(reason, item_ids, Some(plan.clone()));
|
||||
Ok(Some(plan))
|
||||
}
|
||||
|
||||
pub async fn close_plan(
|
||||
&self,
|
||||
session_id: &str,
|
||||
|
||||
@ -142,6 +142,7 @@ fn test_bounded_session_history_protocol() {
|
||||
tool_name: None,
|
||||
tool_calls: None,
|
||||
attachments: Vec::new(),
|
||||
turn_origin: picobot::bus::TurnOrigin::User,
|
||||
}],
|
||||
};
|
||||
let decoded: WsOutbound =
|
||||
@ -196,6 +197,7 @@ fn test_session_history_preserves_tool_call_metadata() {
|
||||
arguments: serde_json::json!({ "path": "README.md" }),
|
||||
}]),
|
||||
attachments: Vec::new(),
|
||||
turn_origin: picobot::bus::TurnOrigin::User,
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"version": "1.5.1",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picobot-webui",
|
||||
"version": "1.5.1",
|
||||
"version": "1.7.0",
|
||||
"dependencies": {
|
||||
"bits-ui": "^2.0.0",
|
||||
"dompurify": "^3.4.12",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"private": true,
|
||||
"version": "1.5.1",
|
||||
"version": "1.7.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
let sessions = $state([]);
|
||||
let currentId = $state(null);
|
||||
let messages = $state([]);
|
||||
let agentEvents = $state([]);
|
||||
let search = $state("");
|
||||
let draft = $state("");
|
||||
let commands = $state([]);
|
||||
@ -178,6 +179,16 @@
|
||||
break;
|
||||
}
|
||||
case "session_stats": break;
|
||||
case "agent_event_updated": {
|
||||
// Durable Agent events are projections, never part of chat history.
|
||||
// Signal/completion cards render separately so the main Agent's
|
||||
// final wording stays the only history authority.
|
||||
if (frame.session_id !== currentId) break;
|
||||
agentEvents = [frame.event, ...agentEvents.filter((event) => event.id !== frame.event.id)].slice(0, 20);
|
||||
break;
|
||||
}
|
||||
case "agent_run_updated":
|
||||
break;
|
||||
case "system_notification":
|
||||
if (!frame.session_id || frame.session_id === currentId) appendMessage("assistant", frame.content);
|
||||
break;
|
||||
@ -221,6 +232,15 @@
|
||||
return "○";
|
||||
}
|
||||
|
||||
function signalSummary(event) {
|
||||
try {
|
||||
const payload = JSON.parse(event.payload_json);
|
||||
return payload.summary || payload.status || event.payload_json.slice(0, 200);
|
||||
} catch {
|
||||
return event.payload_json.slice(0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
function toolResult(callId) {
|
||||
return messages.find((message) => message.role === "tool" && message.tool_call_id === callId) || null;
|
||||
}
|
||||
@ -444,6 +464,28 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages" bind:this={messageBox}>
|
||||
{#if agentEvents.length}
|
||||
<div class="agent-event-cards" aria-label="后台 Agent 事件">
|
||||
{#each agentEvents as event (event.id)}
|
||||
<article class:warning={event.severity === "warning"} class:critical={event.severity === "critical"} class="agent-event-card">
|
||||
<span class="agent-event-icon"><Icon name="bot" size={14} /></span>
|
||||
<div class="agent-event-body">
|
||||
<div class="agent-event-title">
|
||||
{event.event_type === "signal" ? `后台信号 · ${event.severity || "info"}` : `后台任务${event.status === "consumed" ? "已处理" : "完成"}`}
|
||||
<span class="agent-event-delivery">{event.delivery === "steer" ? "steer" : "queue"}</span>
|
||||
</div>
|
||||
{#if event.event_type === "signal"}
|
||||
{#if event.payload_json}
|
||||
<p>{signalSummary(event)}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="agent-event-status">{event.status}{event.last_error ? ` · ${event.last_error}` : ""}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if messages.length === 0 && !activeTurn}
|
||||
<div class="empty"><div class="empty-logo"><Icon name="bot" size={26} /></div><h2>今天想做些什么?</h2><p>消息与 CLI 客户端使用同一套会话、记忆和工具能力。</p></div>
|
||||
{/if}
|
||||
@ -452,6 +494,9 @@
|
||||
<div class:user={message.role === "user"} class:assistant={message.role !== "user"} class:has-tools={message.tool_calls?.length} class="message">
|
||||
<div class="avatar">{#if message.role === "user"}你{:else}<Icon name="bot" size={15} />{/if}</div>
|
||||
<div class="message-content">
|
||||
{#if message.turn_origin === "agent_continuation" && message.role !== "user"}
|
||||
<span class="continuation-tag">后台结果处理</span>
|
||||
{/if}
|
||||
{#if message.reasoning_content}
|
||||
<details class="reasoning-block historical">
|
||||
<summary>思考过程</summary>
|
||||
|
||||
@ -70,6 +70,32 @@
|
||||
return "var(--danger)";
|
||||
}
|
||||
|
||||
function groupTasks(all) {
|
||||
const groups = [];
|
||||
const byGroup = new Map();
|
||||
const roots = [];
|
||||
for (const task of all) {
|
||||
if (task.source === "legacy_background_task") {
|
||||
groups.push({ key: `legacy-${task.id}`, title: task.prompt.slice(0, 100), status: task.status, runs: [task] });
|
||||
} else if (task.group_id) {
|
||||
if (!byGroup.has(task.group_id)) {
|
||||
byGroup.set(task.group_id, { key: `group-${task.group_id}`, title: `任务组 ${task.group_id.slice(0, 8)}`, status: task.status, runs: [] });
|
||||
groups.push(byGroup.get(task.group_id));
|
||||
}
|
||||
byGroup.get(task.group_id).runs.push(task);
|
||||
} else {
|
||||
roots.push({ key: `run-${task.id}`, title: task.prompt.slice(0, 100), status: task.status, runs: [task] });
|
||||
}
|
||||
}
|
||||
for (const group of byGroup.values()) {
|
||||
group.runs.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
|
||||
group.status = group.runs.find((task) => task.status === "running")?.status || group.runs[0]?.status || "pending";
|
||||
}
|
||||
groups.push(...roots);
|
||||
groups.sort((a, b) => (b.runs[0]?.created_at || 0) - (a.runs[0]?.created_at || 0));
|
||||
return groups;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
const timer = setInterval(() => { tick += 1; }, 30000);
|
||||
@ -91,27 +117,40 @@
|
||||
{#if loading}<div class="loading">加载中…</div>
|
||||
{:else if error}<div class="empty-card error-text">{error}</div>
|
||||
{:else if tab === "background"}
|
||||
{#each tasks as task (task.id)}
|
||||
<article class="card">
|
||||
<div class="card-row">
|
||||
<div>
|
||||
<h3>
|
||||
{#if task.status === "running"}<span class="pulse"></span>{/if}
|
||||
{task.prompt.slice(0, 100)}
|
||||
</h3>
|
||||
<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>
|
||||
{#each groupTasks(tasks) as group (group.key)}
|
||||
{#if group.runs.length === 0}
|
||||
<div class="empty-card">暂无后台任务</div>
|
||||
{:else}
|
||||
<article class="card">
|
||||
<div class="card-row">
|
||||
<div>
|
||||
<h3>{group.title}</h3>
|
||||
<div class="meta">
|
||||
<span>{group.runs[0].session_id}</span>
|
||||
<span>{formatTime(group.runs[0].created_at)}</span>
|
||||
{#if group.runs[0].status === "running"}<span class="elapsed">{elapsed(group.runs[0].created_at)}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={group.status} />
|
||||
</div>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
{#if task.result}<div class="details"><p>{task.result}</p></div>{/if}
|
||||
{#if task.error}<p class="error-text">{task.error}</p>{/if}
|
||||
</article>
|
||||
{:else}<div class="empty-card">暂无后台任务</div>{/each}
|
||||
<div class="run-tree">
|
||||
{#each group.runs as task}
|
||||
<div class="run-node" style="--depth:{Math.min(task.depth ?? 1, 6)}">
|
||||
<div class="run-row">
|
||||
<span class="pulse" class:visible={task.status === "running"}></span>
|
||||
<span class="agent-tag">{task.agent_id || "general"}</span>
|
||||
<span class="run-prompt">{task.prompt.slice(0, 80)}</span>
|
||||
<StatusBadge status={task.status} />
|
||||
<span class="meta">{task.tool_calls_count} 次工具调用 · {task.iterations} 轮</span>
|
||||
</div>
|
||||
{#if task.result}<div class="details"><p>{task.result.slice(0, 300)}</p></div>{/if}
|
||||
{#if task.error}<p class="error-text">{task.error.slice(0, 200)}</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
{#each jobs as job (job.id)}
|
||||
<article class="card">
|
||||
@ -147,4 +186,11 @@
|
||||
.cron { font-size: 12px; color: var(--text-soft); background: var(--code-bg); padding: 1px 6px; border-radius: 4px; border: 1px solid var(--line); font-variant-numeric: tabular-nums; }
|
||||
.status-dots { display: flex; gap: 4px; margin-top: 6px; align-items: center; }
|
||||
.elapsed { color: var(--info); font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.run-tree { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.run-node { margin-left: calc(var(--depth) * 18px); border-left: 1px solid var(--line); padding-left: 10px; }
|
||||
.run-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.agent-tag { font-size: 11px; color: var(--accent); background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; padding: 0 6px; font-family: var(--font-mono); }
|
||||
.run-prompt { flex: 1 1 200px; min-width: 120px; }
|
||||
.pulse { display: none; }
|
||||
.pulse.visible { display: inline-block; }
|
||||
</style>
|
||||
|
||||
@ -328,6 +328,19 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.attachment-card strong, .attachment-meta strong { font-size: 12px; }
|
||||
.attachment-card small, .attachment-meta small { margin-top: 2px; color: var(--muted); font-size: 10px; }
|
||||
.attachment-card a, .attachment-meta > a { width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 4px; color: var(--accent); text-decoration: none; }
|
||||
.continuation-tag { justify-self: start; font-size: 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px; background: var(--code-bg); }
|
||||
.agent-event-cards { display: grid; gap: 7px; margin: 14px 0; }
|
||||
.agent-event-card { display: flex; gap: 9px; align-items: flex-start; padding: 8px 11px; border: 1px solid var(--line); border-left-width: 3px; border-left-color: var(--info); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow-2); }
|
||||
.agent-event-card.warning { border-left-color: var(--accent); }
|
||||
.agent-event-card.critical { border-left-color: var(--danger); }
|
||||
.agent-event-icon { width: 24px; height: 24px; flex: 0 0 24px; display: grid; place-items: center; border-radius: 5px; color: var(--info); background: var(--code-bg); border: 1px solid var(--line); }
|
||||
.agent-event-card.warning .agent-event-icon { color: var(--accent); }
|
||||
.agent-event-card.critical .agent-event-icon { color: var(--danger); }
|
||||
.agent-event-body { min-width: 0; }
|
||||
.agent-event-title { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 600; color: var(--text); }
|
||||
.agent-event-delivery { font-family: var(--font-mono); font-size: 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; }
|
||||
.agent-event-body p { margin: 2px 0 0; font-size: 12px; color: var(--text-soft); overflow-wrap: anywhere; }
|
||||
.agent-event-status { font-family: var(--font-mono); }
|
||||
.image-attachment { width: min(100%, 680px); overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow-2); }
|
||||
.image-preview-link { max-height: 520px; display: grid; overflow: hidden; place-items: center; background: var(--panel-2); }
|
||||
.image-preview { display: block; max-width: 100%; max-height: 520px; object-fit: contain; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user