新增: spawn_agent 子代理派发与级联取消

- 纯内存子会话隔离上下文,一轮回传结论报告
- 白名单钳制低风险并恒剔 spawn_agent 防递归
- 子代理策略下高风险自动拒绝,不挂审批防卡死
- 轮数令牌挂钟三重熔断
- 父停止与删会话级联取消子代理
- AiAgentFinished 事件挂父会话
This commit is contained in:
lxy
2026-10-01 19:35:21 +08:00
parent 524120b110
commit 2187ffe173
13 changed files with 1407 additions and 21 deletions
+97 -2
View File
@@ -96,6 +96,57 @@ pub(crate) use idea_source::maybe_fill_idea_source;
// 工具调用心跳 + 审批处理
// ============================================================
/// 工具执行策略(子代理 v1 防挂死的核心闸门)。
///
/// Normal:主对话既有流程(Low 自动执行 / Medium+High 进审批门控 / 路径授权挂起等人工介入)。
/// SubAgent:子代理无审批通道也无用户交互面,任何需要人工介入的路径(路径授权挂起/
/// 黑名单拒绝/Medium/High 风险)一律就地产出失败 tool_result,绝不 insert pending。
/// 挂起对主对话是「等用户」,对子代理是永久停摆(没人能审批它),故必须就地拒绝。
/// 调用方(spawn_agent 子循环)断言本策略下返回的 pending 恒为 0,非 0 即内部错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToolExecPolicy {
/// 主对话默认策略(行为与历史版本完全一致)。
Normal,
/// 子代理策略(需人工介入的路径全部就地拒绝,零挂起)。
SubAgent,
}
/// SubAgent 策略的就地拒绝单点:失败 tool_result(结构化 envelope,断路器可判)+
/// emit AiToolCallCompleted + 审计留痕(decided_by=sub_agent_denied)。
/// 不 insert pending_approvals(子代理无审批通道,挂起即永久停摆)。
async fn deny_sub_agent_tool(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
draft: &ToolCallDraft,
tools_arc: &Arc<AiToolRegistry>,
audit_repo: &AiToolExecutionRepo,
reason: &str,
current_message_id: Option<&str>,
app_handle: &AppHandle,
) {
let msg = serde_json::json!({
"status": "rejected",
"reason": "sub_agent_denied",
"message": format!("子代理无权执行该工具({}):仅低风险只读且路径已授权的工具可在子代理中自动执行,如确需该操作请写入报告交由主会话完成", reason)
}).to_string();
// 短 lock 段:push tool_result(纯写,无 await)
{
let __lock_t = std::time::Instant::now();
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg));
log_slow_lock(__lock_t, "process_tool_calls:deny_sub_agent");
}
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(msg.clone()),
conversation_id: Some(conv_id.to_string()),
};
emit_ai_chat_event(app_handle, ev);
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await;
}
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
/// 返回待审批的工具数量(0 = 全部自动执行完成)
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
@@ -159,7 +210,12 @@ async fn execute_with_heartbeat(
// run_command 分支从 args.timeout_secs 取值(clamp [60,600],默认 60,与 tool_registry.rs:2707
// run_command 内部 clamp 同源),其余工具仍 60s。run_command 内部还有自己的子 timeout,
// 外层只需 >= 内部 timeout 即不抢断(run_command 内部超时会返带语义的 tool_result,优于外层裸中止)。
let outer_secs: u64 = if name == "run_command" {
let outer_secs: u64 = if name == "spawn_agent" {
// 子代理:外层取子循环挂钟预算(SUB_AGENT_WALL_TIMEOUT_SECS)加缓冲。
// 外层必须晚于子循环自身三重熔断(轮次/token/挂钟)触发,让超预算的子代理
// 以截断报告优雅收尾,而非被外层硬腰斩成裸超时错误(报告蒸发)。
crate::commands::ai::agentic::sub_agent::SUB_AGENT_WALL_TIMEOUT_SECS + 60
} else if name == "run_command" {
args.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(60)
@@ -180,6 +236,12 @@ async fn execute_with_heartbeat(
None
};
let exec_fut = crate::commands::ai::command_stream::scope(sink, tools.execute(name, args));
// 父会话 task-local 注入:spawn_agent handler 经 caller_conv() 定位发起调用的父会话
// (直调防御兜底用)。与上方 command_stream 的 SINK scope 嵌套,两个 task-local 互不干扰。
let exec_fut = crate::commands::ai::tools::spawn_agent::scope_caller_conv(
conv_id.to_string(),
exec_fut,
);
match tokio::time::timeout(Duration::from_secs(outer_secs), exec_fut).await {
Ok(result) => result,
Err(_elapsed) => {
@@ -202,7 +264,10 @@ pub(crate) async fn process_tool_calls(
db: &Arc<Database>,
app_handle: &AppHandle,
conv_id: &str,
policy: ToolExecPolicy,
) -> usize {
// 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档)
let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent);
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
tc_list.sort_unstable_by_key(|(i, _)| *i);
// 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶,
@@ -335,6 +400,8 @@ pub(crate) async fn process_tool_calls(
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
// SubAgent 策略:须授权/黑名单拒绝统一收集,后续就地拒绝(绝不挂起)
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
for (idx, draft, args) in drafts {
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
@@ -342,6 +409,11 @@ pub(crate) async fn process_tool_calls(
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_auth_bypass) {
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
FileToolAuthOutcome::NeedsAuth(req) => {
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
if is_sub_agent {
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string()));
continue;
}
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
// 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。
// 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。
@@ -351,7 +423,13 @@ pub(crate) async fn process_tool_calls(
path_auth_pending.push((draft, args, req));
}
}
FileToolAuthOutcome::Denied(reason) => path_denied.push((draft, reason)),
FileToolAuthOutcome::Denied(reason) => {
if is_sub_agent {
sub_agent_path_denied.push((draft, reason));
} else {
path_denied.push((draft, reason));
}
}
}
}
let drafts = authorized_drafts;
@@ -388,6 +466,14 @@ pub(crate) async fn process_tool_calls(
).await;
}
// SubAgent 策略:须授权/黑名单拒绝就地产出失败 tool_result(零挂起,防子循环永久停摆)。
for (draft, reason) in sub_agent_path_denied {
deny_sub_agent_tool(
session_arc, conv_id, &draft, tools_arc, &audit_repo,
&reason, current_message_id, app_handle,
).await;
}
// Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
@@ -499,6 +585,15 @@ pub(crate) async fn process_tool_calls(
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
for (_, draft, args) in drafts {
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
// SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。
// 白名单钳制已使子代理 LLM 只见 Low 工具定义,此处拦的是幻觉调用非白名单工具的残面。
if is_sub_agent && risk_level != RiskLevel::Low {
deny_sub_agent_tool(
session_arc, conv_id, &draft, tools_arc, &audit_repo,
"工具风险等级超出子代理权限", current_message_id, app_handle,
).await;
continue;
}
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常