- AgentConfig: 移除 10+ 个环境变量读取,仅保留 TOKEN_SOFT/HARD_LIMIT 两个
可调参数,context_char_limit 替换为统一的 token_soft_limit 阈值
- compact: find_safe_cut_point 重写为 HashSet O(n) 算法,
micro_compact 改为不可变风格,compress_context 签名升级为
token_soft_limit + max_messages 双参数,新增 COMPACTION_OUTPUT_RESERVE
- modes: ModeConfig.max_steps/tool_timeout_secs 去 Optional 化,
Deep Research 步数 16→100,Literature Reader 步数 6→25
- dashboard: 提取 AgentMarkdown/ThoughtCard/ToolCallCard/AnswerCard/
SubAgentContainer 等共享组件,ResearchAgentPanel 大幅瘦身,
交互卡片重构为 console-panel 紧凑风格
- security: 移除 HERMES_YOLO_MODE、AGENT_BLOCK_NETWORK 开关、
AGENT_CHECKPOINT_ENABLED 开关,关键安全机制强制启用
19 KiB
子代理系统 (subagent.rs + tools/subagent.rs)
参考 Claude Code s04 Subagents 设计,实现上下文隔离的子代理运行器。父代理通过 subagent 工具将独立子任务委托给子代理执行,子代理拥有全新的消息上下文和完整的 ReAct 循环,仅将最终文本摘要返回给父代理。
核心价值:防止父代理上下文被中间工具调用污染,同时让子代理具备完整的工具能力(文献搜索、下载、RAG 检索、笔记等)来独立完成子任务。
架构总览
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["字段:<br/>• app_state: Arc<AppState><br/>• config: AgentConfig<br/>• tool_registry: ToolRegistry (与父代理共享)<br/>• hook_registry: Option<Arc<HookRegistry>><br/>• permission_checker: Arc<PermissionChecker><br/>• progress_tx: Option<UnboundedSender><br/>• parent_session_id: String"]
R_ctors["三种构造方式:<br/>① new() — 最简构造<br/>② new_with_hooks() — 完整构造<br/>③ new_with_registry() — 自定义工具集"]
R_chain["链式配置:<br/>• with_parent_session()<br/>• with_thinking()"]
end
完整执行流程
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_<uuid8>"
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 循环串行执行:
// 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 次相同调用时触发拦截:
// 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. 上下文压缩
子代理的上下文在每步开始前检查,使用简单的字节长度估算:
// 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 — 达到最大步数时的兜底
// 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<ToolDefinition> = 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):
// 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_<uuid8> 名称,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 工具层
// 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 方法流程:
- 参数校验(
research_prompt必填,max_steps取 min(参数, 10)) - 构造子代理
system_prompt(天体物理学研究助手,中文回答) - 创建
SubAgentRunner::new_with_hooks()— 注入 hooks、permissions、SSE 通道、session_id - 调用
runner.run(system_prompt, &research_prompt, max_steps) - 错误 →
ToolOutput::error("子代理执行失败: ...") - 成功 → 包装为
"[子代理研究结果]\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 的实例:
// 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
当前局限与改进方向
| # | 问题 | 影响 | 改进方向 |
|---|---|---|---|
| 1 | 工具串行执行 | 同一步多个工具调用无法并行,慢工具阻塞快工具 | |
| 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) |