优化: agentic防护与删除审批子代理域

- 循环主体 catch_unwind 防护,panic 走正典终态不再卡死
- 会话删除停 in-flight 循环+删除侧单点补发终态
- 审批超时到点主动收敛,执行等待循环加删除判死
- 子代理出口整轨迹落库,拒绝工具结构化+报告协议
- 压缩会话 id 修正,begin_turn 喂用户原文
- 远程桥会话列表按 origin 过滤子会话,新增中断续跑命令
This commit is contained in:
lxy
2026-10-03 15:35:08 +08:00
parent c1e5b96d75
commit d406a31722
12 changed files with 1139 additions and 306 deletions
+252 -14
View File
@@ -114,20 +114,40 @@ pub(crate) enum ToolExecPolicy {
/// SubAgent 策略的就地拒绝单点:失败 tool_result(结构化 envelope,断路器可判)+
/// emit AiToolCallCompleted + 审计留痕(decided_by=sub_agent_denied)。
/// 不 insert pending_approvals(子代理无审批通道,挂起即永久停摆)。
///
/// S2 拒绝可见性:envelope 增 tool_name/risk_level/denial_layer 结构化字段——拒绝不只
/// 「说不」,子 LLM 须能从结构化信息推断可改尝试方向(换 Low 工具/换路径/转交主会话)。
/// denial_layer 区分四层拒绝源,各层行动提示不同:
/// - whitelist-clamp:工具不在白名单(spawn 入口裁剪或幻觉调用)
/// - needs-auth:路径待授权(主会话可授权放行)
/// - path-blacklist:系统黑名单(主会话也放不了行,从 needs-auth 拆出防误导授权尝试)
/// - medium-high-policy:中高风险策略(拆低风险步骤或转交主会话审批)
async fn deny_sub_agent_tool(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
draft: &ToolCallDraft,
tools_arc: &Arc<AiToolRegistry>,
audit_repo: &AiToolExecutionRepo,
layer: &str,
reason: &str,
current_message_id: Option<&str>,
app_handle: &AppHandle,
) {
// 各拒绝层的可改尝试方向(行动提示,让子 LLM 下一步有路可走而非反复重试同一拒绝)
let hint = match layer {
"whitelist-clamp" => "该工具不在你的白名单(spawn 时被裁剪或不存在),改用白名单内的低风险工具完成等价操作",
"needs-auth" => "该路径主会话可经授权放行,在报告未尽事项中说明,由主会话授权后执行",
"path-blacklist" => "该路径系统级禁用,主会话授权也无法放行,须改用其他路径或放弃该操作",
_ => "中高风险工具子代理不可执行,可拆解为低风险步骤,或在报告未尽事项中说明由主会话审批执行",
};
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
let msg = serde_json::json!({
"status": "rejected",
"reason": "sub_agent_denied",
"message": format!("子代理无权执行该工具({}):仅低风险只读且路径已授权的工具可在子代理中自动执行,如确需该操作请写入报告交由主会话完成", reason)
"tool_name": draft.name,
"risk_level": risk_level,
"denial_layer": layer,
"message": format!("子代理无权执行该工具({}):仅低风险只读且路径已授权的工具可在子代理中自动执行。{}", reason, hint)
}).to_string();
// 短 lock 段:push tool_result(纯写,无 await)
{
@@ -142,7 +162,6 @@ async fn deny_sub_agent_tool(
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;
}
@@ -157,7 +176,12 @@ async fn deny_sub_agent_tool(
/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
/// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。
async fn execute_with_heartbeat(
///
/// 审批执行窗口(ai_approve / ai_authorize_dir)复用本函数:审批暂停时主 loop 已 return、
/// 其 heartbeat_loop 随作用域退出,慢工具(大文件 patch / spawn_agent / run_command 长构建)
/// 整窗无心跳 → 前端看门狗误杀。复用同时获得 run_command 实时流式 sink、spawn_agent 父会话
/// task-local 注入与 outer_secs 分档超时(spawn_agent=挂钟预算+60 / run_command=timeout_secs clamp)。
pub(crate) async fn execute_with_heartbeat(
tools: &AiToolRegistry,
name: &str,
args: serde_json::Value,
@@ -242,9 +266,44 @@ async fn execute_with_heartbeat(
conv_id.to_string(),
exec_fut,
);
match tokio::time::timeout(Duration::from_secs(outer_secs), exec_fut).await {
Ok(result) => result,
Err(_elapsed) => {
// ── 提前退出监视(防空转):会话已删 / 审批窗口失效时中止工具执行 ──
// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
// ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
// 长工具最多再跑 2s 即中止,不再空转到 outer_secs 上限。
let tc_watch = tool_call_id.to_string();
let was_pending_at_entry = {
let st = app.state::<crate::state::AppState>();
let session = st.ai_session.lock().await;
session.pending_approvals.contains_key(&tc_watch)
};
let watch_app = app.clone();
let watch_conv = conv_id.to_string();
let abort_watch = async move {
loop {
// 2s 轮询:短锁纯读(锁不跨 await 持有);中止延迟 ≤2s 与锁竞争频率的权衡
tokio::time::sleep(Duration::from_secs(2)).await;
let st = watch_app.state::<crate::state::AppState>();
let session = st.ai_session.lock().await;
if session.deleted_convs.contains(&watch_conv) {
return format!("会话 {} 已删除", watch_conv);
}
if was_pending_at_entry && !session.pending_approvals.contains_key(&tc_watch) {
return format!("审批 {} 已不在挂起表", tc_watch);
}
}
};
tokio::pin!(exec_fut);
// 三路 select:exec 正常完成 / outer_secs 兜底超时(既有语义,drop exec 腰斩) /
// 监视判死提前退出(同样 drop exec,防审批被删或会话已删后空转到超时上限)。
// exec 先完成时 select 丢弃 abort_watch,监视零泄漏(非 spawn,随 select 作用域消亡)。
tokio::select! {
result = &mut exec_fut => result,
_ = tokio::time::sleep(Duration::from_secs(outer_secs)) => {
tracing::error!(
conv_id = %conv_id,
tool = %name,
@@ -252,11 +311,178 @@ async fn execute_with_heartbeat(
"[ai] 工具执行超时({}s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)",
outer_secs
);
Err(anyhow::anyhow!("工具执行超时({}s),已中止(防死锁)", outer_secs))
Err(anyhow::anyhow!("工具 {} 执行超时({}s),已中止(防死锁)", name, outer_secs))
}
reason = abort_watch => {
tracing::warn!(
conv_id = %conv_id,
tool = %name,
tool_call_id = %tool_call_id,
"[ai] execute_with_heartbeat 提前退出: {reason}(防空转)"
);
Err(anyhow::anyhow!("工具 {} 提前中止: {}", name, reason))
}
}
}
// ============================================================
// 审批超时主动收敛(定时器) — 治「审批过期后用户不操作则永挂」
// ============================================================
//
// 原状:cleanup_expired_approvals(私有于 agentic 模块)仅在 try_continue_agent_loop 入口惰性
// 触发,用户不操作则审批永挂(前端无终态、审批弹窗吊死)。此处补登记侧定时器:审批插入后
// (process_tool_calls 收尾统一扫描武装)spawn tokio 定时任务,到点主动执行与
// cleanup_expired_approvals 语义一致的取消动作 + emit AiApprovalResult 终态。
//
// 模块可见性说明:approval_timeout 模块私有于 agentic(纯 `mod` 声明),audit 不可达;
// 文件约束(不动 agentic/mod.rs,他代理在改)下定时器与到点收敛落调用侧,
// cancel_expired_approval 语义镜像 approval_timeout::cleanup_expired_approvals,
// 两端需同步维护(改超时取消语义时两处同改)。
/// 已武装超时定时器的审批 tool_call_id 集合(进程级,防重复 spawn)。
/// 挂起审批在 pending 期间会被每轮 process_tool_calls 的收尾扫描反复见到(启动恢复的积压审批
/// 尤其跨多轮存留),靠此集合保证同 id 只 spawn 一次。只增不清:tool_call_id 一轮唯一,
/// 审批移除后残留条目仅少量内存无行为影响,进程退出即释放。
static ARMED_TIMEOUT_IDS: std::sync::OnceLock<std::sync::Mutex<HashSet<String>>> =
std::sync::OnceLock::new();
/// 为当前所有待审批武装「到点主动收敛」定时任务。
///
/// 幂等:ARMED_TIMEOUT_IDS 去重,同一 tool_call_id 只 spawn 一次。
/// 全量扫描(不按 conv 过滤):启动恢复的积压审批也能在任意一轮 process_tool_calls 被补武装。
/// 跳过:conversation_id=None(无主审批,cleanup 按 conv 路由无法定位)与 created_at=None
/// (异常数据,超时判定恒 false,定时任务纯浪费)。
pub(crate) async fn arm_approval_timeout_timers(app: &AppHandle) {
let timeout_minutes = app
.state::<crate::state::AppState>()
.approval_timeout_minutes
.load(std::sync::atomic::Ordering::SeqCst);
if timeout_minutes == 0 {
return; // 0=禁用超时:不武装,与 cleanup_expired_approvals 禁用语义一致
}
let timeout_dur = std::time::Duration::from_secs(timeout_minutes * 60);
// 短锁快照 + 标武装(锁内纯读写,无 await 持锁跨 Await)
let to_arm: Vec<(String, String, std::time::SystemTime)> = {
let state = app.state::<crate::state::AppState>();
let session = state.ai_session.lock().await;
let mut armed = ARMED_TIMEOUT_IDS.get_or_init(|| std::sync::Mutex::new(HashSet::new()))
.lock()
.unwrap_or_else(|e| e.into_inner());
session
.pending_approvals
.values()
.filter_map(|a| {
let cid = a.conversation_id.as_deref()?;
let created = a.created_at?;
// insert 返 false = 已武装过,跳过(不重复 spawn)
if !armed.insert(a.tool_call_id.clone()) {
return None;
}
Some((cid.to_string(), a.tool_call_id.clone(), created))
})
.collect()
};
for (conv_id, tc_id, created) in to_arm {
let app_task = app.clone();
tokio::spawn(async move {
// 首睡 = 登记时点距过期的差额 + 2s 缓冲(created_at 读取早于 spawn,
// 补缓冲保到点时 elapsed 判定必过线,免得到点差几毫秒被判未过期而空转)。
let mut sleep_for = timeout_dur
.saturating_sub(created.elapsed().unwrap_or_default())
+ std::time::Duration::from_secs(2);
loop {
tokio::time::sleep(sleep_for).await;
let state = app_task.state::<crate::state::AppState>();
// 幂等/防泄漏闸门(短锁纯读):
// 会话已删或 per_conv 已不在 → 静默退场,不复活会话、不 emit 幽灵终态;
// 审批已不在 pending(用户已处理/他路已清理)→ 终态已发,静默返回防重复 emit。
{
let session = state.ai_session.lock().await;
if session.deleted_convs.contains(&conv_id)
|| !session.per_conv.contains_key(&conv_id)
{
return;
}
if !session.pending_approvals.contains_key(&tc_id) {
return;
}
}
// 到点精判读最新配置:兼容运行中调大超时(未过期则补睡差额重查;调小/不变必已过期)
let cur_minutes = state
.approval_timeout_minutes
.load(std::sync::atomic::Ordering::SeqCst);
if cur_minutes == 0 {
return; // 中途禁用超时:放弃主动收敛(禁用语义=永不超时)
}
let created_at = {
let session = state.ai_session.lock().await;
session.pending_approvals.get(&tc_id).and_then(|a| a.created_at)
};
let Some(created_at) = created_at else {
return; // 异常:条目存在却无 created_at,超时判定恒 false,退场交回惰性路径
};
let elapsed = std::time::SystemTime::now()
.duration_since(created_at)
.unwrap_or_default();
let cur_dur = std::time::Duration::from_secs(cur_minutes * 60);
if elapsed >= cur_dur {
cancel_expired_approval(&app_task, state.inner(), &conv_id, &tc_id, cur_minutes).await;
return;
}
sleep_for = cur_dur - elapsed + std::time::Duration::from_secs(2);
}
});
}
}
/// 到点取消单个超时审批:语义镜像 approval_timeout::cleanup_expired_approvals 的单条处理路径
/// (移除 pending → 替换占位 tool_result → emit AiApprovalResult 终态 → 审计 rejected → 落库)。
/// 入参均为已判定过期(调用方闸门后),失败不抛错仅随各步内部降级,避免影响其他审批。
async fn cancel_expired_approval(
app: &AppHandle,
state: &crate::state::AppState,
conv_id: &str,
tc_id: &str,
timeout_minutes: u64,
) {
tracing::info!(
conv_id = %conv_id,
tool_call_id = %tc_id,
timeout_minutes,
"[APPROVAL-TIMEOUT] 定时器到点,主动取消挂起审批"
);
// 1+2. 短锁:从 pending 移除 + 替换占位 tool_result(让 LLM 看到回执不再盲猜)
{
let mut session = state.ai_session.lock().await;
session.pending_approvals.remove(tc_id);
session
.conv(conv_id)
.messages
.replace_tool_result_content(tc_id, &format!("审批超时({}分钟)已自动取消,请改换其他方案", timeout_minutes));
}
// 3. emit AiApprovalResult(前端清除 pending UI,emit+publish 双写经正典 helper)
emit_ai_chat_event(
app,
AiChatEvent::AiApprovalResult {
id: tc_id.to_string(),
approved: false,
conversation_id: Some(conv_id.to_string()),
},
);
// 4. 审计落 rejected(审计表有终态,ai_approve 幂等分支可据此收敛前端残留)
audit_finalize(
state,
tc_id,
"rejected",
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
)
.await;
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
super::conversation::save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
}
pub(crate) async fn process_tool_calls(
session_arc: &Arc<Mutex<AiSession>>,
tool_calls_acc: HashMap<u32, ToolCallDraft>,
@@ -400,8 +626,10 @@ 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();
// SubAgent 策略:须授权/黑名单拒绝统一收集,后续就地拒绝(绝不挂起)。
// 第三元=denial_layer:needs-auth(待授权)与 path-blacklist(系统黑名单)分开标注——
// 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String, &'static str)> = Vec::new();
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
for (idx, draft, args) in drafts {
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
@@ -411,7 +639,7 @@ pub(crate) async fn process_tool_calls(
FileToolAuthOutcome::NeedsAuth(req) => {
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
if is_sub_agent {
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string()));
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth"));
continue;
}
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
@@ -425,7 +653,7 @@ pub(crate) async fn process_tool_calls(
}
FileToolAuthOutcome::Denied(reason) => {
if is_sub_agent {
sub_agent_path_denied.push((draft, reason));
sub_agent_path_denied.push((draft, reason, "path-blacklist"));
} else {
path_denied.push((draft, reason));
}
@@ -467,10 +695,10 @@ pub(crate) async fn process_tool_calls(
}
// SubAgent 策略:须授权/黑名单拒绝就地产出失败 tool_result(零挂起,防子循环永久停摆)。
for (draft, reason) in sub_agent_path_denied {
for (draft, reason, layer) in sub_agent_path_denied {
deny_sub_agent_tool(
session_arc, conv_id, &draft, tools_arc, &audit_repo,
&reason, current_message_id, app_handle,
layer, &reason, current_message_id, app_handle,
).await;
}
@@ -588,9 +816,12 @@ pub(crate) async fn process_tool_calls(
// SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。
// 白名单钳制已使子代理 LLM 只见 Low 工具定义,此处拦的是幻觉调用非白名单工具的残面。
if is_sub_agent && risk_level != RiskLevel::Low {
// 已注册工具=中高风险策略拒绝;未注册名(risk 兜底 High)=白名单外幻觉调用,
// 标 whitelist-clamp 让子 LLM 知道该工具不在其可见定义里,而非风险不让用。
let layer = if tools_arc.get(&draft.name).is_some() { "medium-high-policy" } else { "whitelist-clamp" };
deny_sub_agent_tool(
session_arc, conv_id, &draft, tools_arc, &audit_repo,
"工具风险等级超出子代理权限", current_message_id, app_handle,
layer, "工具风险等级超出子代理权限", current_message_id, app_handle,
).await;
continue;
}
@@ -611,6 +842,13 @@ pub(crate) async fn process_tool_calls(
).await;
}
// ── 审批超时主动收敛:为本轮登记(含历史遗留)的挂起武装到点定时任务 ──
// 本函数是审批插入的唯一漏斗(路径授权挂起 + Med/High 风险审批都在上方循环落 pending),
// 收尾统一扫描武装,覆盖两类登记。惰性清理只在用户下次交互时生效,用户不操作则审批
// 永挂(前端无终态、弹窗吊死);到点由定时任务主动 emit AiApprovalResult 终态收敛前端。
// 内部幂等(armed 集合去重 + 会话已删/审批已处理静默跳过),重复调用无副作用。
arm_approval_timeout_timers(app_handle).await;
// trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,
// 调用方不再持锁,本段所有 await 完全在锁外),
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit)。对齐 Low risk 并行模式。