AstroResearch/docs/architecture/agent/subagent.md
Asfmq cec4b8cf7b feat: Docker 容器化、Cookie 鉴权、Coordinator 编排、FTS5 搜索与 P1-P3 全面收尾
Docker 容器化部署
  - 提供 Mode A (Alpine musl, ~23MB) 和 Mode B (Distroless glibc, ~87MB)
    两种镜像,Docker Compose 一键启动
  - build.rs 支持 SKIP_DASHBOARD_BUILD 跳过前端构建
  - 国内镜像加速 (npm/apt/apk) 通过 USE_MIRRORS build-arg 控制

  安全:Cookie-Based 鉴权系统
  - HttpOnly/SameSite=Strict Cookie 会话管理(24h 过期自动清理)
  - 登录/登出/验证接口 + 中间件注入
  - 前端登录页面 + 退出按钮
  - 三层 CORS:localhost 鉴权 / 全放通 bookmarklet / 受保护路由
  - 书签脚本 fetch 添加 credentials:'include'

  Coordinator 模式 (P2)
  - 4 个 meta-tool (delegate_task/check_task/task_stop/synthesize)
  - WorkerPool + Semaphore 并发控制 + 超时保护
  - 前端协调者模式开关

  Hook 系统:UserPromptSubmit 事件 (P2)
  - 第 13 个生命周期事件,fire-and-forget 审计

  FTS5 全文搜索 (P3)
  - agent_sessions_fts + agent_messages_fts 虚拟表
  - search_history Agent 工具 + /api/search/history HTTP 接口
  - 前端防抖搜索框 + 仅当前会话筛选

  工具加载优化 (P3)
  - defer_loading 延迟加载 (7 个重型工具)
  - is_readonly 只读标记 (9 个查询工具)
  - classifier_summary 工具目录供 LLM 按需判断

  模型回退策略 (P3)
  - LLM_FALLBACK_MODEL 优先回退 + LLM_FALLBACK_CHAIN 链式轮换
  - LlmClient model 改为 Arc<RwLock> 支持运行时切换
  - 连续 3 次过载后自动切换

  压缩记忆桥接 (P3)
  - 压缩丢弃消息 → 子代理提取持久记忆 (extract_memories_from_compaction)

  git2 依赖修复
  - 切换到 vendored-libgit2,消除 OpenSSL 系统依赖
2026-06-23 20:22:06 +08:00

414 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 子代理系统 (`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&lt;AppState&gt;<br/>• config: AgentConfig<br/>• tool_registry: ToolRegistry (与父代理共享)<br/>• hook_registry: Option&lt;Arc&lt;HookRegistry&gt;&gt;<br/>• permission_checker: Arc&lt;PermissionChecker&gt;<br/>• progress_tx: Option&lt;UnboundedSender&gt;<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)
```
## 外部集成场景
`SubAgentRunner` 除了通过 `SubAgentTool` 被父代理调用外,还被以下两个子系统使用:
### 1. Coordinator WorkerPoolP2
协调者模式中,`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 |
---