新增: spawn_agent 子代理派发与级联取消
- 纯内存子会话隔离上下文,一轮回传结论报告 - 白名单钳制低风险并恒剔 spawn_agent 防递归 - 子代理策略下高风险自动拒绝,不挂审批防卡死 - 轮数令牌挂钟三重熔断 - 父停止与删会话级联取消子代理 - AiAgentFinished 事件挂父会话
This commit is contained in:
@@ -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 警告(非静默),命令照常
|
||||
|
||||
Reference in New Issue
Block a user