# 子代理系统 (`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.context_char_limit * 3 / 2 {
compact::compress_context(&mut messages, llm, config.context_char_limit, "subagent").await;
}
```
- 触发阈值:`context_char_limit * 1.5`(默认 ~24000 字符)
- 压缩方式:调用 `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)
```
## 当前局限与改进方向
| # | 问题 | 影响 | 改进方向 |
|:---|:---|:---|:---|
| 1 | **工具串行执行** | 同一步多个工具调用无法并行,慢工具阻塞快工具 | 复用 `executor::execute_parallel`,或至少对 `is_concurrency_safe()` 工具并行 |
| 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) |
---