AstroResearch/docs/architecture/agent/subagent.md
Asfmq f6df9d8136 feat: Agent 思考模式前端可控、子代理全链路持久化、权限系统、工具 ID 追踪体系、前端面板与文档架构重构
- 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 等变量说明
2026-06-18 01:21:02 +08:00

17 KiB
Raw Blame History

子代理系统 (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&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

完整执行流程

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.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_contextLLM 摘要压缩)
  • 标识来源:"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 审计记录、数据汇总

PreToolUsePostToolUse 在子代理的工具执行循环中直接内联实现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 方法流程:

  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 — 异步的、持续性的、角色分工的并行协作("你负责搜索,他负责解析"

工具注册

子代理工具 SubAgentToolToolRegistry::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)

当前局限与改进方向

# 问题 影响 改进方向
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