feat: enable bash delegation, drop legacy anonymous general agent, ship built-in general-purpose Agent
- bash is now Delegatable so named Agents may run shell commands - remove the legacy anonymous 'general' path entirely: SubAgentManager run_background/run_foreground_batch/run_inline/run_foreground, filter_tools/get_skills_prompt, TaskNotification direct delivery, DelegateContext task-local, background_permits/admission, and the delegate tool's check_task/cancel_task/list_tasks actions (agent_task replaces them); /stop and todo migrate off the task-local bridge - stop writing the legacy background_tasks table (read-only transition); /api/tasks union still renders historical records - ship a built-in general-purpose Agent definition (resources/agents) released to ~/.picobot/agents on first run like built-in skills, with bash/read-only/search/web/calculator/sleep tools; root_delegates now defaults to it so orchestration works out of the box - update AGENTS/README/architecture/config/db-schema docs accordingly
This commit is contained in:
parent
554d4b15ac
commit
78d6e29672
@ -93,7 +93,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
|||||||
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context
|
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context
|
||||||
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
|
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
|
||||||
- **AgentLoop** is stateless across turns; it receives prepared history, drains same-Turn steering only at safe model boundaries, calls LLM providers, executes tools, and returns one result
|
- **AgentLoop** is stateless across turns; it receives prepared history, drains same-Turn steering only at safe model boundaries, calls LLM providers, executes tools, and returns one result
|
||||||
- **AgentCatalog** is immutable per runtime generation; when orchestration is enabled, candidate preparation strictly validates trusted Markdown definitions, Provider profiles, delegated tools, Skill allowlists, and delegation edges before activation. Named Agents currently support foreground execution; durable named background delivery is not available until the run/inbox phase lands
|
- **AgentCatalog** is immutable per runtime generation; when orchestration is enabled, candidate preparation strictly validates trusted Markdown definitions, Provider profiles, delegated tools, Skill allowlists, and delegation edges before activation. Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; background batches, nested background, and legacy general background (kept for one transition version) remain restricted
|
||||||
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
|
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
|
||||||
- **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts
|
- **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts
|
||||||
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
|
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
|
||||||
|
|||||||
@ -344,6 +344,8 @@ PicoBot 有两类记忆:
|
|||||||
| `memory_store` / `memory_recall` / `timeline_recall` / `memory_forget` | 长期记忆操作 |
|
| `memory_store` / `memory_recall` / `timeline_recall` / `memory_forget` | 长期记忆操作 |
|
||||||
| `reload_config` | 在用户明确要求时校验并重新加载 Gateway 配置 |
|
| `reload_config` | 在用户明确要求时校验并重新加载 Gateway 配置 |
|
||||||
| `delegate` | 向具名 Agent 委托单个或批量任务;`foreground` 等待结果,`background` 异步执行。批量 foreground 会并发运行并按请求顺序聚合 |
|
| `delegate` | 向具名 Agent 委托单个或批量任务;`foreground` 等待结果,`background` 异步执行。批量 foreground 会并发运行并按请求顺序聚合 |
|
||||||
|
| `agent_task` | 查询/列出/读取结果/取消已持久化的具名 Agent run(仅编排启用时注册) |
|
||||||
|
| `emit_signal` | 后台 run 向主 Agent 发送结构化内部信号(queue/steer 投递;仅带 signal 契约的 run 注册) |
|
||||||
| `todo` | 为复杂、多轮任务创建并更新当前 session 的持久化计划 |
|
| `todo` | 为复杂、多轮任务创建并更新当前 session 的持久化计划 |
|
||||||
| `send_message` | 向指定渠道或当前会话发送消息,可附带文件/截图;WebUI/TUI 当前 Turn 的附件并入最终回复 |
|
| `send_message` | 向指定渠道或当前会话发送消息,可附带文件/截图;WebUI/TUI 当前 Turn 的附件并入最终回复 |
|
||||||
| `chat_manager` | 查看渠道、会话和历史消息 |
|
| `chat_manager` | 查看渠道、会话和历史消息 |
|
||||||
@ -406,7 +408,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
|||||||
|
|
||||||
### 具名子 Agent(Phase 1)
|
### 具名子 Agent(Phase 1)
|
||||||
|
|
||||||
启用 `agent_orchestration.enabled` 后,PicoBot 会在 Gateway 候选运行代构造时从配置目录下的 `definitions_dir` 加载 `*.md`。相对路径按 `config.json` 所在目录解析,且 canonical path 不得逃逸该目录;角色文件、Provider profile、工具、Skill 和委托边任一无效都会拒绝启动或热重载。第一阶段只开放具名 `foreground` 执行;具名 `background` 和 background 批量接纳会明确拒绝,直到 durable run/inbox 阶段完成。未启用时,旧 general 单任务 background 仍作为兼容路径存在。
|
启用 `agent_orchestration.enabled` 后,PicoBot 会在 Gateway 候选运行代构造时从配置目录下的 `definitions_dir` 加载 `*.md`。相对路径按 `config.json` 所在目录解析,且 canonical path 不得逃逸该目录;角色文件、Provider profile、工具、Skill 和委托边任一无效都会拒绝启动或热重载。支持具名 `foreground`(单/批量)与 Root 发起的具名 `background` 单任务:background 结果经 durable inbox 由主 Agent 的 continuation Turn 汇总,可配合 `emit_signal`(queue/steer)推送内部信号。background 批量与子 Agent 发起的 background 尚未开放;未启用时,旧 general 单任务 background 仍作为兼容路径存在(带迁移提示)。
|
||||||
|
|
||||||
```md
|
```md
|
||||||
---
|
---
|
||||||
|
|||||||
46
build.rs
46
build.rs
@ -13,6 +13,29 @@ fn main() {
|
|||||||
let skills_out_dir = Path::new(&out_dir).join("skills");
|
let skills_out_dir = Path::new(&out_dir).join("skills");
|
||||||
fs::create_dir_all(&skills_out_dir).unwrap();
|
fs::create_dir_all(&skills_out_dir).unwrap();
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed=resources/agents");
|
||||||
|
let agents_dir = Path::new("resources/agents");
|
||||||
|
let agents_out_dir = Path::new(&out_dir).join("agents");
|
||||||
|
fs::create_dir_all(&agents_out_dir).unwrap();
|
||||||
|
let mut agents = Vec::new();
|
||||||
|
if let Ok(entries) = fs::read_dir(agents_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let agent_name = path
|
||||||
|
.file_stem()
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
fs::copy(&path, agents_out_dir.join(format!("{agent_name}.md"))).unwrap();
|
||||||
|
agents.push(agent_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agents.sort();
|
||||||
|
|
||||||
let mut skills = Vec::new();
|
let mut skills = Vec::new();
|
||||||
|
|
||||||
if let Ok(entries) = fs::read_dir(skills_dir) {
|
if let Ok(entries) = fs::read_dir(skills_dir) {
|
||||||
@ -58,6 +81,29 @@ pub static EMBEDDED_SKILLS: &[EmbeddedSkill] = &[
|
|||||||
let generated_path = Path::new(&out_dir).join("embedded_skills.rs");
|
let generated_path = Path::new(&out_dir).join("embedded_skills.rs");
|
||||||
let mut f = fs::File::create(&generated_path).unwrap();
|
let mut f = fs::File::create(&generated_path).unwrap();
|
||||||
f.write_all(code.as_bytes()).unwrap();
|
f.write_all(code.as_bytes()).unwrap();
|
||||||
|
|
||||||
|
let mut agent_code = String::from(
|
||||||
|
r#"pub struct EmbeddedAgent {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub content: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static EMBEDDED_AGENTS: &[EmbeddedAgent] = &[
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
for name in &agents {
|
||||||
|
let file_path = agents_out_dir
|
||||||
|
.join(format!("{name}.md"))
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
agent_code.push_str(&format!(
|
||||||
|
" EmbeddedAgent {{ name: \"{name}\", content: include_str!(\"{file_path}\") }},\n",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
agent_code.push_str("];\n");
|
||||||
|
let agent_path = Path::new(&out_dir).join("embedded_agents.rs");
|
||||||
|
let mut f = fs::File::create(&agent_path).unwrap();
|
||||||
|
f.write_all(agent_code.as_bytes()).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_webui(out_dir: &Path) {
|
fn build_webui(out_dir: &Path) {
|
||||||
|
|||||||
34
resources/agents/general-purpose.md
Normal file
34
resources/agents/general-purpose.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
id: general-purpose
|
||||||
|
description: 通用目的子代理,处理主 Agent 委托的独立子任务
|
||||||
|
llm_profile: default
|
||||||
|
tools:
|
||||||
|
- bash
|
||||||
|
- file_read
|
||||||
|
- file_search
|
||||||
|
- content_search
|
||||||
|
- web_fetch
|
||||||
|
- calculator
|
||||||
|
- sleep
|
||||||
|
---
|
||||||
|
|
||||||
|
# General Purpose Agent
|
||||||
|
|
||||||
|
You are the **general-purpose** sub-agent of PicoBot, a general-purpose assistant.
|
||||||
|
|
||||||
|
## Role
|
||||||
|
- Handle any independent subtask delegated by the main agent.
|
||||||
|
- Work autonomously and report back concrete results.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
- Follow the task description and context provided by the delegator.
|
||||||
|
- Use the tools you need; prefer read-only operations unless the task requires changes.
|
||||||
|
- Be accurate: do not fabricate tool results or guesses; report failures honestly.
|
||||||
|
- If information is missing, ask or state the gap rather than inventing it.
|
||||||
|
- You do not hold the main session's conversation history; rely on the context given in your task.
|
||||||
|
- Do not delegate further to other agents unless explicitly instructed.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
- Deliver the final result in a clear, structured, self-contained format.
|
||||||
|
- Keep it concise unless the task requires detail.
|
||||||
|
- If the task cannot be completed, explain why and what is blocking it.
|
||||||
@ -50,7 +50,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
|||||||
- 每个活动 Turn 独占一个 TurnSink;平台 message ID 和 reaction 清理状态只存在于 sink 内
|
- 每个活动 Turn 独占一个 TurnSink;平台 message ID 和 reaction 清理状态只存在于 sink 内
|
||||||
- Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext`
|
- Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext`
|
||||||
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry
|
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry
|
||||||
- 具名子 Agent 从运行代不可变 `AgentCatalog` 加载,Definition 固定 Provider profile、工具/Skill allowlist、委托边与限制;新工具默认 RootOnly。Phase 1 支持单个/批量 foreground(批量并发、按请求顺序返回)和显式父子授权;具名 background 在 durable run/inbox 完成前拒绝。禁用编排时旧 general background 仍通过 MessageBus 直接通知原会话
|
- 具名子 Agent 从运行代不可变 `AgentCatalog` 加载,Definition 固定 Provider profile、工具/Skill allowlist、委托边与限制;新工具默认 RootOnly。支持单个/批量 foreground(批量并发、按请求顺序返回)和显式父子授权;Root 对具名 Agent 的 background 单任务走 durable run/inbox + continuation 投递(结果不再直接通知 Channel)。禁用编排时旧 general background 仍通过 MessageBus 直接通知原会话
|
||||||
- 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
|
- 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
|
||||||
- WebUI 聊天复用 `/ws` 与 `cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
|
- WebUI 聊天复用 `/ws` 与 `cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
|
||||||
- WebUI 使用 Svelte 5 + Vite,Bits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
|
- WebUI 使用 Svelte 5 + Vite,Bits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
|
||||||
@ -258,14 +258,66 @@ Gateway 初始化时读取 `config.mcp.servers`:
|
|||||||
|
|
||||||
| 模式 | 行为 |
|
| 模式 | 行为 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `foreground` | 当前轮等待一个或多个子 Agent;批量任务并发执行并按请求顺序聚合 |
|
| `foreground` | 当前轮等待一个或多个子 Agent;批量任务并发执行并按请求顺序聚合,全部持久化到 `agent_runs` |
|
||||||
| `background` | 异步执行并返回 run ID;当前只有旧 general 兼容路径可用 |
|
| `background` | 异步执行并立即返回 run ID;仅限 Root 对具名 Agent 的单任务,结果经 durable inbox 由主 Agent 的 continuation Turn 汇总 |
|
||||||
|
|
||||||
启用 `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,当前明确拒绝。
|
### 具名 Agent Definition(身份设定)
|
||||||
|
|
||||||
未启用编排或省略 target 时使用旧 general 兼容路径。其工具也只能取旧默认集合与 Delegatable 策略的交集;旧后台任务写入 `background_tasks` 表,完成后通过原 channel/chat 直接通知,默认 24 小时后清理,不具备 durable inbox 语义。
|
启用 `agent_orchestration` 后,每个具名子 Agent 是一个 Markdown 文件:`<配置目录>/agents/<id>.md`(默认 `~/.picobot/agents/`)。frontmatter 只保存非秘密引用与限制(API key/base URL 仍在 `config.json`/`.env`):
|
||||||
|
|
||||||
后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册,受 `gateway.max_concurrent_background_tasks` 限制;Gateway 关停时先收到取消信号,再在总宽限期内清理。
|
```md
|
||||||
|
---
|
||||||
|
id: researcher
|
||||||
|
description: 搜索、阅读并整理技术资料
|
||||||
|
llm_profile: research-sonnet
|
||||||
|
|
||||||
|
tools:
|
||||||
|
- file_read
|
||||||
|
- file_search
|
||||||
|
- content_search
|
||||||
|
- web_fetch
|
||||||
|
|
||||||
|
delegates:
|
||||||
|
- reviewer
|
||||||
|
|
||||||
|
skills:
|
||||||
|
- technical-research
|
||||||
|
|
||||||
|
limits:
|
||||||
|
timeout_secs: 900
|
||||||
|
max_iterations: 24
|
||||||
|
max_children: 4
|
||||||
|
max_depth: 3
|
||||||
|
|
||||||
|
signal:
|
||||||
|
delivery: steer
|
||||||
|
severity_allowlist: [info, warning, critical]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Role
|
||||||
|
|
||||||
|
你是一名严谨的研究 Agent。只返回与任务有关的结论、证据和不确定性。
|
||||||
|
```
|
||||||
|
|
||||||
|
- `id`:`[a-z][a-z0-9_-]{0,63}`,文件名必须与 id 一致;`root`/`main`/`default`/`general` 为保留名。重复 ID、大小写折叠冲突、越界 symlink 或引用错误(未知 Provider profile、未注册/不可委托工具、未知 skill 或 delegate 目标)会拒绝整个候选运行代,绝不静默裁剪。
|
||||||
|
- `llm_profile`:引用 `config.json` 中 `agents` key,Definition 绑定 Provider 与模型,运行中不热切换。
|
||||||
|
- `tools`/`skills`:固定 allowlist。`skills` 声明要求工具集含 `get_skill`,且只注入该 allowlist。新工具默认 RootOnly;当前可委托工具为 `file_read`、`file_search`、`content_search`、`web_fetch`、`calculator`、普通 `browser` 动作和 `sleep`。
|
||||||
|
- `delegates`:出边白名单,运行时还校验 ancestry 重复、`max_tree_depth` 与树级 `max_runs_per_tree` 预算。
|
||||||
|
- `signal`:可选信号契约。带该块的 run 才获得 `emit_signal` 工具(fail-closed);`delivery: steer` 使信号在活动 Turn 的安全边界注入主 Agent,`queue` 走 continuation。
|
||||||
|
- 角色正文(`---` 之后)即 `role_prompt`,与 frontmatter 一起做 SHA-256 `definition_hash` 快照。
|
||||||
|
|
||||||
|
### 执行与投递
|
||||||
|
|
||||||
|
- 每次具名委托先持久化 run(含 execution_id、budget、Definition 快照),`allowed_tools` 只能收窄、不能扩权;子 Agent 输出视为不可信数据。
|
||||||
|
- foreground 父 run 等待子 run 时进入 `waiting_children` 且不占 provider/tool step permit;run quota 只约束 background 接纳,嵌套 foreground 并发上限为 1 时不死锁。
|
||||||
|
- background 接纳时预留 completion slot(容量条件更新),runner 持有 run quota permit 与 activity guard 直到 terminal commit;完成后 completion 事件落 `agent_inbox_events`,Session worker 按 `max_user_turn_burst_before_inbox`/`max_inbox_wait_secs` 公平调度,以 hidden trigger + 只读工具集的 continuation Turn 让主 Agent 汇总结果,不再直接发 Channel 通知。失败按 lease token 释放重试,超 `max_inbox_delivery_attempts` 进 dead-letter,重启经 activation recovery 收敛。
|
||||||
|
- `agent_task`(get/list/get_result/cancel)查询与控制 run;`agent_task.cancel` 会把该 run 未消费的普通信号标记 superseded。`/stop` 取消活动 run 但保留已存在的 pending 事件;archive/delete 取消 run 并将未消费事件 dead-letter。
|
||||||
|
- 后台 run 内可调用 `emit_signal`(key/severity/summary/details/dedupe_key),总数、速率、burst、severity allowlist、载荷大小/深度与冷却窗去重均由契约强制;steer 信号经两阶段 admission(claim → mailbox 预留 → admit(turn_id) → 激活)注入当前 Turn,`/stop` 时按 token 条件释放回 pending,绝不静默丢弃。
|
||||||
|
- 每个 run 的完成事件 payload 携带该 run 已发出的 signal IDs,主 Agent 可识别重复报告。
|
||||||
|
|
||||||
|
未启用编排或省略 target 时使用旧 general 兼容路径(结果带迁移提示)。其工具也只能取旧默认集合与 Delegatable 策略的交集;旧后台任务写入 `background_tasks` 表,完成后通过原 channel/chat 直接通知,默认 24 小时后清理,不具备 durable inbox 语义,等待一个版本观察后随旧适配器移除。
|
||||||
|
|
||||||
|
后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册;Gateway 关停时先收到取消信号,再在总宽限期内清理。
|
||||||
|
|
||||||
## Session Todo 计划
|
## Session Todo 计划
|
||||||
|
|
||||||
|
|||||||
@ -62,17 +62,17 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
| `definitions_dir` | agents | 第一层 `*.md` Definition 目录 |
|
| `definitions_dir` | agents | 第一层 `*.md` Definition 目录 |
|
||||||
| `root_delegates` | [] | Root 可委托的具名 Agent ID |
|
| `root_delegates` | [] | Root 可委托的具名 Agent ID |
|
||||||
| `max_tree_depth` | 4 | Root-relative 委托深度硬上限 |
|
| `max_tree_depth` | 4 | Root-relative 委托深度硬上限 |
|
||||||
| `max_runs_per_tree` | 16 | 单任务树 run 预算(Phase 2 持久 Coordinator 强制完整树计数) |
|
| `max_runs_per_tree` | 16 | 单任务树 run 预算(树级原子计数强制) |
|
||||||
| `max_concurrent_runs` / `max_concurrent_runs_per_session` | 6 / 4 | run admission 上限(Phase 2) |
|
| `max_concurrent_runs` / `max_concurrent_runs_per_session` | 6 / 4 | background run 接纳配额(global→session 顺序获取,runner 持有至 terminal commit;foreground 不占) |
|
||||||
| `max_concurrent_provider_steps` / `..._per_session` | 8 / 4 | Provider step 上限(Phase 2) |
|
| `max_concurrent_provider_steps` / `..._per_session` | 8 / 4 | Provider step 上限(global→session) |
|
||||||
| `max_concurrent_tool_steps` / `..._per_session` | 16 / 8 | 普通工具 step 上限(Phase 2) |
|
| `max_concurrent_tool_steps` / `..._per_session` | 16 / 8 | 普通工具 step 上限(global→session) |
|
||||||
| `max_pending_inbox_events_per_session` | 128 | durable inbox 容量(Phase 3) |
|
| `max_pending_inbox_events_per_session` | 128 | durable inbox 容量(条件更新,预留槽不可被信号挤占) |
|
||||||
| `inbox_event_ttl_hours` | 168 | inbox event TTL(Phase 3) |
|
| `inbox_event_ttl_hours` | 168 | inbox 事件 TTL(已预留配置;TTL 清理尚未实现) |
|
||||||
| `max_inbox_delivery_attempts` | 8 | inbox 最大投递次数(Phase 3) |
|
| `max_inbox_delivery_attempts` | 8 | inbox 最大投递次数,超限 dead-letter |
|
||||||
| `max_user_turn_burst_before_inbox` | 4 | 用户 Turn 公平调度阈值(Phase 3) |
|
| `max_user_turn_burst_before_inbox` | 4 | 用户 Turn 公平调度阈值 |
|
||||||
| `max_inbox_wait_secs` | 30 | inbox 最大等待阈值(Phase 3) |
|
| `max_inbox_wait_secs` | 30 | inbox 最大等待阈值 |
|
||||||
|
|
||||||
当前已实现具名 foreground Agent、不同 `llm_profile`、固定工具/Skill allowlist、批量并发和父子委托边校验。具名 background 会明确拒绝,直到 durable run/inbox 实现;未启用时旧 general background 兼容路径保持可用。
|
已实现:具名 foreground 与 background(Root 单任务)、不同 `llm_profile`、固定工具/Skill allowlist、批量并发、父子委托边校验、durable inbox continuation、`emit_signal`(queue/steer)、run quota 与 step gate。未开放:background 批量、子 Agent 发起的 background、`idempotency_key` 工具入口。未启用编排时旧 general background 兼容路径保持可用(带迁移提示,等待一个版本观察后移除)。
|
||||||
|
|
||||||
## gateway 字段
|
## gateway 字段
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
数据库为 SQLite,默认位于 workspace 下的 `picobot.db`。
|
数据库为 SQLite,默认位于 workspace 下的 `picobot.db`。
|
||||||
|
|
||||||
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout,连接池最多 8 个连接。当前 `PRAGMA user_version=4`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
|
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout,连接池最多 8 个连接。当前 `PRAGMA user_version=6`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
|
||||||
|
|
||||||
## sessions 表
|
## sessions 表
|
||||||
|
|
||||||
@ -23,6 +23,8 @@
|
|||||||
| `deleted_at` | INTEGER | 软删除时间戳 |
|
| `deleted_at` | INTEGER | 软删除时间戳 |
|
||||||
| `last_consolidated_at` | INTEGER | 上次记忆归并时间 |
|
| `last_consolidated_at` | INTEGER | 上次记忆归并时间 |
|
||||||
| `last_compressed_message_at` | INTEGER | 上次上下文压缩边界时间戳 |
|
| `last_compressed_message_at` | INTEGER | 上次上下文压缩边界时间戳 |
|
||||||
|
| `delivery_context` | TEXT | 渠道声明的可跨 Turn 复用投递上下文 JSON(如飞书 thread/root 身份);一次性 reply/reaction ID 永不写入 |
|
||||||
|
| `delivery_context_updated_at` | INTEGER | delivery_context 最后更新时间 |
|
||||||
|
|
||||||
`session_turn_usage` 以 `turn_id` 幂等保存已提交 Turn 的 Provider usage,包括累计输入、输出、缓存输入、请求数和最后一次请求的 prompt tokens。它与 Turn 消息批次在同一事务中提交,供 WebUI 状态栏和 `/info` 使用;升级前历史无法可靠回填,因此统计起点以首条 usage 记录为准。
|
`session_turn_usage` 以 `turn_id` 幂等保存已提交 Turn 的 Provider usage,包括累计输入、输出、缓存输入、请求数和最后一次请求的 prompt tokens。它与 Turn 消息批次在同一事务中提交,供 WebUI 状态栏和 `/info` 使用;升级前历史无法可靠回填,因此统计起点以首条 usage 记录为准。
|
||||||
|
|
||||||
@ -48,12 +50,14 @@
|
|||||||
| `turn_id` | TEXT | 产生该消息的活动 Turn ID |
|
| `turn_id` | TEXT | 产生该消息的活动 Turn ID |
|
||||||
| `iteration` | INTEGER | Agent 工具循环中的迭代序号 |
|
| `iteration` | INTEGER | Agent 工具循环中的迭代序号 |
|
||||||
| `completion_status` | TEXT | `completed` / `cancelled` / `interrupted`,旧数据默认 completed |
|
| `completion_status` | TEXT | `completed` / `cancelled` / `interrupted`,旧数据默认 completed |
|
||||||
|
| `client_visibility` | TEXT | `visible` / `hidden`,默认 visible;hidden 只供模型回放(continuation 内部触发),客户端历史/投影/投递一律过滤 |
|
||||||
|
| `turn_origin` | TEXT | `user` / `agent_continuation` / `scheduled`,默认 user;客户端据此渲染"后台结果处理"标签而不创建用户气泡 |
|
||||||
|
|
||||||
`(session_id, seq)` 有唯一索引,防止并发写入重复序号。删除 session 会通过外键级联删除 messages。
|
`(session_id, seq)` 有唯一索引,防止并发写入重复序号。删除 session 会通过外键级联删除 messages。索引 `(session_id, client_visibility, seq)` 支撑按可见性分层查询。
|
||||||
|
|
||||||
## background_tasks 表
|
## background_tasks 表(legacy 兼容,只读过渡)
|
||||||
|
|
||||||
delegate 后台子任务表。`session_id` 不使用数据库外键,因为 session 使用软删除,关联关系由应用层维护。
|
旧 general(无 target)delegate 后台子任务表,由 legacy 适配器写入、`/api/tasks` 只读展示,等待一个版本观察后随旧适配器一起移除。具名 Agent 的后台运行不再写此表。`session_id` 不使用数据库外键,因为 session 使用软删除,关联关系由应用层维护。
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
@ -72,6 +76,96 @@ delegate 后台子任务表。`session_id` 不使用数据库外键,因为 ses
|
|||||||
| `finished_at` | INTEGER | 结束时间 |
|
| `finished_at` | INTEGER | 结束时间 |
|
||||||
| `created_at` | INTEGER | 创建时间 |
|
| `created_at` | INTEGER | 创建时间 |
|
||||||
|
|
||||||
|
## agent_run_groups 表(schema v6,Agent 编排)
|
||||||
|
|
||||||
|
批量委托的组头。单任务委托不建组;`completion_policy` 决定 background 完成事件形态(当前 background 批量未开放,组仅用于批量 foreground)。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | TEXT PK | 组 ID |
|
||||||
|
| `root_session_id` | TEXT | 根会话(软删除,无级联外键,由应用层收敛) |
|
||||||
|
| `caller_run_id` | TEXT | 发起方 run ID(NULL 表示 Root 发起) |
|
||||||
|
| `caller_scope_id` | TEXT | 幂等作用域;Root 固定字面量 `"ROOT"` |
|
||||||
|
| `idempotency_key` | TEXT | 幂等键(当前工具未开放,预留) |
|
||||||
|
| `mode` | TEXT | foreground / background |
|
||||||
|
| `completion_policy` | TEXT | all / each |
|
||||||
|
| `expected_runs` / `terminal_runs` / `abnormal_runs` | INTEGER | 组内 run 计数 |
|
||||||
|
| `completion_slot_reserved` | INTEGER | 是否预留 background completion 槽 |
|
||||||
|
| `completion_delivery` / `failure_delivery` | TEXT | 组完成/失败投递 lane(queue/steer) |
|
||||||
|
| `status` | TEXT | queued / running / completed / partial / failed / timed_out / cancelled / interrupted |
|
||||||
|
| `deadline_at` / `runtime_generation` / `revision` | INTEGER | 截止、运行代、客户端投影修订号 |
|
||||||
|
| `created_at` / `updated_at` / `finished_at` | INTEGER | 时间线 |
|
||||||
|
|
||||||
|
## agent_runs 表(schema v6,Agent 编排)
|
||||||
|
|
||||||
|
每次具名委托(foreground 与 background 一致)先落库再执行;`execution_id` 条件更新保证迟到结果丢弃。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | TEXT PK | run ID |
|
||||||
|
| `group_id` | TEXT FK | 所属组(RESTRICT) |
|
||||||
|
| `root_session_id` | TEXT | 根会话 |
|
||||||
|
| `parent_run_id` | TEXT FK | 父 run(RESTRICT),NULL 表示 Root 直接委托 |
|
||||||
|
| `caller_agent_id` / `caller_scope_id` | TEXT | 调用方身份;Root 的 caller_scope_id 固定 `"ROOT"` |
|
||||||
|
| `agent_id` / `definition_hash` / `provider_profile` | TEXT | Definition 快照(绑定运行代,运行中不热切换) |
|
||||||
|
| `provider_name` / `model_id` | TEXT | Provider 与模型 |
|
||||||
|
| `mode` | TEXT | foreground / background |
|
||||||
|
| `depth` | INTEGER | 委托深度(>=1) |
|
||||||
|
| `plan_item_id` | TEXT | 绑定计划子项(接纳时原子领取) |
|
||||||
|
| `execution_id` | TEXT | 执行尝试 ID,唯一索引 |
|
||||||
|
| `task` / `context_json` | TEXT | 任务与调用方上下文 |
|
||||||
|
| `budget_json` | TEXT | 树级剩余预算 |
|
||||||
|
| `signal_contract_json` / `signal_delivery` | TEXT | Definition 信号契约快照与投递 lane(queue/steer) |
|
||||||
|
| `completion_delivery` / `failure_delivery` | TEXT | 完成/失败投递 lane(当前单任务 background 恒为 queue,保留给批量) |
|
||||||
|
| `status` | TEXT | queued / running / waiting_children / completed / failed / timed_out / cancelled / interrupted |
|
||||||
|
| `result` / `error` | TEXT | 终态完整结果/错误(get_result 与 tool 结果同源) |
|
||||||
|
| `prompt_tokens` / `completion_tokens` / `cost` | INTEGER/REAL | Provider usage |
|
||||||
|
| `tool_calls_count` / `iterations` | INTEGER | 执行统计 |
|
||||||
|
| `runtime_generation` / `attempt` | INTEGER | 运行代与重试次数 |
|
||||||
|
| `completion_slot_reserved` | INTEGER | background 完成槽预留 |
|
||||||
|
| `deadline_at` / `started_at` / `finished_at` / `created_at` / `updated_at` | INTEGER | 时间线 |
|
||||||
|
| `revision` | INTEGER | 客户端投影修订号 |
|
||||||
|
|
||||||
|
索引:`execution_id` 唯一、`(root_session_id, caller_scope_id, idempotency_key)` 部分唯一、`(root_session_id, created_at DESC)`、`(parent_run_id, created_at)`、`(runtime_generation, status, deadline_at)`(恢复扫描)。
|
||||||
|
|
||||||
|
## agent_session_state 表(schema v6,Agent 编排)
|
||||||
|
|
||||||
|
每根会话一行,inbox 容量与客户端 revision 的权威计数:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `root_session_id` | TEXT PK |
|
||||||
|
| `revision` | 单调客户端投影修订号 |
|
||||||
|
| `pending_event_count` | 未消费事件数(容量条件更新) |
|
||||||
|
| `reserved_completion_slots` | 已接纳 background run 预留的完成槽 |
|
||||||
|
| `updated_at` | 最后更新时间 |
|
||||||
|
|
||||||
|
容量判断在同一写事务内做条件 `UPDATE`(`pending + reserved + 新增 <= 上限`),杜绝并发 `COUNT(*)` 漂移。
|
||||||
|
|
||||||
|
## agent_inbox_events 表(schema v6,Agent 编排)
|
||||||
|
|
||||||
|
background 完成/信号投递的唯一事实源:`pending → leased → admitted → consumed`,失败按 token 释放回 pending,超限进 dead-letter,崩溃靠 lease 过期恢复。
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `id` | TEXT PK |
|
||||||
|
| `root_session_id` / `scope_kind` / `scope_id` | 归属(run 或 group,CHECK 互斥) |
|
||||||
|
| `run_id` / `group_id` | TEXT FK(RESTRICT) |
|
||||||
|
| `event_type` | signal / completion / group_completion |
|
||||||
|
| `event_key` | 去重键(signal 含冷却窗口 id) |
|
||||||
|
| `delivery` | queue / steer |
|
||||||
|
| `requires_continuation` | 是否反向启动 continuation Turn(cancel 产物为 false) |
|
||||||
|
| `severity` / `payload_json` | 信号级别与结构化载荷(completion 含 signal_ids) |
|
||||||
|
| `status` | pending / leased / admitted / consumed / superseded / dead_letter |
|
||||||
|
| `attempt_count` / `lease_token` / `lease_until` / `next_attempt_at` | 投递尝试与租约 |
|
||||||
|
| `admitted_turn_id` | steer 事件接纳的 Turn(/stop 按此条件释放) |
|
||||||
|
| `last_error` | 最近失败原因 |
|
||||||
|
| `revision` | 投影修订号 |
|
||||||
|
| `updated_at` | 最后更新时间 |
|
||||||
|
| `created_at` / `consumed_at` / `superseded_at` / `dead_lettered_at` / `fallback_notified_at` / `fallback_suppressed_reason` | 状态时间线 |
|
||||||
|
|
||||||
|
`(scope_kind, scope_id, event_type, event_key)` 唯一(signal 冷却窗去重)。消费/死信会同步递减 `agent_session_state.pending_event_count`。
|
||||||
|
|
||||||
## task_plans / task_items 表
|
## task_plans / task_items 表
|
||||||
|
|
||||||
`task_plans` 保存 session 级任务计划,通过部分唯一索引保证每个 session 最多一个 `status='active'` 的计划。`version` 在任何子项变化时递增,用于 WebSocket 快照排序和乐观并发检查。
|
`task_plans` 保存 session 级任务计划,通过部分唯一索引保证每个 session 最多一个 `status='active'` 的计划。`version` 在任何子项变化时递增,用于 WebSocket 快照排序和乐观并发检查。
|
||||||
|
|||||||
52
src/agent/builtin.rs
Normal file
52
src/agent/builtin.rs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
//! Built-in Agent definitions, released to the user config directory on
|
||||||
|
//! first run just like built-in skills. A released definition is a regular
|
||||||
|
//! user-editable file afterwards; the installer never overwrites it.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::config::LLMProviderConfig;
|
||||||
|
|
||||||
|
mod embedded {
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/embedded_agents.rs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install built-in Agent definitions into `<config_dir>/agents/`. Files
|
||||||
|
/// that already exist (user-modified or user-created) are left untouched.
|
||||||
|
pub fn install_builtin_agents(config_dir: &Path, profiles: &std::collections::HashMap<String, LLMProviderConfig>) {
|
||||||
|
let agents_dir = config_dir.join("agents");
|
||||||
|
if let Err(error) = std::fs::create_dir_all(&agents_dir) {
|
||||||
|
tracing::warn!(dir = %agents_dir.display(), error = %error, "Failed to create agents directory");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for agent in embedded::EMBEDDED_AGENTS {
|
||||||
|
let path = agents_dir.join(format!("{}.md", agent.name));
|
||||||
|
if path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(error) = std::fs::write(&path, agent.content) {
|
||||||
|
tracing::warn!(name = agent.name, error = %error, "Failed to install built-in Agent definition");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let profile = match profiles.get("default").cloned() {
|
||||||
|
Some(profile) => profile,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
name = agent.name,
|
||||||
|
"Skipping built-in Agent validation: no 'default' provider profile configured"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Validate the released file immediately so a future catalog load
|
||||||
|
// cannot fail on a broken built-in. Validation failure keeps the
|
||||||
|
// file (the user can edit it) but logs loudly.
|
||||||
|
match super::definition::parse_definition(&path, std::sync::Arc::new(profile)) {
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::info!(name = agent.name, dir = %path.display(), "Installed built-in Agent definition");
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(name = agent.name, error = %error, "Installed built-in Agent definition failed validation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1125,17 +1125,13 @@ mod tests {
|
|||||||
) -> (Arc<AgentCoordinator>, tempfile::TempDir) {
|
) -> (Arc<AgentCoordinator>, tempfile::TempDir) {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let storage = Arc::new(Storage::new(&dir.path().join("coord.db")).await.unwrap());
|
let storage = Arc::new(Storage::new(&dir.path().join("coord.db")).await.unwrap());
|
||||||
let (notify_tx, _notify_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
let catalog = write_catalog(dir.path());
|
let catalog = write_catalog(dir.path());
|
||||||
let manager = Arc::new(
|
let manager = Arc::new(
|
||||||
SubAgentManager::new(
|
SubAgentManager::new(
|
||||||
provider_config(),
|
provider_config(),
|
||||||
Arc::new(ToolRegistry::new()),
|
Arc::new(ToolRegistry::new()),
|
||||||
Some(storage.clone()),
|
Some(storage.clone()),
|
||||||
notify_tx,
|
|
||||||
1,
|
|
||||||
None,
|
None,
|
||||||
crate::task_supervisor::TaskSupervisor::new(),
|
|
||||||
)
|
)
|
||||||
.with_catalog(Arc::new(catalog)),
|
.with_catalog(Arc::new(catalog)),
|
||||||
);
|
);
|
||||||
@ -1627,17 +1623,13 @@ mod tests {
|
|||||||
let coordinator2 = {
|
let coordinator2 = {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let storage2 = Arc::new(Storage::new(&dir.path().join("c2.db")).await.unwrap());
|
let storage2 = Arc::new(Storage::new(&dir.path().join("c2.db")).await.unwrap());
|
||||||
let (notify_tx, _) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
let catalog = write_catalog(dir.path());
|
let catalog = write_catalog(dir.path());
|
||||||
let manager = Arc::new(
|
let manager = Arc::new(
|
||||||
SubAgentManager::new(
|
SubAgentManager::new(
|
||||||
provider_config(),
|
provider_config(),
|
||||||
Arc::new(ToolRegistry::new()),
|
Arc::new(ToolRegistry::new()),
|
||||||
Some(storage2.clone()),
|
Some(storage2.clone()),
|
||||||
notify_tx,
|
|
||||||
1,
|
|
||||||
None,
|
None,
|
||||||
crate::task_supervisor::TaskSupervisor::new(),
|
|
||||||
)
|
)
|
||||||
.with_catalog(Arc::new(catalog)),
|
.with_catalog(Arc::new(catalog)),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
pub mod agent_loop;
|
pub mod agent_loop;
|
||||||
|
pub mod builtin;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod context_compressor;
|
pub mod context_compressor;
|
||||||
pub mod coordinator;
|
pub mod coordinator;
|
||||||
@ -24,8 +25,7 @@ pub use projection::AgentProjectionHub;
|
|||||||
pub use run::{AgentBudget, AgentExecutionContext};
|
pub use run::{AgentBudget, AgentExecutionContext};
|
||||||
pub use steering::{SteeringDrain, SteeringPushError, TurnInput, TurnInputSource, TurnMailbox};
|
pub use steering::{SteeringDrain, SteeringPushError, TurnInput, TurnInputSource, TurnMailbox};
|
||||||
pub use sub_agent::{
|
pub use sub_agent::{
|
||||||
DelegateContext, ExecutionMode, SubAgentConfig, SubAgentError, SubAgentManager, SubAgentResult,
|
ExecutionMode, SubAgentConfig, SubAgentError, SubAgentManager, SubAgentResult, TaskStatus,
|
||||||
TaskNotification, TaskStatus,
|
|
||||||
};
|
};
|
||||||
pub use system_prompt::{
|
pub use system_prompt::{
|
||||||
PromptContext, PromptSection, SystemPromptBuilder, build_sub_agent_system_prompt,
|
PromptContext, PromptSection, SystemPromptBuilder, build_sub_agent_system_prompt,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -204,7 +204,10 @@ impl Default for AgentOrchestrationConfig {
|
|||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
definitions_dir: "agents".to_string(),
|
definitions_dir: "agents".to_string(),
|
||||||
root_delegates: Vec::new(),
|
// The built-in general-purpose Agent is released automatically;
|
||||||
|
// it is delegated by default so orchestration works out of the
|
||||||
|
// box. Explicit configuration fully overrides this list.
|
||||||
|
root_delegates: vec!["general-purpose".to_string()],
|
||||||
max_tree_depth: 4,
|
max_tree_depth: 4,
|
||||||
max_runs_per_tree: 16,
|
max_runs_per_tree: 16,
|
||||||
max_concurrent_runs: 6,
|
max_concurrent_runs: 6,
|
||||||
|
|||||||
@ -210,7 +210,6 @@ impl GatewayState {
|
|||||||
.with_admission(admission.clone()),
|
.with_admission(admission.clone()),
|
||||||
browser_config,
|
browser_config,
|
||||||
health,
|
health,
|
||||||
config.gateway.max_concurrent_background_tasks,
|
|
||||||
)?;
|
)?;
|
||||||
let session_manager = Arc::new(session_manager);
|
let session_manager = Arc::new(session_manager);
|
||||||
session_manager.bind_inbox_wake();
|
session_manager.bind_inbox_wake();
|
||||||
|
|||||||
@ -1718,7 +1718,6 @@ pub struct SessionManager {
|
|||||||
pub(super) bus: Arc<MessageBus>,
|
pub(super) bus: Arc<MessageBus>,
|
||||||
memory_manager: Arc<crate::memory::MemoryManager>,
|
memory_manager: Arc<crate::memory::MemoryManager>,
|
||||||
work_manager: Arc<crate::work::WorkManager>,
|
work_manager: Arc<crate::work::WorkManager>,
|
||||||
sub_agent_manager: Arc<crate::agent::SubAgentManager>,
|
|
||||||
agent_catalog: Arc<crate::agent::AgentCatalog>,
|
agent_catalog: Arc<crate::agent::AgentCatalog>,
|
||||||
execution_gate: Arc<crate::agent::gate::ExecutionGate>,
|
execution_gate: Arc<crate::agent::gate::ExecutionGate>,
|
||||||
agent_coordinator: Option<Arc<crate::agent::AgentCoordinator>>,
|
agent_coordinator: Option<Arc<crate::agent::AgentCoordinator>>,
|
||||||
@ -1929,7 +1928,6 @@ impl SessionManager {
|
|||||||
services: SessionManagerServices,
|
services: SessionManagerServices,
|
||||||
browser_config: Option<BrowserConfig>,
|
browser_config: Option<BrowserConfig>,
|
||||||
health: Arc<crate::health::HealthService>,
|
health: Arc<crate::health::HealthService>,
|
||||||
max_concurrent_background_tasks: usize,
|
|
||||||
) -> Result<Self, AgentError> {
|
) -> Result<Self, AgentError> {
|
||||||
let SessionManagerServices {
|
let SessionManagerServices {
|
||||||
bus,
|
bus,
|
||||||
@ -1958,6 +1956,12 @@ impl SessionManager {
|
|||||||
.map_err(|error| AgentError::Other(format!("failed to create tools: {error}")))?,
|
.map_err(|error| AgentError::Other(format!("failed to create tools: {error}")))?,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Release built-in Agent definitions (like built-in skills) before
|
||||||
|
// the catalog loads; existing user files are never overwritten.
|
||||||
|
crate::agent::builtin::install_builtin_agents(
|
||||||
|
&catalog_preparation.config_dir,
|
||||||
|
&catalog_preparation.provider_profiles,
|
||||||
|
);
|
||||||
let agent_catalog = Arc::new(
|
let agent_catalog = Arc::new(
|
||||||
crate::agent::AgentCatalog::load(
|
crate::agent::AgentCatalog::load(
|
||||||
&catalog_preparation.config,
|
&catalog_preparation.config,
|
||||||
@ -1976,18 +1980,13 @@ impl SessionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create SubAgentManager and register DelegateTool
|
// Create SubAgentManager and register DelegateTool
|
||||||
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
let sub_agent_manager = Arc::new(
|
let sub_agent_manager = Arc::new(
|
||||||
crate::agent::SubAgentManager::new(
|
crate::agent::SubAgentManager::new(
|
||||||
provider_config.clone(),
|
provider_config.clone(),
|
||||||
tools.clone(),
|
tools.clone(),
|
||||||
Some(storage.clone()),
|
Some(storage.clone()),
|
||||||
notify_tx,
|
|
||||||
max_concurrent_background_tasks,
|
|
||||||
Some(skills_loader.clone()),
|
Some(skills_loader.clone()),
|
||||||
task_supervisor.clone(),
|
|
||||||
)
|
)
|
||||||
.with_admission(admission.clone())
|
|
||||||
.with_catalog(agent_catalog.clone())
|
.with_catalog(agent_catalog.clone())
|
||||||
.with_execution_gate(execution_gate.clone())
|
.with_execution_gate(execution_gate.clone())
|
||||||
.with_work_manager(work_manager.clone()),
|
.with_work_manager(work_manager.clone()),
|
||||||
@ -2018,29 +2017,6 @@ impl SessionManager {
|
|||||||
tools.register(delegate_tool);
|
tools.register(delegate_tool);
|
||||||
tools.register(crate::tools::ReloadConfigTool::new(reload.clone()));
|
tools.register(crate::tools::ReloadConfigTool::new(reload.clone()));
|
||||||
|
|
||||||
// Start background task notification consumer
|
|
||||||
let sm_bus = bus.clone();
|
|
||||||
task_supervisor.spawn("background-task-notifications", async move {
|
|
||||||
while let Some(notif) = notify_rx.recv().await {
|
|
||||||
let content =
|
|
||||||
format_task_notification(¬if.task_id, ¬if.status, ¬if.result_summary);
|
|
||||||
let metadata = HashMap::from([
|
|
||||||
("_type".to_string(), "notification".to_string()),
|
|
||||||
("_session_id".to_string(), notif.session_id),
|
|
||||||
]);
|
|
||||||
let outbound = OutboundMessage {
|
|
||||||
channel: notif.channel,
|
|
||||||
chat_id: notif.chat_id,
|
|
||||||
content,
|
|
||||||
reply_to: None,
|
|
||||||
media: vec![],
|
|
||||||
metadata,
|
|
||||||
delivery: None,
|
|
||||||
};
|
|
||||||
let _ = sm_bus.publish_outbound(outbound).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start periodic background task cleanup (every hour, TTL 24h)
|
// Start periodic background task cleanup (every hour, TTL 24h)
|
||||||
let cleanup_storage = storage.clone();
|
let cleanup_storage = storage.clone();
|
||||||
task_supervisor.spawn("background-task-cleanup", async move {
|
task_supervisor.spawn("background-task-cleanup", async move {
|
||||||
@ -2072,7 +2048,6 @@ impl SessionManager {
|
|||||||
bus,
|
bus,
|
||||||
memory_manager,
|
memory_manager,
|
||||||
work_manager,
|
work_manager,
|
||||||
sub_agent_manager,
|
|
||||||
agent_catalog,
|
agent_catalog,
|
||||||
execution_gate,
|
execution_gate,
|
||||||
agent_coordinator,
|
agent_coordinator,
|
||||||
@ -2447,9 +2422,6 @@ impl SessionManager {
|
|||||||
// after releasing the session lock. Named durable runs are
|
// after releasing the session lock. Named durable runs are
|
||||||
// cancelled with suppress_continuation so no continuation Turn
|
// cancelled with suppress_continuation so no continuation Turn
|
||||||
// restarts after an explicit stop.
|
// restarts after an explicit stop.
|
||||||
self.sub_agent_manager
|
|
||||||
.cancel_by_session(&sid.to_string())
|
|
||||||
.await;
|
|
||||||
if let Some(coordinator) = self.agent_coordinator.as_ref()
|
if let Some(coordinator) = self.agent_coordinator.as_ref()
|
||||||
&& let Err(error) = coordinator
|
&& let Err(error) = coordinator
|
||||||
.cancel_session(&sid.to_string(), "stopped by user")
|
.cancel_session(&sid.to_string(), "stopped by user")
|
||||||
@ -4048,18 +4020,13 @@ fn spawn_agent_worker(
|
|||||||
if let Some(handle) = wakeup_handle {
|
if let Some(handle) = wakeup_handle {
|
||||||
tool_context = tool_context.with_turn_wakeup(handle);
|
tool_context = tool_context.with_turn_wakeup(handle);
|
||||||
}
|
}
|
||||||
let process_result = crate::agent::sub_agent::DELEGATE_CONTEXT.scope(
|
let process_result = agent
|
||||||
crate::agent::DelegateContext {
|
.process_streaming_with_context(
|
||||||
session_id: unified_str2,
|
|
||||||
channel: chan2.clone(),
|
|
||||||
chat_id: cid2.clone(),
|
|
||||||
},
|
|
||||||
agent.process_streaming_with_context(
|
|
||||||
history_out.clone(),
|
history_out.clone(),
|
||||||
agent_turn.clone(),
|
agent_turn.clone(),
|
||||||
tool_context.clone(),
|
tool_context.clone(),
|
||||||
),
|
)
|
||||||
).await;
|
.await;
|
||||||
let mut result = match process_result {
|
let mut result = match process_result {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(AgentError::LlmError(ref msg))
|
Err(AgentError::LlmError(ref msg))
|
||||||
@ -5081,24 +5048,6 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_task_notification(
|
|
||||||
task_id: &str,
|
|
||||||
status: &crate::agent::TaskStatus,
|
|
||||||
summary: &str,
|
|
||||||
) -> String {
|
|
||||||
match status {
|
|
||||||
crate::agent::TaskStatus::Completed => format!(
|
|
||||||
"📋 后台任务完成\n\n任务 ID: {}\n\n结果:\n{}",
|
|
||||||
task_id, summary
|
|
||||||
),
|
|
||||||
crate::agent::TaskStatus::Failed(err) => {
|
|
||||||
format!("📋 后台任务失败\n\n任务 ID: {}\n错误: {}", task_id, err)
|
|
||||||
}
|
|
||||||
crate::agent::TaskStatus::Cancelled => format!("📋 后台任务已取消\n\n任务 ID: {}", task_id),
|
|
||||||
crate::agent::TaskStatus::TimedOut => format!("📋 后台任务超时\n\n任务 ID: {}", task_id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod slash_command_tests {
|
mod slash_command_tests {
|
||||||
use super::{
|
use super::{
|
||||||
|
|||||||
@ -17,13 +17,3 @@ pub struct BackgroundTask {
|
|||||||
pub finished_at: Option<i64>,
|
pub finished_at: Option<i64>,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct BackgroundTaskUpdate<'a> {
|
|
||||||
pub status: &'a str,
|
|
||||||
pub result: Option<&'a str>,
|
|
||||||
pub error: Option<&'a str>,
|
|
||||||
pub started_at: Option<i64>,
|
|
||||||
pub finished_at: Option<i64>,
|
|
||||||
pub tool_calls_count: Option<i64>,
|
|
||||||
pub iterations: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1490,33 +1490,6 @@ impl Storage {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn update_background_task_status(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
update: crate::storage::background_task::BackgroundTaskUpdate<'_>,
|
|
||||||
) -> Result<(), StorageError> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
UPDATE background_tasks
|
|
||||||
SET status = ?, result = COALESCE(?, result), error = COALESCE(?, error),
|
|
||||||
started_at = COALESCE(?, started_at), finished_at = COALESCE(?, finished_at),
|
|
||||||
tool_calls_count = COALESCE(?, tool_calls_count),
|
|
||||||
iterations = COALESCE(?, iterations)
|
|
||||||
WHERE id = ?
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(update.status)
|
|
||||||
.bind(update.result)
|
|
||||||
.bind(update.error)
|
|
||||||
.bind(update.started_at)
|
|
||||||
.bind(update.finished_at)
|
|
||||||
.bind(update.tool_calls_count)
|
|
||||||
.bind(update.iterations)
|
|
||||||
.bind(id)
|
|
||||||
.execute(self.pool())
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_background_task(
|
pub async fn get_background_task(
|
||||||
&self,
|
&self,
|
||||||
@ -1823,48 +1796,6 @@ mod tests {
|
|||||||
assert_eq!(sentinel_count, 1);
|
assert_eq!(sentinel_count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn background_task_completion_persists_execution_metrics() {
|
|
||||||
let (storage, _dir) = create_test_storage().await;
|
|
||||||
let task = crate::storage::BackgroundTask {
|
|
||||||
id: "task-metrics".into(),
|
|
||||||
session_id: "cli:test:dialog".into(),
|
|
||||||
channel: "cli".into(),
|
|
||||||
chat_id: "test".into(),
|
|
||||||
prompt: "measure".into(),
|
|
||||||
allowed_tools: None,
|
|
||||||
status: "pending".into(),
|
|
||||||
result: None,
|
|
||||||
error: None,
|
|
||||||
tool_calls_count: 0,
|
|
||||||
iterations: 0,
|
|
||||||
started_at: None,
|
|
||||||
finished_at: None,
|
|
||||||
created_at: 1,
|
|
||||||
};
|
|
||||||
storage.create_background_task(&task).await.unwrap();
|
|
||||||
|
|
||||||
storage
|
|
||||||
.update_background_task_status(
|
|
||||||
&task.id,
|
|
||||||
crate::storage::background_task::BackgroundTaskUpdate {
|
|
||||||
status: "completed",
|
|
||||||
result: Some("done"),
|
|
||||||
error: None,
|
|
||||||
started_at: Some(2),
|
|
||||||
finished_at: Some(3),
|
|
||||||
tool_calls_count: Some(4),
|
|
||||||
iterations: Some(5),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let persisted = storage.get_background_task(&task.id).await.unwrap();
|
|
||||||
assert_eq!(persisted.tool_calls_count, 4);
|
|
||||||
assert_eq!(persisted.iterations, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn webui_lists_recent_tasks_across_sessions() {
|
async fn webui_lists_recent_tasks_across_sessions() {
|
||||||
let (storage, _dir) = create_test_storage().await;
|
let (storage, _dir) = create_test_storage().await;
|
||||||
|
|||||||
@ -86,6 +86,10 @@ impl Tool for BashTool {
|
|||||||
"bash"
|
"bash"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn delegation_policy(&self) -> crate::tools::DelegationPolicy {
|
||||||
|
crate::tools::DelegationPolicy::Delegatable
|
||||||
|
}
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Execute a bash shell command and return its output. Use with caution."
|
"Execute a bash shell command and return its output. Use with caution."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,7 +87,7 @@ impl DelegateTool {
|
|||||||
.collect();
|
.collect();
|
||||||
let mut target = json!({
|
let mut target = json!({
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "目标 Agent ID。省略时仅使用兼容 general Agent"
|
"description": "目标具名 Agent ID(来自 agent_orchestration 的 agents 目录)"
|
||||||
});
|
});
|
||||||
if !targets.is_empty() {
|
if !targets.is_empty() {
|
||||||
target["enum"] = json!(targets);
|
target["enum"] = json!(targets);
|
||||||
@ -155,20 +155,9 @@ impl DelegateTool {
|
|||||||
args: &Value,
|
args: &Value,
|
||||||
context: &ToolExecutionContext,
|
context: &ToolExecutionContext,
|
||||||
) -> anyhow::Result<ToolResult> {
|
) -> anyhow::Result<ToolResult> {
|
||||||
let requested_mode = args
|
let mode = match args.get("mode").and_then(Value::as_str).unwrap_or("foreground") {
|
||||||
.get("mode")
|
"foreground" => ExecutionMode::Foreground,
|
||||||
.and_then(Value::as_str)
|
"background" => ExecutionMode::Background,
|
||||||
.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 => {
|
other => {
|
||||||
return Ok(failure(format!(
|
return Ok(failure(format!(
|
||||||
"unknown mode '{other}'; supported modes are foreground and background"
|
"unknown mode '{other}'; supported modes are foreground and background"
|
||||||
@ -178,9 +167,6 @@ impl DelegateTool {
|
|||||||
let task_values: Vec<&Value> = match args.get("tasks").and_then(Value::as_array) {
|
let task_values: Vec<&Value> = match args.get("tasks").and_then(Value::as_array) {
|
||||||
Some(tasks) if !tasks.is_empty() => tasks.iter().collect(),
|
Some(tasks) if !tasks.is_empty() => tasks.iter().collect(),
|
||||||
Some(_) => return Ok(failure("tasks must not be empty")),
|
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],
|
None => vec![args],
|
||||||
};
|
};
|
||||||
let mut configs = Vec::with_capacity(task_values.len());
|
let mut configs = Vec::with_capacity(task_values.len());
|
||||||
@ -228,38 +214,15 @@ impl DelegateTool {
|
|||||||
|
|
||||||
match mode {
|
match mode {
|
||||||
ExecutionMode::Foreground => {
|
ExecutionMode::Foreground => {
|
||||||
let all_named = configs.iter().all(|config| config.target.is_some());
|
let Some(coordinator) = self.coordinator.as_ref() else {
|
||||||
let results = if all_named && let Some(coordinator) = self.coordinator.as_ref() {
|
return Ok(failure(
|
||||||
coordinator
|
"delegate requires agent_orchestration to be enabled (named Agents only)",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let results = coordinator
|
||||||
.delegate_foreground(context, configs)
|
.delegate_foreground(context, configs)
|
||||||
.await
|
.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",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut results = self
|
|
||||||
.sub_agent_manager
|
|
||||||
.run_foreground_batch(configs, context)
|
|
||||||
.await
|
|
||||||
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
|
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
|
||||||
// Legacy general compatibility: tell the model the
|
|
||||||
// unnamed path is deprecated so it migrates to named
|
|
||||||
// Agents (which get durable runs, fixed tool sets and
|
|
||||||
// per-run resource scopes).
|
|
||||||
for result in results.iter_mut() {
|
|
||||||
if matches!(result.status, TaskStatus::Completed) {
|
|
||||||
result.content = format!(
|
|
||||||
"{}\n\n[提示] 无 target 的通用 Agent 是兼容模式:不持久化、不可审计。\
|
|
||||||
建议为固定角色创建具名 Agent definition 并使用 target 委托。",
|
|
||||||
result.content
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results
|
|
||||||
};
|
|
||||||
let payload: Vec<_> = results
|
let payload: Vec<_> = results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|result| {
|
.map(|result| {
|
||||||
@ -297,91 +260,23 @@ impl DelegateTool {
|
|||||||
"child Agents cannot create background runs in the current implementation",
|
"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 {
|
let Some(coordinator) = self.coordinator.as_ref() else {
|
||||||
return Ok(failure(
|
return Ok(failure(
|
||||||
"named background Agents require agent_orchestration to be enabled",
|
"delegate requires agent_orchestration to be enabled (named Agents only)",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
config.session_id = context
|
let config = configs.into_iter().next().expect("checked non-empty");
|
||||||
.agent
|
match coordinator.delegate_background(context, config).await {
|
||||||
.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!({
|
Ok(run_id) => Ok(success(json!({
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"runs": vec![json!({ "run_id": run_id, "status": "queued" })]
|
"runs": vec![json!({ "run_id": run_id, "status": "queued" })]
|
||||||
}))),
|
}))),
|
||||||
Err(error) => Ok(failure(error.to_string())),
|
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<_>>() }),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -398,11 +293,6 @@ impl Tool for DelegateTool {
|
|||||||
json!({
|
json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["run", "check_task", "cancel_task", "list_tasks"],
|
|
||||||
"description": "Compatibility task-management actions remain available during migration; omit for run"
|
|
||||||
},
|
|
||||||
"target": self.task_schema()["properties"]["target"].clone(),
|
"target": self.task_schema()["properties"]["target"].clone(),
|
||||||
"task": { "type": "string", "description": "Single delegated task" },
|
"task": { "type": "string", "description": "Single delegated task" },
|
||||||
"context": { "type": "string", "description": "Explicit context for the child Agent" },
|
"context": { "type": "string", "description": "Explicit context for the child Agent" },
|
||||||
@ -418,19 +308,17 @@ impl Tool for DelegateTool {
|
|||||||
"description": "Independent tasks; execution is concurrent and results preserve request order"
|
"description": "Independent tasks; execution is concurrent and results preserve request order"
|
||||||
},
|
},
|
||||||
"plan_item_id": { "type": "string" },
|
"plan_item_id": { "type": "string" },
|
||||||
"task_id": { "type": "string", "description": "Legacy task-management action target" },
|
|
||||||
"allowed_tools": {
|
"allowed_tools": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "type": "string" },
|
"items": { "type": "string" },
|
||||||
"description": "Deprecated; only narrows the legacy general Agent and never expands named Agent permissions"
|
"description": "Only narrows the target Definition's tool set; never expands permissions"
|
||||||
},
|
},
|
||||||
"max_iterations": { "type": "integer", "minimum": 1 },
|
"max_iterations": { "type": "integer", "minimum": 1 },
|
||||||
"timeout_secs": { "type": "integer", "minimum": 1 }
|
"timeout_secs": { "type": "integer", "minimum": 1 }
|
||||||
},
|
},
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{ "required": ["task"] },
|
{ "required": ["task"] },
|
||||||
{ "required": ["tasks"] },
|
{ "required": ["tasks"] }
|
||||||
{ "required": ["action"] }
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -454,24 +342,10 @@ impl Tool for DelegateTool {
|
|||||||
context: &ToolExecutionContext,
|
context: &ToolExecutionContext,
|
||||||
args: Value,
|
args: Value,
|
||||||
) -> anyhow::Result<ToolOutput> {
|
) -> anyhow::Result<ToolOutput> {
|
||||||
let action = args.get("action").and_then(Value::as_str).unwrap_or("run");
|
self.handle_run(&args, context).await.map(Into::into)
|
||||||
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}'")),
|
|
||||||
};
|
|
||||||
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>) {
|
fn status_projection(status: &TaskStatus) -> (&'static str, Option<&str>) {
|
||||||
match status {
|
match status {
|
||||||
TaskStatus::Completed => ("completed", None),
|
TaskStatus::Completed => ("completed", None),
|
||||||
@ -503,7 +377,6 @@ mod tests {
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
fn manager() -> Arc<SubAgentManager> {
|
fn manager() -> Arc<SubAgentManager> {
|
||||||
let (notify_tx, _) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
Arc::new(SubAgentManager::new(
|
Arc::new(SubAgentManager::new(
|
||||||
crate::config::LLMProviderConfig {
|
crate::config::LLMProviderConfig {
|
||||||
provider_type: "openai".to_string(),
|
provider_type: "openai".to_string(),
|
||||||
@ -524,10 +397,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
Arc::new(crate::tools::ToolRegistry::new()),
|
Arc::new(crate::tools::ToolRegistry::new()),
|
||||||
None,
|
None,
|
||||||
notify_tx,
|
|
||||||
1,
|
|
||||||
None,
|
None,
|
||||||
crate::task_supervisor::TaskSupervisor::new(),
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,9 +15,13 @@ impl TodoTool {
|
|||||||
Self { work_manager }
|
Self { work_manager }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn context() -> anyhow::Result<crate::agent::DelegateContext> {
|
fn session_id(context: &crate::tools::ToolExecutionContext) -> anyhow::Result<String> {
|
||||||
crate::agent::sub_agent::get_delegate_context()
|
context
|
||||||
.map_err(|_| anyhow::anyhow!("todo context not available outside a session worker"))
|
.agent
|
||||||
|
.as_ref()
|
||||||
|
.map(|agent| agent.root_session_id.clone())
|
||||||
|
.or_else(|| context.session_id.clone())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("todo requires a session-bound context"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,10 +68,20 @@ impl Tool for TodoTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
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 action = args["action"]
|
let action = args["action"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("missing action"))?;
|
.ok_or_else(|| anyhow::anyhow!("missing action"))?;
|
||||||
let ctx = Self::context()?;
|
let session_id = Self::session_id(context)?;
|
||||||
let expected = args["expected_version"].as_i64();
|
let expected = args["expected_version"].as_i64();
|
||||||
let result = match action {
|
let result = match action {
|
||||||
"create" => {
|
"create" => {
|
||||||
@ -79,28 +93,29 @@ impl Tool for TodoTool {
|
|||||||
.filter_map(|value| value.as_str().map(str::to_string))
|
.filter_map(|value| value.as_str().map(str::to_string))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
self.work_manager
|
self.work_manager
|
||||||
.create_plan(&ctx.session_id, objective, &items)
|
.create_plan(&session_id, objective, &items)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
"view" => match self.work_manager.active_plan(&ctx.session_id).await? {
|
"view" => match self.work_manager.active_plan(&session_id).await? {
|
||||||
Some(plan) => Ok(plan),
|
Some(plan) => Ok(plan),
|
||||||
None => {
|
None => {
|
||||||
return Ok(ToolResult {
|
return Ok(ToolResult {
|
||||||
success: true,
|
success: true,
|
||||||
output: "当前 session 没有 active plan".to_string(),
|
output: "当前 session 没有 active plan".to_string(),
|
||||||
error: None,
|
error: None,
|
||||||
});
|
}
|
||||||
|
.into());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"append" => {
|
"append" => {
|
||||||
self.work_manager
|
self.work_manager
|
||||||
.append_item(&ctx.session_id, required_str(&args, "title")?, expected)
|
.append_item(&session_id, required_str(&args, "title")?, expected)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
"update" => {
|
"update" => {
|
||||||
self.work_manager
|
self.work_manager
|
||||||
.update_item(
|
.update_item(
|
||||||
&ctx.session_id,
|
&session_id,
|
||||||
required_str(&args, "item_id")?,
|
required_str(&args, "item_id")?,
|
||||||
required_str(&args, "status")?,
|
required_str(&args, "status")?,
|
||||||
args["summary"].as_str(),
|
args["summary"].as_str(),
|
||||||
@ -110,7 +125,7 @@ impl Tool for TodoTool {
|
|||||||
}
|
}
|
||||||
"close" => {
|
"close" => {
|
||||||
self.work_manager
|
self.work_manager
|
||||||
.close_plan(&ctx.session_id, required_str(&args, "status")?, expected)
|
.close_plan(&session_id, required_str(&args, "status")?, expected)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
_ => return Err(anyhow::anyhow!("unknown todo action: {action}")),
|
_ => return Err(anyhow::anyhow!("unknown todo action: {action}")),
|
||||||
@ -121,12 +136,14 @@ impl Tool for TodoTool {
|
|||||||
success: true,
|
success: true,
|
||||||
output: render_plan(&plan),
|
output: render_plan(&plan),
|
||||||
error: None,
|
error: None,
|
||||||
}),
|
}
|
||||||
|
.into()),
|
||||||
Err(error) => Ok(ToolResult {
|
Err(error) => Ok(ToolResult {
|
||||||
success: false,
|
success: false,
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
error: Some(error.to_string()),
|
error: Some(error.to_string()),
|
||||||
}),
|
}
|
||||||
|
.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user