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 等变量说明
This commit is contained in:
fmq
2026-06-18 01:21:02 +08:00
parent 49784739fa
commit f6df9d8136
60 changed files with 9913 additions and 1844 deletions
+247 -35
View File
@@ -2,7 +2,7 @@
//
// 子代理运行器 — 上下文隔离子代理(参考 Claude Code s04 Subagents)。
//
// 父代理通过 delegate_research 工具将子任务委托给子代理执行。
// 父代理通过 subagent 工具将子任务委托给子代理执行。
// 子代理拥有:
// - 全新的 messages 上下文(不包含父代理的中间工具调用)
// - 完整的工具访问权限(与父代理共享 ToolRegistry
@@ -17,10 +17,9 @@ use tracing::{info, warn};
use super::compact;
use super::hooks::{
HookRegistry, PostToolUseContext, PreToolUseContext, SubagentStartContext,
SubagentStopContext,
HookRegistry, PostToolUseContext, PreToolUseContext, SubagentStartContext, SubagentStopContext,
};
use super::runtime::permission::PermissionChecker;
use super::runtime::permission::{PermissionChecker, PermissionResult};
use super::runtime::{AgentConfig, AgentStreamEvent};
use super::tools::{ToolContext, ToolOutput, ToolRegistry};
use crate::api::AppState;
@@ -37,6 +36,8 @@ pub struct SubAgentRunner {
permission_checker: Arc<PermissionChecker>,
/// 可选的进度发送器(用于向父代理报告中间步骤)
progress_tx: Option<UnboundedSender<AgentStreamEvent>>,
/// 父代理的会话 ID(用于子代理消息的数据库持久化)
parent_session_id: String,
}
impl SubAgentRunner {
@@ -50,6 +51,7 @@ impl SubAgentRunner {
hook_registry: None,
permission_checker: Arc::new(PermissionChecker::new()),
progress_tx: None,
parent_session_id: String::new(),
}
}
@@ -68,9 +70,22 @@ impl SubAgentRunner {
hook_registry,
permission_checker,
progress_tx,
parent_session_id: String::new(),
}
}
/// 设置父代理会话 ID(调用者应在 run 之前设置)
pub fn with_parent_session(mut self, session_id: String) -> Self {
self.parent_session_id = session_id;
self
}
/// 设置是否启用 LLM 思考模式
pub fn with_thinking(mut self, enable: bool) -> Self {
self.config.enable_thinking = enable;
self
}
/// 使用自定义 ToolRegistry 创建子代理运行器。
/// 用于受限场景(如记忆提取子代理仅需只读 + save_memory)。
pub fn new_with_registry(app_state: Arc<AppState>, tool_registry: ToolRegistry) -> Self {
@@ -81,6 +96,7 @@ impl SubAgentRunner {
hook_registry: None,
permission_checker: Arc::new(PermissionChecker::new()),
progress_tx: None,
parent_session_id: String::new(),
}
}
@@ -90,29 +106,51 @@ impl SubAgentRunner {
/// * `system_prompt` - 子代理的系统提示词
/// * `research_prompt` - 要执行的研究任务描述
/// * `max_steps` - 子代理最大推理步数(默认 5)
/// * `hook_registry` - 可选的 HookRegistry(用于触发子代理生命周期事件)
pub async fn run(
&self,
system_prompt: &str,
research_prompt: &str,
max_steps: usize,
) -> ToolOutput {
let subagent_name = "delegate_research";
let subagent_name = format!("sub_{}", &uuid::Uuid::new_v4().to_string()[..8]);
// OnSubagentStart hook
if let Some(ref registry) = self.hook_registry {
registry
.run_on_subagent_start(&SubagentStartContext {
parent_session_id: String::new(),
subagent_name: subagent_name.to_string(),
parent_session_id: self.parent_session_id.clone(),
subagent_name: subagent_name.clone(),
prompt: research_prompt.to_string(),
})
.await;
}
// 保存子代理的 system prompt + user prompt 到数据库
self.save_subagent_message(
&subagent_name,
0,
0,
"system",
system_prompt,
None,
None,
None,
);
self.save_subagent_message(
&subagent_name,
0,
1,
"user",
research_prompt,
None,
None,
None,
);
// 执行实际工作并捕获结果,以便触发 OnSubagentStop hook
let result = self
.run_inner(system_prompt, research_prompt, max_steps)
.run_inner(system_prompt, research_prompt, max_steps, &subagent_name)
.await;
let (is_error, result_summary) = if result.is_error {
(true, result.content.clone())
@@ -120,11 +158,23 @@ impl SubAgentRunner {
(false, result.content.chars().take(200).collect())
};
// 保存最终结果
self.save_subagent_message(
&subagent_name,
0,
max_steps as i32 + 1,
"assistant",
&result.content,
None,
None,
None,
);
if let Some(ref registry) = self.hook_registry {
registry
.run_on_subagent_stop(&SubagentStopContext {
parent_session_id: String::new(),
subagent_name: subagent_name.to_string(),
parent_session_id: self.parent_session_id.clone(),
subagent_name: subagent_name.clone(),
result_summary,
steps: max_steps,
is_error,
@@ -135,12 +185,62 @@ impl SubAgentRunner {
result
}
/// 保存子代理消息到 agent_messages 表
#[allow(clippy::too_many_arguments)]
fn save_subagent_message(
&self,
agent_name: &str,
turn_index: i32,
step_index: i32,
role: &str,
content: &str,
thought: Option<&str>,
tool_calls_json: Option<&str>,
tool_call_id: Option<&str>,
) {
if self.parent_session_id.is_empty() {
return;
}
let db = self.app_state.db.clone();
let session_id = self.parent_session_id.clone();
let agent = agent_name.to_string();
let role_owned = role.to_string();
let content_owned = content.to_string();
let thought_owned = thought.map(|s| s.to_string());
let tc_json = tool_calls_json.map(|s| s.to_string());
let tc_id = tool_call_id.map(|s| s.to_string());
let metadata = serde_json::json!({ "agent": agent, "is_subagent": true });
let metadata_str = serde_json::to_string(&metadata).unwrap_or_default();
tokio::spawn(async move {
let token_count = content_owned.len() as i32 / 4;
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)
.bind(step_index)
.bind(&role_owned)
.bind(&content_owned)
.bind(&thought_owned)
.bind(&tc_json)
.bind(&tc_id)
.bind(token_count)
.bind(&metadata_str)
.bind(&agent)
.execute(&db)
.await;
});
}
/// 实际执行逻辑(提取为内部方法以便 hook 包装)
async fn run_inner(
&self,
system_prompt: &str,
research_prompt: &str,
max_steps: usize,
subagent_name: &str,
) -> ToolOutput {
let llm = &self.app_state.llm;
let tool_defs = self.tool_registry.definitions();
@@ -177,7 +277,10 @@ impl SubAgentRunner {
}
// LLM 流式调用
let mut stream_rx = match llm.chat_stream(&messages, &tool_defs).await {
let mut stream_rx = match llm
.chat_stream(&messages, &tool_defs, self.config.enable_thinking)
.await
{
Ok(rx) => rx,
Err(e) => {
warn!("[SubAgent] LLM stream 失败: {}", e);
@@ -186,15 +289,40 @@ impl SubAgentRunner {
};
let mut accumulated_content = String::new();
let mut accumulated_reasoning = String::new();
let mut accumulated_tool_calls: Option<Vec<crate::clients::llm::ToolCall>> = None;
let mut activity_log: Vec<String> = Vec::new();
while let Some(event) = stream_rx.recv().await {
match event {
StreamEvent::ReasoningDelta(delta) => {
accumulated_reasoning.push_str(&delta);
// 转发子代理思考过程到父代理
if let Some(ref tx) = self.progress_tx {
let _ = tx.send(AgentStreamEvent::Thought {
content: format!("[子代理] {}", accumulated_reasoning),
step,
});
}
}
StreamEvent::TextDelta(delta) => {
accumulated_content.push_str(&delta);
}
StreamEvent::ToolCallsComplete(tool_calls) => {
accumulated_tool_calls = Some(tool_calls);
// 确保每个工具调用有唯一 ID(LLM 可能不返回 id)
let fixed_tool_calls: Vec<crate::clients::llm::ToolCall> = tool_calls
.into_iter()
.map(|tc| {
let id = if tc.id.is_empty() {
format!("call_{}", &uuid::Uuid::new_v4().to_string()[..8])
} else {
tc.id
};
activity_log.push(format!("🔧 调用工具: {}", tc.function.name));
crate::clients::llm::ToolCall { id, ..tc }
})
.collect();
accumulated_tool_calls = Some(fixed_tool_calls);
}
StreamEvent::Done => break,
StreamEvent::Error(e) => {
@@ -205,26 +333,38 @@ impl SubAgentRunner {
}
}
// 记录思考过程
if !accumulated_reasoning.is_empty() {
activity_log.push(format!(
"💭 思考: {}",
accumulated_reasoning.chars().take(300).collect::<String>()
));
}
// 无工具调用 = 最终回答
let tool_calls = match accumulated_tool_calls {
Some(ref tc) if !tc.is_empty() => tc.clone(),
_ => {
// 转发最终文本到父代理
// 转发子代理结论到父代理(作为 thought 显示在时间线,不污染 finalAnswer
if let Some(ref tx) = self.progress_tx {
let _ = tx.send(AgentStreamEvent::TextDelta {
content: format!(
"[子代理] {}",
accumulated_content.chars().take(200).collect::<String>()
),
let _ = tx.send(AgentStreamEvent::Thought {
content: accumulated_content.clone(),
step,
});
}
let content_len = accumulated_content.len();
info!("[SubAgent] 子代理完成,返回 {} 字符摘要", content_len);
let summary = format!(
"[子代理活动记录]\n\n{}\n\n[子代理结论]\n\n{}",
activity_log.join("\n"),
accumulated_content
);
return ToolOutput::success(
accumulated_content,
summary,
serde_json::json!({
"steps": step,
"content_length": content_len
"content_length": content_len,
"tool_calls": activity_log.iter().filter(|e| e.starts_with("🔧")).count(),
}),
);
}
@@ -240,6 +380,18 @@ impl SubAgentRunner {
None,
Some(tool_calls.clone()),
);
// 持久化 assistant 消息
let tc_json = serde_json::to_string(&tool_calls).unwrap_or_default();
self.save_subagent_message(
subagent_name,
0,
step as i32,
"assistant",
&accumulated_content,
None,
Some(&tc_json),
None,
);
messages.push(assistant_msg);
// 执行工具调用
@@ -272,10 +424,8 @@ impl SubAgentRunner {
let args: serde_json::Value = match serde_json::from_str(tool_args_str) {
Ok(v) => v,
Err(e) => {
let error_msg = ChatMessage::tool_result(
&tool_call.id,
format!("参数解析失败: {}", e),
);
let error_msg =
ChatMessage::tool_result(&tool_call.id, format!("参数解析失败: {}", e));
messages.push(error_msg);
continue;
}
@@ -284,6 +434,7 @@ impl SubAgentRunner {
// ── 向父代理发送进度事件 ──
if let Some(ref tx) = self.progress_tx {
let _ = tx.send(AgentStreamEvent::ToolCall {
id: tool_call.id.clone(),
name: format!("[sub] {}", tool_name),
arguments: args.clone(),
step,
@@ -316,6 +467,16 @@ impl SubAgentRunner {
&tool_call.id,
format!("工具 {} 被阻止: {}", tool_name, reason),
);
self.save_subagent_message(
subagent_name,
0,
step as i32,
"tool",
&format!("工具 {} 被阻止: {}", tool_name, reason),
None,
None,
Some(&tool_call.id),
);
messages.push(tool_msg);
continue;
}
@@ -325,15 +486,52 @@ impl SubAgentRunner {
args.clone()
};
// Permission check
if self.permission_checker.is_denied(tool_name) {
warn!("[SubAgent] 权限检查拒绝工具: {}", tool_name);
let tool_msg = ChatMessage::tool_result(
&tool_call.id,
format!("工具 {} 在子代理上下文中不可用(权限不足)", tool_name),
);
messages.push(tool_msg);
continue;
// Permission check — 完整三态检查(包含内容级匹配)
let perm_result = self.permission_checker.check(tool_name, Some(&final_args));
match perm_result {
PermissionResult::Denied { reason } => {
warn!("[SubAgent] 权限检查拒绝工具 {}: {}", tool_name, reason);
let err_msg =
format!("工具 {} 在子代理上下文中不可用: {}", tool_name, reason);
let tool_msg = ChatMessage::tool_result(&tool_call.id, &err_msg);
self.save_subagent_message(
subagent_name,
0,
step as i32,
"tool",
&err_msg,
None,
None,
Some(&tool_call.id),
);
messages.push(tool_msg);
continue;
}
PermissionResult::AskUser { .. } => {
// 子代理上下文中无用户可询问,自动拒绝
warn!(
"[SubAgent] 工具 {} 需要用户确认,子代理上下文中自动拒绝",
tool_name
);
let err_msg =
format!("工具 {} 需要用户确认但在子代理上下文中不可用", tool_name);
let tool_msg = ChatMessage::tool_result(&tool_call.id, &err_msg);
self.save_subagent_message(
subagent_name,
0,
step as i32,
"tool",
&err_msg,
None,
None,
Some(&tool_call.id),
);
messages.push(tool_msg);
continue;
}
PermissionResult::Allowed => {
// 继续执行
}
}
// 执行工具
@@ -374,6 +572,7 @@ impl SubAgentRunner {
if let Some(ref tx) = self.progress_tx {
let preview: String = final_output_content.chars().take(200).collect();
let _ = tx.send(AgentStreamEvent::ToolResult {
tool_call_id: tool_call.id.clone(),
name: format!("[sub] {}", tool_name),
output: preview,
is_error: output.is_error,
@@ -398,6 +597,16 @@ impl SubAgentRunner {
};
let tool_msg = ChatMessage::tool_result(&tool_call.id, &truncated);
self.save_subagent_message(
subagent_name,
0,
step as i32,
"tool",
&truncated,
None,
None,
Some(&tool_call.id),
);
messages.push(tool_msg);
}
}
@@ -415,7 +624,10 @@ impl SubAgentRunner {
));
let empty_tools: Vec<ToolDefinition> = Vec::new();
let mut stream_rx = match llm.chat_stream(&final_messages, &empty_tools).await {
let mut stream_rx = match llm
.chat_stream(&final_messages, &empty_tools, self.config.enable_thinking)
.await
{
Ok(rx) => rx,
Err(e) => {
return ToolOutput::error(format!("子代理最终答案生成失败: {}", e));