//! 子代理循环(spawn_agent v1) — 纯内存子会话 + 拼装既有积木的独立 agentic 循环。 //! //! 架构定稿:子代理 = 纯内存子会话,不落库、不进侧栏列表、无标题/知识提炼、无终态事件, //! 最终轮文本即报告经返回值交付(Claude Code Task 语义同构:隔离上下文、一轮回传报告)。 //! //! ## 零落库机制(单点复用) //! 子会话 conv_id 启动即写入 `AiSession.deleted_convs`,`save_conversation_inner` 入口检测 //! 到该集合命中即跳过全部 DB 写入。既有路径(含 stream_round 异常分支内的兜底 save)对该 //! conv 一律不产生落库,无需给共享链路加子代理分支。 //! //! ## 循环拼装(全部复用既有积木,不另起炉灶) //! - provider 解析:AiProviderRepo.list_all + ProviderPool::select(override 亲和)+ 候选链; //! - 消息构建:ContextManager::build_for_request(预算裁剪)+ namespace 展开 + 占位配对断言; //! - 流式调用:stream_round(conv_id 全参数化,candidate 链 + 重试退避 + MidStream 保文); //! - 工具执行:process_tool_calls(ToolExecPolicy::SubAgent 策略,见 audit/mod.rs); //! - 熔断:L1 失败断路器与 G2 探索熔断读 per_conv 尾部消息,子循环内天然生效。 //! //! ## 三重熔断 + 级联取消 //! - max_rounds(默认 6,spawn 参数可调,上限钳制)/ 累计 token 上限 / 挂钟超时,任一触发截断; //! - 每轮停止检查点兼查:own_stop || 父 stop_flag || 父 per_conv 条目已不存在 //! (父侧 ai_chat_stop / ai_chat_force_send / ai_conversation_delete 经 SubAgentRegistry 级联)。 //! //! ## 事件与展示边界 //! 仅在结束时挂父会话 emit 一条 AiAgentFinished(前端展示二期消费);不 emit AiCompleted / //! AiMaxRounds 终态事件、不 spawn 标题/知识提炼。stream_round 复用带来的残留:其 Fatal 分支 //! 会向子 conv emit AiError、MidStream 分支会触发一次子 conv 的后台标题任务(写库被上者拦, //! 仅一次无效 LLM 调用),均为网络异常罕见路径,接受并记录于此。 use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tauri::AppHandle; use tokio::sync::Mutex; use df_ai::ai_tools::AiToolRegistry; use df_ai::context::{ContextManager, TokenEstimator}; use df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED; use df_ai::provider::{ChatMessage, MessageRole, ToolDefinition}; use df_ai::router::{Modality, TaskRequirements}; use df_storage::crud::AiProviderRepo; use df_storage::db::Database; use crate::commands::ai::agentic::{ CIRCUIT_BREAKER_ENABLED, CIRCUIT_BREAKER_THRESHOLD, DEFAULT_MAX_AGENT_RETRIES, STALL_BREAKER_ENABLED, }; use crate::commands::ai::audit::{process_tool_calls, ToolExecPolicy}; use crate::commands::ai::conversation::{truncate_for_persist, TokenAccumulator}; use crate::commands::ai::event_bus::emit_ai_chat_event; use crate::commands::ai::AiChatEvent; use crate::state::LlmConcurrency; use super::guard::GeneratingGuard; use crate::commands::ai::provider_pool::ProviderPool; use super::router_setup::resolve_model_with_override; use super::{ check_stall_breaker, count_recent_failures, push_assistant_message, stream_round, summarize_tool_results, tokens_snapshot, RoundOutcome, }; // ============================================================ // 预算常量(三重熔断) // ============================================================ /// 子代理默认最大工具循环轮数(spawn 参数 max_rounds 缺省值,透传可调)。 pub(crate) const SUB_AGENT_DEFAULT_MAX_ROUNDS: u32 = 6; /// max_rounds 入口钳制上限(防 spawn 参数放大预算失控)。 pub(crate) const SUB_AGENT_MAX_ROUNDS_CAP: u32 = 20; /// 累计 token 上限(prompt+completion 总量,超即截断退出)。 /// 对齐主对话 64 万 prompt 实证事故的量级收敛:子代理预算显著小于主会话。 pub(crate) const SUB_AGENT_MAX_TOTAL_TOKENS: u64 = 200_000; /// 挂钟超时(整个子循环,含 LLM 流式 + 工具执行)。 /// 与审批超时默认值(15 分钟)同量级:父侧 ai_approve / execute_with_heartbeat 的 /// 外层超时对 spawn_agent 取同值,保证外层不先于子循环自腰斩。 pub(crate) const SUB_AGENT_WALL_TIMEOUT_SECS: u64 = 900; // ============================================================ // 结果类型 // ============================================================ /// 子代理终态。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SubAgentStatus { /// 收敛轮正常结束(末轮无 tool_calls,full_text 即报告) Completed, /// 任一熔断/取消/流中断截断(报告为末轮文本或可读说明) Truncated, /// 启动失败或运行期致命错误(报告为错误说明) Failed, } impl SubAgentStatus { pub(crate) fn as_str(self) -> &'static str { match self { SubAgentStatus::Completed => "completed", SubAgentStatus::Truncated => "truncated", SubAgentStatus::Failed => "failed", } } } /// 子代理执行结果(经 spawn_agent 工具返回值交付给父 LLM)。 #[derive(Debug, Clone)] pub(crate) struct SubAgentOutcome { pub(crate) status: SubAgentStatus, pub(crate) summary: String, pub(crate) rounds: u32, pub(crate) tokens: u64, } impl SubAgentOutcome { /// 启动期失败快捷构造(rounds/tokens 为 0)。 pub(crate) fn failed(msg: impl Into) -> Self { Self { status: SubAgentStatus::Failed, summary: msg.into(), rounds: 0, tokens: 0, } } } // ============================================================ // 级联取消注册表(parent conv_id → children child conv_ids) // ============================================================ /// 短临界区读写(注册/注销/遍历均为无 await 纯内存操作),用 std Mutex 避免跨 await 持锁。 pub(crate) type SubAgentRegistry = Arc>>>; /// 构造空注册表(AppState::init 调用一次,Arc 进 AppState 与 SpawnAgentCtx 共享)。 pub(crate) fn new_registry() -> SubAgentRegistry { Arc::new(std::sync::Mutex::new(HashMap::new())) } /// spawn 前注册(parent → child 幂等追加)。 pub(crate) fn sub_agents_register(reg: &SubAgentRegistry, parent: &str, child: &str) { let mut map = reg.lock().unwrap_or_else(|e| e.into_inner()); let entry = map.entry(parent.to_string()).or_default(); if !entry.iter().any(|c| c == child) { entry.push(child.to_string()); } } /// 子循环结束后注销(child 移除,父条目空则整条清掉,防 HashMap 无限增长)。 pub(crate) fn sub_agents_remove(reg: &SubAgentRegistry, parent: &str, child: &str) { let mut map = reg.lock().unwrap_or_else(|e| e.into_inner()); if let Some(entry) = map.get_mut(parent) { entry.retain(|c| c != child); if entry.is_empty() { map.remove(parent); } } } /// 取父会话当前派生的子代理列表(级联取消入口用;无条目返空 Vec)。 pub(crate) fn sub_agents_children(reg: &SubAgentRegistry, parent: &str) -> Vec { reg.lock() .unwrap_or_else(|e| e.into_inner()) .get(parent) .cloned() .unwrap_or_default() } /// 级联取消:父会话被停止/强制发送/删除时遍历 children 置 stop_flag + notify。 /// /// 调用方(ai_chat_stop / ai_chat_force_send / ai_conversation_delete)须已持 session 锁, /// 本函数短临界区纯内存操作(无 await),与「禁止持 session 锁跨 await」纪律兼容。 /// 仅触碰已存在的子会话条目(不惰性重建,防取消动作反而制造空条目残留); /// 子循环停止检查点读 own stop_flag 即退出,父侧条目消失/父 stop 是兜底判据。 pub(crate) fn cascade_stop_children_locked( session: &mut crate::commands::ai::AiSession, reg: &SubAgentRegistry, parent: &str, ) { for child in sub_agents_children(reg, parent) { if let Some(conv) = session.per_conv.get_mut(&child) { conv.stop_flag.store(true, Ordering::SeqCst); conv.notify.notify_one(); } } } // ============================================================ // child_id 与纯函数 // ============================================================ /// 子代理序号(进程级单调递增,保证 child_id 全程唯一,防 per_conv 条目串号)。 static SUB_AGENT_SEQ: AtomicU64 = AtomicU64::new(0); /// 生成子会话 id:形态 `{parent}::agent-{n}`,同父会话内单调递增。 pub(crate) fn next_child_id(parent: &str) -> String { let n = SUB_AGENT_SEQ.fetch_add(1, Ordering::SeqCst) + 1; format!("{}::agent-{}", parent, n) } /// 白名单钳制(写入死在 spawn 入口,不依赖 LLM 自觉): /// - 候选 = tools 参数非空时取其与注册表 Low 风险工具集的交集;缺省时取全量 Low 工具集; /// - 恒剔除 spawn_agent 自身(禁递归); /// - 返回 (allowed 白名单, trimmed 被裁剪项[供任务书尾注说明])。两侧均去重排序(确定性)。 pub(crate) fn clamp_tool_whitelist( registry: &AiToolRegistry, requested: &[String], ) -> (Vec, Vec) { let low_set: std::collections::BTreeSet = registry .tool_names() .into_iter() .filter(|n| { registry .get(n) .map(|t| matches!(t.risk_level, df_ai::ai_tools::RiskLevel::Low)) .unwrap_or(false) }) .collect(); let base: std::collections::BTreeSet = if requested.is_empty() { low_set.iter().cloned().collect() } else { requested .iter() .filter(|n| low_set.contains(*n)) .cloned() .collect() }; let allowed: Vec = base .into_iter() .filter(|n| n != "spawn_agent") .collect(); let trimmed: Vec = if requested.is_empty() { Vec::new() } else { requested .iter() .filter(|n| !allowed.contains(*n)) .cloned() .collect() }; (allowed, trimmed) } /// 单轮预算熔断判定(prepare 段前调用)。返回 Some(原因) 即应截断退出,None 继续。 /// 纯函数抽出供单测锁定三路熔断语义(挂钟超时 / token 上限 / 轮次上限)。 pub(crate) fn round_budget_stop( round: u32, max_rounds: u32, tokens_total: u64, deadline: tokio::time::Instant, now: tokio::time::Instant, ) -> Option { if now >= deadline { return Some(format!("挂钟超时({}s)", SUB_AGENT_WALL_TIMEOUT_SECS)); } if tokens_total >= SUB_AGENT_MAX_TOTAL_TOKENS { return Some(format!("累计 token 达上限({})", SUB_AGENT_MAX_TOTAL_TOKENS)); } if round >= max_rounds { return Some(format!("达最大轮次上限({})", max_rounds)); } None } /// 子代理专用 system prompt(要点:面向父任务执行、最终回复即结论报告、无审批通道)。 pub(crate) fn sub_agent_system_prompt() -> String { "你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\ ## 定位\n\ - 面向父任务执行:只做任务书范围内的事,用户看不到你的过程,无法与你交互或审批\n\ - 探索/检索/核对类工作用工具完成,结论写进最终回复\n\ - 你没有审批通道:路径授权申请与中高风险工具会被直接拒绝并返回失败结果,\n\ 遇到被拒绝的操作改用已授权方式完成,或在报告中说明缺口\n\ - 控制轮次:默认最多 6 轮,收敛即停,不做任务书之外的无关探索\n\ ## 输出要求\n\ - 最终回复就是交付给父任务的结论报告,不要寒暄、不要向用户提问\n\ - 报告结构:结论先行,然后列关键发现/证据/数据,最后列未尽事项与建议" .to_string() } // ============================================================ // 主循环 // ============================================================ /// 运行一个子代理循环(由 spawn_agent 工具同步 await,报告经返回值交付)。 /// /// 单一出口:inner 跑完后统一 emit AiAgentFinished(挂父会话)+ 清理子会话内存条目, /// 保证启动失败路径也有一致的终态事件与无残留清理。 #[allow(clippy::too_many_arguments)] pub(crate) async fn run_sub_agent_loop( session_arc: Arc>, tools_arc: Arc, db: Arc, app_handle: AppHandle, parent_conv_id: String, child_conv_id: String, system_prompt: String, task_prompt: String, tool_defs: Vec, max_rounds: u32, model_override: Option, llm_concurrency: LlmConcurrency, ) -> SubAgentOutcome { let outcome = run_sub_agent_loop_inner( session_arc.clone(), tools_arc, db, app_handle.clone(), parent_conv_id.clone(), child_conv_id.clone(), system_prompt, task_prompt, tool_defs, max_rounds, model_override, llm_concurrency.clone(), ).await; // 统一出口:挂父会话 emit 终态(前端展示二期消费;报告本体经父 tool_result 返回值交付) emit_ai_chat_event(&app_handle, AiChatEvent::AiAgentFinished { agent_id: child_conv_id.clone(), conversation_id: Some(parent_conv_id), summary: outcome.summary.clone(), status: outcome.status.as_str().to_string(), rounds: outcome.rounds, tokens: outcome.tokens, }); // 清理子会话内存条目(报告已提取,纯内存子会话不留残迹;审计表记录保留可追溯) { let mut session = session_arc.lock().await; session.per_conv.remove(&child_conv_id); session.save_locks.remove(&child_conv_id); } llm_concurrency.release_conv(&child_conv_id).await; outcome } /// 子循环本体(结果型返回,终态事件与清理由 run_sub_agent_loop 统一收口)。 #[allow(clippy::too_many_arguments)] async fn run_sub_agent_loop_inner( session_arc: Arc>, tools_arc: Arc, db: Arc, app_handle: AppHandle, parent_conv_id: String, child_conv_id: String, system_prompt: String, task_prompt: String, tool_defs: Vec, max_rounds: u32, model_override: Option, llm_concurrency: LlmConcurrency, ) -> SubAgentOutcome { // 挂钟 deadline(三重熔断之三) let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(SUB_AGENT_WALL_TIMEOUT_SECS); // 并发份额:global + per_conv(child) 双非阻塞 try_acquire,任一耗尽即 failed // (子代理不阻塞等待,防占满父 loop 的执行槽与全局配额) let (_global_permit, _conv_permit) = match ( llm_concurrency.try_acquire_global().await, llm_concurrency.try_acquire_per_conv(&child_conv_id).await, ) { (Some(g), Some(c)) => (g, c), _ => { return SubAgentOutcome::failed( "子代理启动失败:LLM 并发份额已占满(global 或 per_conv),请稍后重试或调大并发配置", ); } }; // ── 子会话初始化(一次锁):建 child per_conv + push 任务书 + deleted_convs 零落库标记 ── let (child_stop, child_notify, child_epoch) = { let mut session = session_arc.lock().await; // 父会话已不存在(被删除/清理)→ 不启动 if !session.per_conv.contains_key(&parent_conv_id) { return SubAgentOutcome::failed("子代理启动失败:父会话已不存在(可能已被删除)"); } // 零落库(关键):deleted_convs 使 save_conversation 入口对该 conv 一律跳过, // 覆盖 stream_round 异常分支兜底 save 等所有既有写库路径(见模块头文档)。 // 先于 conv() 调用(insert 需可变借 session,避免与 conv 借用冲突) session.deleted_convs.insert(child_conv_id.clone()); let conv = session.conv(&child_conv_id); conv.messages.push(ChatMessage::user(&task_prompt)); conv.stop_flag.store(false, Ordering::SeqCst); conv.iteration_used = 0; (conv.stop_flag.clone(), conv.notify.clone(), conv.loop_epoch.clone()) }; let my_epoch = child_epoch.load(Ordering::SeqCst); // 生成态 guard:复用主循环 RAII(Generating→Idle 迁移 + 异常 Drop 兜底),子会话状态机一致 let mut guard = GeneratingGuard::new(child_conv_id.clone(), child_epoch.clone(), my_epoch, app_handle.clone()); // ── provider 解析(30s 超时,对齐主 loop 入口;失败走 failed 返回值,不 emit AiError)── let (primary_provider, candidates) = { let resolve = tokio::time::timeout(std::time::Duration::from_secs(30), async { let provider_repo = AiProviderRepo::new(&db); let pool = match provider_repo.list_all().await { Ok(v) => v, Err(e) => { tracing::warn!(error = %e, "[sub-agent] list_all providers 失败,空池处理"); Vec::new() } }; // override 作为亲和键(用户指定模型优先选含该模型的 provider),与主 loop 同款 let ranked = ProviderPool::select(&pool, model_override.as_deref()); match ranked.split_first() { Some((first, rest)) => Some((first.clone(), rest.to_vec())), None => None, } }) .await; match resolve { Ok(Some((primary, candidates))) => (primary, candidates), Ok(None) => { return SubAgentOutcome::failed("子代理启动失败:无可用 AI provider(请检查 provider 配置)"); } Err(_) => { return SubAgentOutcome::failed("子代理启动失败:provider 解析超时(30s),请检查数据库/钥匙串状态"); } } }; // resolved_model 初值 + 后续 candidate 切换覆盖(与主 loop 同款; // resolve_model_with_override 内部 select_model_id 池空兜底 default_model + override 穿透) let req_probe = TaskRequirements { modalities: vec![Modality::Text], needs_tool_use: true, estimated_context: 0, tier: None, }; let mut resolved_model = resolve_model_with_override(&req_probe, &primary_provider, &model_override); let mut provider_config = primary_provider; // system_prompt 是整个子循环的不变量,token 估算一次缓存复用(对齐主 loop) let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt); // 循环级累计状态(对齐主 loop 同名变量语义) let mut tokens = TokenAccumulator::default(); let mut saved_token_snapshot = df_ai::provider::TokenUsage::default(); let mut last_round_estimated = false; let mut last_reasoning_content: Option = None; let mut provider_saturation: HashMap = HashMap::new(); let mut fail_window: VecDeque = VecDeque::new(); let (mut stall_count, mut stall_warned, mut loop_count, mut loop_warned) = (0u32, false, 0u32, false); let mut rounds_done: u32 = 0; // for 耗尽(每轮都有 tool_calls 未收敛)默认截断;各 break 点按语义覆写 let mut status = SubAgentStatus::Truncated; let mut stop_reason = String::new(); let mut report = String::new(); for round in 0..max_rounds { // ── 停止检查点 + 预算检查(均在 prepare 段前)── if let Some(reason) = cancellation_reason(&session_arc, &child_conv_id, &parent_conv_id).await { status = SubAgentStatus::Truncated; stop_reason = format!("已被取消({})", reason); break; } let total_now = tokens_snapshot(&tokens).total_tokens as u64; if let Some(reason) = round_budget_stop(round, max_rounds, total_now, deadline, tokio::time::Instant::now()) { status = SubAgentStatus::Truncated; stop_reason = reason; break; } // ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 + // tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)── let (messages, estimated_prompt) = { let session = session_arc.lock().await; let Some(conv) = session.conv_read(&child_conv_id) else { status = SubAgentStatus::Failed; stop_reason = "子会话条目已消失".to_string(); break; }; let (mut history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens); // namespace 引用展开为真实内容(view-only,仅本轮请求视图,对齐主 loop) for m in &mut history_msgs { if df_ai::namespace_store::is_namespace_ref(&m.content) { let path = m.content.clone(); match session.namespace_store.read_only(&path) { Some(original) => m.content = original.to_string(), None => { m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string(); } } } } let mut msgs = vec![ChatMessage::system(&system_prompt)]; msgs.extend(history_msgs); let history_tokens = conv.messages.history_tokens(); // tool_result 大输出 view-only 摘要 + 占位配对断言(与主 loop 同款防线) let msgs = summarize_tool_results(msgs, history_tokens, &child_conv_id, round as usize); let msgs = ContextManager::assert_placeholder_pairing(msgs, PLACEHOLDER_INTEGRITY_ENABLED); let est = TokenEstimator::default(); let estimated_prompt: u32 = msgs.iter().map(|m| est.estimate_message(m)).sum(); (msgs, estimated_prompt) }; // ── 流式消费(复用 stream_round:candidate 链 + 重试退避 + MidStream 保文)── let agentic_req = TaskRequirements { modalities: vec![Modality::Text], needs_tool_use: true, estimated_context: estimated_prompt as usize, tier: None, }; match stream_round( &session_arc, &db, &child_conv_id, &messages, &tool_defs, &app_handle, &child_stop, &child_notify, DEFAULT_MAX_AGENT_RETRIES, &model_override, &agentic_req, estimated_prompt, &mut last_round_estimated, &mut last_reasoning_content, &mut resolved_model, &mut provider_config, &mut provider_saturation, &mut tokens, &mut saved_token_snapshot, &mut guard, &candidates, &llm_concurrency, // 子会话无目标钉扎,空快照(该参数仅供主 loop AiCompleted 快照透传) &[], &child_epoch, my_epoch, ).await { RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => { rounds_done += 1; tokens.add_usage(&round_usage); let has_tool_calls = !tool_calls.is_empty(); // assistant 消息入子会话(供下轮 build_for_request 与熔断读尾部; // 不落库,deleted_convs 拦截) { let mut session = session_arc.lock().await; if session.per_conv.contains_key(&child_conv_id) { push_assistant_message( &mut session, &child_conv_id, has_tool_calls, &tool_calls, &full_text, &resolved_model, &last_reasoning_content, round_usage.prompt_tokens, round_usage.completion_tokens, round_usage.prompt_cache_hit_tokens, round_usage.prompt_cache_miss_tokens, round_usage.reasoning_tokens, round_is_estimated, ); } } // 流后复查取消:停止信号到达时优先按截断处理,不把半截文本当完成报告 if let Some(reason) = cancellation_reason(&session_arc, &child_conv_id, &parent_conv_id).await { status = SubAgentStatus::Truncated; stop_reason = format!("已被取消({})", reason); break; } if !has_tool_calls { // 收敛轮:末轮 full_text 即报告自摘要 status = SubAgentStatus::Completed; report = full_text; break; } // ── 工具执行(SubAgent 策略:NeedsAuth/中高风险自动 deny,零待审批挂起)── let pending = process_tool_calls( &session_arc, tool_calls, &tools_arc, &db, &app_handle, &child_conv_id, ToolExecPolicy::SubAgent, ).await; if pending > 0 { // 防御:SubAgent 策略下不应产生挂起;出现即终止(子代理无审批恢复通道) status = SubAgentStatus::Failed; stop_reason = format!("子代理策略下出现意外审批挂起({} 项),终止", pending); break; } // L1 失败断路器(读子会话尾部消息,机制与主 loop 一致) let (max_count, sample_key) = count_recent_failures(&session_arc, &child_conv_id, &mut fail_window).await; if CIRCUIT_BREAKER_ENABLED && max_count >= CIRCUIT_BREAKER_THRESHOLD { status = SubAgentStatus::Failed; stop_reason = format!( "连续同类工具失败 {} 次(如 {}),已熔断", max_count, sample_key ); break; } // G2 探索熔断 + 写删回环熔断(读子会话尾部签名,机制与主 loop 一致) let (tripped, nc, nw, lc, lw) = check_stall_breaker( &session_arc, &app_handle, &child_conv_id, round as usize, stall_count, stall_warned, loop_count, loop_warned, ).await; stall_count = nc; stall_warned = nw; loop_count = lc; loop_warned = lw; if STALL_BREAKER_ENABLED && tripped { status = SubAgentStatus::Failed; stop_reason = "重复工具调用/写删回环探索熔断".to_string(); break; } } // stream_round 异常路径已完成其既有收尾(见模块头"事件与展示边界"残留说明) RoundOutcome::Fatal => { status = SubAgentStatus::Failed; stop_reason = "LLM 调用失败(全候选 provider 耗尽或致命错误)".to_string(); break; } RoundOutcome::MidStreamSaved => { status = SubAgentStatus::Truncated; stop_reason = "流中途中断,已保文".to_string(); break; } RoundOutcome::StaleAfterStream => { status = SubAgentStatus::Failed; stop_reason = "子会话状态被外部接管".to_string(); break; } } } // for 正常耗尽(每轮均有工具调用未收敛)→ 补截断原因 if status == SubAgentStatus::Truncated && stop_reason.is_empty() { stop_reason = format!("达最大轮次上限({})", max_rounds); } // ── 报告提取:收敛轮 full_text 优先;截断/失败取末条 assistant 文本附终止说明;再无则用原因 ── let total_tokens = tokens_snapshot(&tokens).total_tokens as u64; let summary = if !report.trim().is_empty() { // 父侧注入前超长截断复用现有 truncate_for_persist(头尾保留语义一致) truncate_for_persist(report.trim()) } else { match last_assistant_text(&session_arc, &child_conv_id).await { Some(text) => { let combined = if stop_reason.is_empty() { text } else { format!("{}\n\n(子代理提前终止:{})", text, stop_reason) }; truncate_for_persist(combined.trim()) } None if !stop_reason.is_empty() => format!("子代理未产出报告:{}", stop_reason), None => "子代理未产出报告".to_string(), } }; tracing::info!( parent = %parent_conv_id, agent = %child_conv_id, status = status.as_str(), rounds = rounds_done, tokens = total_tokens, summary_len = summary.len(), "[sub-agent] 子代理结束" ); guard.reset().await; SubAgentOutcome { status, summary, rounds: rounds_done, tokens: total_tokens, } } /// 停止检查点:own_stop || 父 stop_flag || 父 per_conv 条目已不存在(级联取消判据)。 /// 短锁段纯读,无 await 于锁内。 async fn cancellation_reason( session_arc: &Arc>, child_id: &str, parent_id: &str, ) -> Option { let session = session_arc.lock().await; match session.conv_read(parent_id) { None => Some("父会话已不存在".to_string()), Some(parent) if parent.stop_flag.load(Ordering::SeqCst) => Some("父会话已停止".to_string()), _ => match session.conv_read(child_id) { Some(child) if child.stop_flag.load(Ordering::SeqCst) => Some("收到停止信号".to_string()), None => Some("子会话条目已消失".to_string()), _ => None, }, } } /// 取子会话末条非空 assistant 文本(截断/失败路径的报告兜底)。 async fn last_assistant_text( session_arc: &Arc>, child_id: &str, ) -> Option { let session = session_arc.lock().await; let msgs = session.conv_read(child_id)?.messages.all_messages_clone(); msgs.into_iter() .rev() .find(|m| matches!(m.role, MessageRole::Assistant)) .map(|m| m.content) .filter(|c| !c.trim().is_empty()) } // ============================================================ // 单元测试:白名单钳制 / max_rounds 熔断 / registry 级联注册移除 // ============================================================ #[cfg(test)] mod tests { use super::*; use df_ai::ai_tools::{object_schema, RiskLevel}; /// 构造测试注册表:含 Low/Medium/High 各档 + 一个 Low 的 spawn_agent(验证恒剔除)。 /// /// 测试工具名用 t_ 前缀隔离:register 会把 ToolMeta::default 写入进程级全局元数据表, /// 若复用生产工具名(read_file 等)会覆盖真实元数据,令依赖全局表的消费方单测 /// (cache/path_auth/helpers 经 ensure_tool_meta_seeded)出现顺序依赖污染。 /// spawn_agent 保留真名:恒剔除语义锁的就是这个名字,且无任何测试断言其元数据。 fn test_registry() -> AiToolRegistry { // 每 register 独立构造 handler(AiToolHandler 是 Box 非 Copy,复用同一实例会 move 报错) fn dummy_handler() -> df_ai::ai_tools::AiToolHandler { Box::new(|_args| Box::pin(async { Ok(serde_json::json!({})) })) } let mut reg = AiToolRegistry::new(); reg.register("t_read_only", "只读", object_schema(vec![]), RiskLevel::Low, dummy_handler()); reg.register("spawn_agent", "派发", object_schema(vec![]), RiskLevel::Low, dummy_handler()); reg.register("t_create", "创建", object_schema(vec![]), RiskLevel::Medium, dummy_handler()); reg.register("t_exec", "执行", object_schema(vec![]), RiskLevel::High, dummy_handler()); reg } /// 白名单钳制:显式请求中剔除高风险与 spawn_agent 自身,未知名进 trimmed。 #[test] fn clamp_drops_high_risk_and_self() { let reg = test_registry(); let requested = vec![ "t_read_only".to_string(), "t_exec".to_string(), "t_create".to_string(), "spawn_agent".to_string(), "nonexistent".to_string(), ]; let (allowed, trimmed) = clamp_tool_whitelist(®, &requested); assert_eq!(allowed, vec!["t_read_only".to_string()], "仅 Low 工具可保留"); assert!(trimmed.contains(&"t_exec".to_string()), "High 应被裁剪"); assert!(trimmed.contains(&"t_create".to_string()), "Medium 应被裁剪"); assert!(trimmed.contains(&"spawn_agent".to_string()), "spawn_agent 应恒剔除(禁递归)"); assert!(trimmed.contains(&"nonexistent".to_string()), "未注册名应进裁剪说明"); } /// 白名单钳制:tools 缺省 = 全量 Low 工具集(仍剔 spawn_agent)。 #[test] fn clamp_default_is_low_set_without_self() { let reg = test_registry(); let (allowed, trimmed) = clamp_tool_whitelist(®, &[]); assert_eq!(allowed, vec!["t_read_only".to_string()]); assert!(trimmed.is_empty(), "缺省请求不产生裁剪尾注"); } /// max_rounds 熔断:轮次/token/挂钟三路均能触发且带可读原因;预算内返 None。 #[test] fn round_budget_trips_on_rounds_tokens_and_deadline() { let now = tokio::time::Instant::now(); let deadline = now + std::time::Duration::from_secs(60); // 预算内:继续 assert_eq!(round_budget_stop(0, 6, 0, deadline, now), None); // 轮次熔断 let r = round_budget_stop(6, 6, 0, deadline, now).unwrap(); assert!(r.contains("轮次"), "轮次原因: {}", r); // token 熔断 let r = round_budget_stop(1, 6, SUB_AGENT_MAX_TOTAL_TOKENS, deadline, now).unwrap(); assert!(r.contains("token"), "token 原因: {}", r); // 挂钟超时熔断(优先级最高) let past = now - std::time::Duration::from_secs(1); let r = round_budget_stop(1, 6, 0, past, now).unwrap(); assert!(r.contains("超时"), "超时原因: {}", r); } /// registry 级联:注册幂等追加、children 可查、注销移除且父条目空时整条清掉。 #[test] fn sub_agent_registry_register_remove() { let reg = new_registry(); sub_agents_register(®, "parent", "parent::agent-1"); sub_agents_register(®, "parent", "parent::agent-2"); sub_agents_register(®, "parent", "parent::agent-1"); // 幂等 assert_eq!( sub_agents_children(®, "parent"), vec!["parent::agent-1".to_string(), "parent::agent-2".to_string()] ); assert!(sub_agents_children(®, "other").is_empty(), "无条目父返空"); sub_agents_remove(®, "parent", "parent::agent-1"); assert_eq!( sub_agents_children(®, "parent"), vec!["parent::agent-2".to_string()] ); sub_agents_remove(®, "parent", "parent::agent-2"); // 父条目空后整条清除,不残留空键 { let map = reg.lock().unwrap(); assert!(map.is_empty(), "父条目空应整条清除"); } // 注销不存在的条目不 panic sub_agents_remove(®, "ghost", "ghost::agent-1"); } /// child_id 形态:单调递增且含父前缀。 #[test] fn next_child_id_shape() { let a = next_child_id("conv-x"); let b = next_child_id("conv-x"); assert!(a.starts_with("conv-x::agent-")); assert!(b.starts_with("conv-x::agent-")); assert_ne!(a, b, "同父连续生成的 child_id 必须唯一"); } /// 子代理 system prompt:含面向父任务与最终回复即报告的关键约束。 #[test] fn sub_agent_prompt_mentions_report_contract() { let p = sub_agent_system_prompt(); assert!(p.contains("子代理")); assert!(p.contains("最终回复")); assert!(p.contains("审批通道"), "应说明无审批通道(防挂死设计的前提告知)"); } }