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:
+247
-35
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user