- AgentConfig/LlmClient 新增 enable_thinking 参数,前端 SSE 请求传递 thinking 开关,仅千问/DashScope 时启用 - 完善权限系统,支持细粒度的权限控制和用户权限申请 - delegate_research 工具重命名为 subagent,SubAgentTool/SubAgentRunner 重构 - 子代理消息(system/user/assistant/tool)持久化到 agent_messages 表,带 agent_name 标识 - 子代理活动日志(工具调用列表+思考摘要)注入返回结果,Hooks 获得正确 session_id 和 subagent_name - LLM 工具调用 ID 回退生成 UUID(llm.rs),ToolCall/ToolResult SSE 事件增加 id/tool_call_id 双字段 - ToolContext 扩展 session_id/sse_tx/enable_thinking 字段,executor 统一注入而非构造函数传参 - agent_messages 新增 metadata+raw_json 列,agent_sessions 暴露 summary 字段 - 删除文件级 transcript 快照(compact.rs),改为依赖 DB 持久化 - ResearchAgentPanel 重写:TimelineItem 类型替代 StreamStep,支持会话历史回放 - 新增 AgentMetricsPanel/AskUserQuestionCard/AuditLogViewer 三个前端组件,types.ts 完整类型定义 - docs/architecture/ 分层重组:概览/核心模块/核心工作流 + agent/ 子目录 11 篇专题文档 - docs/api.md 补充 RAG/Target/Agent 接口,docs/development.md 新建开发指南 - .env.example 完全重写,补充 FALLBACK_MODEL 等变量说明
365 lines
17 KiB
Markdown
365 lines
17 KiB
Markdown
# 子代理系统 (`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["字段:<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
|
||
```
|
||
|
||
## 完整执行流程
|
||
|
||
```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_<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 循环串行**执行:
|
||
|
||
```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<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`):
|
||
|
||
```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_<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 工具层
|
||
|
||
```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) |
|
||
|
||
---
|
||
|