# 子代理系统 (`subagent.rs` + `tools/subagent.rs`) 参考 Claude Code s04 Subagents 设计,实现**上下文隔离的子代理运行器**。父代理通过 `subagent` 工具将独立子任务委托给子代理执行,子代理拥有全新的消息上下文和完整的 ReAct 循环,仅将最终文本摘要返回给父代理。 核心价值:**防止父代理上下文被中间工具调用污染**,同时让子代理具备完整的工具能力(文献搜索、下载、RAG 检索、笔记等)来独立完成子任务。 ## 架构总览 ```mermaid graph TB subgraph Tool["SubAgentTool (tools/subagent.rs)"] direction TB T_name["工具名: 'subagent' (对外语义: delegate_research)"] T_params["参数: research_prompt (必需), max_steps (默认5, 最大10)"] T_interrupt["InterruptBehavior::Block — 需等待写操作完成"] T_role["职责: 参数解析 → 构造 SubAgentRunner → 结果包装"] end Tool -->|"调用"| Runner subgraph Runner["SubAgentRunner (subagent.rs)"] direction TB R_fields["字段:
• app_state: Arc<AppState>
• config: AgentConfig
• tool_registry: ToolRegistry (与父代理共享)
• hook_registry: Option<Arc<HookRegistry>>
• permission_checker: Arc<PermissionChecker>
• progress_tx: Option<UnboundedSender>
• parent_session_id: String"] R_ctors["三种构造方式:
① new() — 最简构造
② new_with_hooks() — 完整构造
③ new_with_registry() — 自定义工具集"] R_chain["链式配置:
• with_parent_session()
• with_thinking()"] end ``` ## 完整执行流程 ```mermaid sequenceDiagram participant Lead as 父代理 (AgentRuntime) participant Tool as SubAgentTool participant Runner as SubAgentRunner participant LLM as LLM API participant FE as 前端 SSE participant DB as SQLite Lead->>Tool: subagent(research_prompt, max_steps?) Tool->>Tool: 参数校验 + 构造 system_prompt Tool->>Runner: new_with_hooks(app_state, hooks, pchecker, sse_tx) Tool->>Runner: .with_parent_session(sid).with_thinking(bool) Tool->>Runner: run(system_prompt, research_prompt, max_steps) Note over Runner: Phase 1 — 初始化 Runner->>Runner: 生成 subagent_name = "sub_" Runner->>Runner: 触发 OnSubagentStart hook Runner->>DB: 保存 system + user 消息 (fire-and-forget) Note over Runner: Phase 2 — run_inner() ReAct 循环 Runner->>Runner: 构建全新 messages: [system, user] loop 每步迭代 (step ≤ max_steps) Runner->>Runner: 上下文压缩检查 (est_tokens > limit × 1.5) Runner->>LLM: chat_stream(messages, tool_defs, thinking?) LLM-->>Runner: ReasoningDelta / TextDelta / ToolCallsComplete Runner->>FE: Thought "[子代理] ..." (通过 progress_tx) alt 无工具调用 → 最终答案 Runner->>FE: Thought (子代理结论) Runner-->>Tool: ToolOutput::success([子代理活动记录] + [子代理结论]) else 有工具调用 Runner->>Runner: 死循环检测 (连续相同调用 ≥ 3 次 → 拦截) loop 每个 tool_call (串行执行) Runner->>FE: ToolCall "[sub] tool_name" (通过 progress_tx) Runner->>Runner: PreToolUse hook (Block → 跳过, MutateInput → 修改参数) Runner->>Runner: 权限检查 (is_denied → 跳过) Runner->>Runner: tool.execute(args, ctx) (timeout 保护) Runner->>Runner: PostToolUse hook (MutateOutput → 修改输出) Runner->>Runner: 输出截断 (max_tool_output_chars) Runner->>FE: ToolResult "[sub] tool_name" (通过 progress_tx) Runner->>DB: 保存 assistant + tool 消息 (fire-and-forget) Runner->>Runner: messages.push(ChatMessage::tool_result) end Runner->>Runner: messages.push(ChatMessage::assistant) end end Note over Runner: Phase 3 — 达到最大步数 Runner->>LLM: force_final_answer (空工具列表, 无工具调用) LLM-->>Runner: 最终文本答案 Note over Runner: Phase 4 — 收尾 Runner->>DB: 保存最终 assistant 消息 Runner->>Runner: 触发 OnSubagentStop hook Runner-->>Tool: ToolOutput { content, metadata } Tool->>Tool: 包装: "[子代理研究结果]\n\n{content}" Tool-->>Lead: ToolOutput ``` ## 核心机制详解 ### 1. 上下文隔离 子代理拥有**全新的 `messages` 向量**,仅包含 system prompt + user prompt,不包含父代理的任何中间工具调用。父代理只会收到最终的文本摘要,中间的工具调用细节(搜索了什么、下载了什么)不会污染父上下文。 这是子代理系统最核心的价值——与直接在父代理中执行相比,子代理消耗的父上下文 token 是 O(1) 而不是 O(steps)。 ``` 父代理 context: 子代理 context: ┌─────────────────────┐ ┌─────────────────────────┐ │ system prompt │ │ system prompt │ │ user: "分析星系演化" │ │ user: "搜索星系演化的文献" │ │ assistant: tool_calls│ │ assistant: tool_call │ │ tool_result: [大文本] │ │ tool_result: [搜索结果] │ │ ... (越来越多) │ │ assistant: "找到 5 篇..." │ │ │ └─────────────────────────┘ │ ← 子代理结果注入这里 │ ↓ │ (仅有摘要,无中间步骤)│ 只返回文本摘要给父代理 └─────────────────────┘ ``` ### 2. 工具执行模式 — 串行 与父代理 `executor.rs` 使用 `FuturesUnordered` 的**并行**执行不同,子代理采用简单的 **for 循环串行**执行: ```rust // src/agent/subagent.rs:395 — 串行 for 循环 for tool_call in &tool_calls { let output = tool.execute(final_args, &tool_ctx).await; messages.push(ChatMessage::tool_result(&tool_call.id, &truncated)); } ``` | 特性 | 父代理 (executor.rs) | 子代理 (subagent.rs) | |:---|:---|:---| | 执行方式 | `FuturesUnordered` 并行 | `for` 循环串行 | | 结果推送 | 渐进式(快工具不等待慢工具) | 逐个完成 | | FileStateCache | 支持(避免重复读文件) | 不支持 | | 大结果持久化到磁盘 | `maybe_persist_tool_result` | 无(仅截断) | | Sibling Abort | 支持(出错时中断兄弟任务) | 不支持 | **设计考量**:子代理的 `max_steps` 通常为 5,任务规模较小,并行收益有限。串行实现更简单、更可预测,且每次工具调用之前会检查 PreToolUse hook 和权限,串行执行保证了 hook 决策的时序正确性。 ### 3. 死循环检测 子代理独立维护 `last_call: Option<(String, String)>` 追踪上次的工具调用(名称 + 参数)。当连续 3 次相同调用时触发拦截: ```rust // src/agent/subagent.rs:416-418 let call_key = (tool_name.clone(), tool_args_str.clone()); if last_call.as_ref() == Some(&call_key) { consecutive_count += 1; if consecutive_count >= duplicate_threshold { // threshold = 3 // 注入错误消息,强制 LLM 停止并给出答案 let error_msg = ChatMessage::tool_result( &tool_call.id, format!("工具 {} 被连续重复调用。请停止并给出当前收集到的答案。", tool_name), ); messages.push(error_msg); continue; // 跳过本次执行 } } else { last_call = Some(call_key); consecutive_count = 1; } ``` 这与父代理的 `DuplicateDetector` 功能相同,但实现更简洁——子代理的场景简单,不需要全局去重表。 ### 4. 上下文压缩 子代理的上下文在每步开始前检查,使用简单的字节长度估算: ```rust // src/agent/subagent.rs:261-265 let est_tokens: usize = messages .iter() .map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4) .sum(); if est_tokens > self.config.token_soft_limit * 3 / 2 { compact::compress_context( &mut messages, llm, config.token_soft_limit, config.max_messages, "subagent", ) .await; } ``` - 触发阈值:`token_soft_limit * 1.5`(默认 ~48000 tokens) - 压缩方式:调用 `compact::compress_context`(LLM 摘要压缩) - 标识来源:`"subagent"`,区别于 `"lead"` / `"teammate"` / `"background"` ### 5. force_final_answer — 达到最大步数时的兜底 ```rust // src/agent/subagent.rs:563-600 async fn force_final_answer(&self, llm: &LlmClient, messages: &[ChatMessage]) -> ToolOutput { let mut final_messages = messages.to_vec(); final_messages.push(ChatMessage::user( "请根据已收集的信息直接给出最终答案,不要再调用工具。", )); let empty_tools: Vec = Vec::new(); // 不提供任何工具 let mut stream_rx = llm.chat_stream(&final_messages, &empty_tools, ...).await?; // 收集 TextDelta 直到 Done } ``` - 追加一条 user 消息明确要求停止工具调用 - 传递**空的工具定义列表**,从 API 层面禁止工具调用 - 返回 `metadata: { "forced": true }` 标记这是强制答案 ### 6. 数据库持久化 所有子代理消息通过 `save_subagent_message` 写入 `agent_messages` 表,使用 fire-and-forget 模式(`tokio::spawn`): ```rust // src/agent/subagent.rs:190-235 fn save_subagent_message(&self, agent_name: &str, turn_index: i32, step_index: i32, role: &str, content: &str, ...) { let metadata = serde_json::json!({ "agent": agent_name, "is_subagent": true }); tokio::spawn(async move { let _ = sqlx::query( "INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, thought, tool_calls, tool_call_id, token_count, metadata, agent_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&session_id) .bind(turn_index) // 始终为 0 .bind(step_index) // 当前 step 编号 .bind(&role_owned) // system / user / assistant / tool .bind(&content_owned) // ... .bind(&metadata_str) // {"agent":"sub_xxx","is_subagent":true} .bind(&agent) // subagent_name .execute(&db).await; }); } ``` 持久化的消息类型: - **system** — 子代理系统提示词 - **user** — 研究任务描述 - **assistant** — 每步 LLM 响应(含 tool_calls JSON) - **tool** — 每个工具的执行结果(截断后) - **assistant** — 最终结果(step_index = max_steps + 1) 所有消息带 `{"is_subagent": true}` 元数据标记,`agent_name` 字段存放 `sub_` 名称,`turn_index` 始终为 0。 **设计考量**:fire-and-forget 意味着持久化失败不会阻塞子代理执行。代价是极端情况下(DB 连接断开)可能丢失审计数据。 ## Hook 集成 子代理在以下时机触发 Hook 事件: | 事件 | 触发位置 | 上下文数据 | 用途 | |:---|:---|:---|:---| | `OnSubagentStart` | `run()` Phase 1 | parent_session_id, subagent_name, prompt | 审计、指标初始化 | | `PreToolUse` | 每个工具执行前 | session_id="subagent", tool_name, args | 取消检查、参数修改 | | `PostToolUse` | 每个工具执行后 | session_id="subagent", output_content, is_error | 指标采集、输出修改 | | `OnSubagentStop` | `run()` Phase 4 | result_summary (前200字符), steps, is_error | 审计记录、数据汇总 | `PreToolUse` 和 `PostToolUse` 在子代理的工具执行循环中**直接内联实现**(`subagent.rs:444-521`),而非通过 `executor.rs` 的并行管道。这意味着: - PreToolUse 的 Block 操作 → 注入错误 tool_result 消息,跳过执行 - PreToolUse 的 MutateInput → 替换 final_args - PostToolUse 的 MutateOutput → 替换 final_output_content - Permission check → `is_denied` 直接拒绝 **与父代理的区别**:父代理通过 `executor::execute_parallel()` 统一处理 hooks + 并发,子代理在自身的 for 循环中手动调用 hook 方法。两者享有相同的 `HookRegistry` 实例(通过 `SubAgentRunner::new_with_hooks` 注入)。 ## SubAgentTool 工具层 ```rust // src/agent/tools/subagent.rs impl AgentTool for SubAgentTool { fn name(&self) -> &str { "subagent" } fn parameters(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "research_prompt": { "type": "string", "description": "要委托给子代理执行的完整研究任务描述..." }, "max_steps": { "type": "integer", "description": "子代理最大推理步数,默认5,最大10", "default": 5 } }, "required": ["research_prompt"] }) } // 子代理可能执行写操作,中断时应阻塞以完成 fn interrupt_behavior(&self) -> InterruptBehavior { InterruptBehavior::Block } } ``` `execute` 方法流程: 1. 参数校验(`research_prompt` 必填,`max_steps` 取 min(参数, 10)) 2. 构造子代理 `system_prompt`(天体物理学研究助手,中文回答) 3. 创建 `SubAgentRunner::new_with_hooks()` — 注入 hooks、permissions、SSE 通道、session_id 4. 调用 `runner.run(system_prompt, &research_prompt, max_steps)` 5. 错误 → `ToolOutput::error("子代理执行失败: ...")` 6. 成功 → 包装为 `"[子代理研究结果]\n\n{content}"` ## 与 Team Teammate 的对比 两者都实现了"将工作委托给独立的 ReAct 循环",但设计上有本质差异: | 特性 | SubAgentRunner | Team Teammate | |:---|:---|:---| | 文件位置 | `src/agent/subagent.rs` | `src/agent/team/teammate.rs` | | 触发方式 | LLM 调用 `subagent` 工具 | Lead 通过文件收件箱发送任务 | | 工具集 | 完整 ToolRegistry(含 subagent) | 排除 subagent(防无限委托链) | | Hook 管道 | 完整(PreToolUse/PostToolUse/Start/Stop) | 无 | | SSE 进度 | 支持(通过 progress_tx 透传) | 无 | | DB 持久化 | 完整(agent_messages + agent_audit_log) | 无 | | 运行模式 | 同步:父代理等待子代理完成 | 异步:循环轮询收件箱 | | 生命周期 | 一次性:任务完成即销毁 | 持续:SPAWN → WORKING → IDLE → SHUTDOWN | | 嵌套能力 | 可递归(无深度限制) | 不可(排除 subagent 工具) | | 上下文压缩 | 支持(compact::compress_context) | 支持(同) | | 死循环检测 | 支持(阈值=3) | 无独立检测 | | 最大步数 | 默认 5,最大 10 | `max_steps.min(5)` | | 取消支持 | 无独立取消信号 | `AtomicBool` 取消标志 | **使用场景区分**: - **SubAgent** — 同步的、一次性的、需要完整工具能力的子任务("综述近 5 年星系演化的文献") - **Teammate** — 异步的、持续性的、角色分工的并行协作("你负责搜索,他负责解析") ## 工具注册 子代理工具 `SubAgentTool` 在 `ToolRegistry::add_base_tools()` 中注册,随后被替换为带 hooks 的实例: ```rust // src/agent/runtime/mod.rs:264 // AgentRuntime 初始化时用带 hooks 的版本替换默认的 SubAgentTool tool_registry.replace_tool( crate::agent::tools::subagent::SubAgentTool::new_with_hooks( hook_registry.clone(), permission_checker.clone(), ), ); ``` 参数透传链路: ``` LLM 调用 subagent(research_prompt, max_steps) → SubAgentTool.execute(args, ToolContext) → SubAgentRunner::new_with_hooks(app_state, hooks, pchecker, ctx.sse_tx) .with_parent_session(ctx.session_id) .with_thinking(ctx.enable_thinking) .run(system_prompt, research_prompt, max_steps) ``` ## 外部集成场景 `SubAgentRunner` 除了通过 `SubAgentTool` 被父代理调用外,还被以下两个子系统使用: ### 1. Coordinator WorkerPool(P2) 协调者模式中,`WorkerPool::delegate()` 为每个委托任务创建一个 `SubAgentRunner` 实例: ``` CoordinatorAgent → delegate_task(task_description, context) → WorkerPool::delegate() → tokio::spawn(Worker) → SubAgentRunner::new_with_registry(app_state, 完整 ToolRegistry) .with_parent_session(session_id) .with_thinking(enable_thinking) .run(WORKER_SYSTEM_PROMPT, task_description, worker_max_steps) → 结果写入 CoordinatorTask.result → synthesize() → 收集所有已完成 Worker 结果 → 合成最终答案 ``` 关键特征: - Worker 子代理拥有**完整工具访问权限**(与 Coordinator 的 4 个元工具相对) - 通过 `Semaphore` 控制最大并发(默认 4) - 超时保护(默认 300s),超时后标记为 `TimedOut` - 结果通过共享 `CoordinatorTask` 结构返回,不经过 SSE ### 2. 压缩记忆提取(P3 桥接) `compact::extract_memories_from_compaction()` 创建仅含 `SaveMemoryTool` 的受限子代理: ``` AgentRuntime::compress_and_restore() → compress_context_with_hooks_and_log(...) → extract_memories_from_compaction(snapshot, sid, mgr, app_state) → SubAgentRunner::new_with_registry(app_state, SaveMemoryTool 仅此一个) .with_parent_session(sid) .with_thinking(false) .run(EXTRACTION_SYSTEM_PROMPT, prompt, max_steps=3) → 写入记忆(fire-and-forget,不阻塞 ReAct 循环) ``` 关键特征: - 工具集最小化(仅 `save_memory`)——只做记忆写入,不能读文件或搜索 - 思考模式关闭(`with_thinking(false)`)——降低提取成本 - 内容阈值保护:拼接文本 < 300 字符时跳过 - 与 `run_extraction()`(会话结束时触发)互为补充——详见 [memory.md](memory.md) ## 当前局限与改进方向 | # | 问题 | 影响 | 改进方向 | |:---|:---|:---|:---| | 1 | **工具串行执行** | 同一步多个工具调用无法并行,慢工具阻塞快工具 | ~~复用 executor~~ 已通过 Coordinator WorkerPool 在多 Worker 层面实现并行;单 Worker 内仍串行 | | 2 | **无嵌套深度限制** | 子代理可调用 subagent 创建子子代理,理论上无限递归 | 增加深度计数器,超过 2 层时移除 subagent 工具 | | 3 | **Token 估算粗糙** | `content.len() + 4` 对中文极不准确(中文 1 字符 ≈ 1.5-2 token) | 使用 `tiktoken-rs` 或 tokenizer 精确计数 | | 4 | **fire-and-forget 持久化** | DB 写入失败静默忽略,可能丢失审计数据 | 至少记录 warn 日志;关键消息可改为 await | | 5 | **force_final_answer 不调温度** | 达到 max_steps 时模型可能仍尝试输出工具调用格式 | 降低 temperature 或增加 stop 序列 | | 6 | **subagent_name 不可控** | 自动生成的 UUID 片段不利于日志可读性 | 允许 LLM 传入 `name` 参数作为标识 | | 7 | **无 FileStateCache** | 子代理反复读取同一文件时会重复 I/O | 传入 FileStateCache 或使用全局缓存 | | 8 | **步骤内消息顺序丢失** | 同一步的多个 tool 消息共享相同 step_index | 引入子序号(如 step_index.sub_index) | ---