diff --git a/crates/df-ai/src/intent.rs b/crates/df-ai/src/intent.rs index 535b094..7e7a749 100644 --- a/crates/df-ai/src/intent.rs +++ b/crates/df-ai/src/intent.rs @@ -245,6 +245,9 @@ pub enum ToolDomain { /// 命令执行:run_command(shell 命令)。基本盘成员:读写执行为 agent 基本能力 /// (2026-09-30 用户拍板,不受意图收敛限制),安全由执行层 validate_path/授权/RiskLevel 兜底 Exec, + /// 编排:spawn_agent 子代理派发。基本盘成员:委派是编排类基本能力, + /// 高置信意图收敛下不登记则工具对 LLM 不可见,安全由子代理侧白名单钳制兜底 + Agent, /// 网络:HTTP Http, } @@ -288,6 +291,8 @@ impl ToolDomain { // 2026-09-30 用户拍板读写执行为 agent 基本能力,不再仅 Debug 暴露; // 安全由执行层兜底(RiskLevel 审批/授权三道门/路径黑名单),藏工具≠安全。 ToolDomain::Exec => &["run_command"], + // Agent domain:spawn_agent 单列,编排委派基本盘成员(子代理侧白名单钳制兜底安全)。 + ToolDomain::Agent => &["spawn_agent"], ToolDomain::Http => &["http_request"], // DataReadOnly:Data domain 的只读子集。 // 源于 Code 意图收敛需求:用户在编码场景说"先 list_projects 看下项目结构" @@ -440,7 +445,8 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)> /// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。 /// /// 设计(2026-09-30 起):基本盘 + 场景增量。 -/// 基本盘 = File + Exec:读、写、执行命令是 agent 基本能力,不受意图收敛限制(用户拍板), +/// 基本盘 = File + Exec + Agent:读、写、执行命令、委派子代理是 agent 基本能力, +/// 不受意图收敛限制(用户拍板), /// 所有非空子集意图(Code/Debug/File/Project/Task/Idea/Search/Http)都可见。 /// 安全防线在执行层:validate_path 黑名单 + 授权三道门 + RiskLevel 审批,藏工具≠安全; /// 藏工具只会复现断手(Project 砍 File 致 read_file 不可见、Debug 独占 run_command 致 @@ -451,8 +457,9 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)> /// (发请求是该意图本名能力,砍掉即复现断手)。 /// 各意图新子集均为旧子集超集,只增不减。 pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> { - // 基本盘:读写执行(RiskLevel/审批/路径授权兜底,藏工具≠安全);场景增量=业务域收敛减少分心 - const BASE_DOMAINS: &[ToolDomain] = &[ToolDomain::File, ToolDomain::Exec]; + // 基本盘:读写执行 + 编排委派(RiskLevel/审批/路径授权/子代理白名单钳制兜底,藏工具≠安全); + // 场景增量=业务域收敛减少分心 + const BASE_DOMAINS: &[ToolDomain] = &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Agent]; let extra: &[ToolDomain] = match intent { Intent::Code => &[ToolDomain::Http, ToolDomain::DataReadOnly], Intent::Debug => &[ToolDomain::Http, ToolDomain::Data], diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 3fcdf80..a3e7996 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -39,7 +39,7 @@ use df_storage::models::AiProviderRecord; // ProviderAcquire 已随 stream_round 迁至 stream_relay.rs。 use crate::state::{AppState, LlmConcurrency}; -use super::audit::process_tool_calls; +use super::audit::{process_tool_calls, ToolExecPolicy}; use super::event_bus::emit_ai_chat_event; // compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。 use super::conversation::{save_conversation, TokenAccumulator}; @@ -361,6 +361,9 @@ pub(crate) mod stall_detector; // (纯搬运语义等价,锁边界/emit 顺序逐字保留,见 stream_relay.rs 文件头)。 // ============================================================ pub(crate) mod stream_relay; +// 子代理循环(spawn_agent v1):纯内存子会话 + 拼装既有积木的独立 agentic 循环。 +// 声明对齐兄弟模块风格;模块间积木(stream_round/process_tool_calls 等)经 super 复用。 +pub(crate) mod sub_agent; pub(crate) use stream_relay::{merge_volatile_tail, stream_round, RoundOutcome}; pub(crate) use router_setup::resolve_model_with_override; pub(crate) use stall_detector::{ @@ -1644,8 +1647,8 @@ async fn process_round_tools( ) -> ToolsOutcome { // AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。 let round_tool_call_count = tool_calls_acc.len(); - // 处理工具调用(Low 自动执行 / Medium+High 待审批) - let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id).await; + // 处理工具调用(Low 自动执行 / Medium+High 待审批;主对话恒 Normal 策略,子循环走 SubAgent) + let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id, ToolExecPolicy::Normal).await; // DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug, // 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。 tracing::debug!( diff --git a/src-tauri/src/commands/ai/agentic/sub_agent.rs b/src-tauri/src/commands/ai/agentic/sub_agent.rs new file mode 100644 index 0000000..95d3874 --- /dev/null +++ b/src-tauri/src/commands/ai/agentic/sub_agent.rs @@ -0,0 +1,795 @@ +//! 子代理循环(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("审批通道"), "应说明无审批通道(防挂死设计的前提告知)"); + } +} diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 31b2b69..9e08203 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -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>, + conv_id: &str, + draft: &ToolCallDraft, + tools_arc: &Arc, + 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, 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 警告(非静默),命令照常 diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 815cd08..280c869 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -1018,14 +1018,27 @@ pub async fn ai_approve( )) } else { None }; let exec_fut = super::super::command_stream::scope(sink, state.ai_tools.execute(&approval.tool_name, args.clone())); + // spawn_agent 父会话定位:审批执行路径同样经 task-local 注入父 conv + // (handler 兜底 active_conversation_id 不可靠:审批可能发生在用户已切会话后)。 + let exec_fut = super::super::tools::spawn_agent::scope_caller_conv( + conv_id.clone().unwrap_or_default(), + exec_fut, + ); + // spawn_agent 外层超时对齐其挂钟预算加缓冲:子循环自身三重熔断先触发, + // 以截断报告优雅收尾,不被 60s 裸超时腰斩(报告蒸发)。 + let approve_outer_secs: u64 = if approval.tool_name == "spawn_agent" { + crate::commands::ai::agentic::sub_agent::SUB_AGENT_WALL_TIMEOUT_SECS + 60 + } else { + 60 + }; match tokio::time::timeout( - std::time::Duration::from_secs(60), + std::time::Duration::from_secs(approve_outer_secs), exec_fut, ).await { Ok(r) => r, // 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。 // 仅对 run_command 追加(其余工具无 shell 适配问题),避免污染其他工具错误语义。 - Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}{}", approval.tool_name, + Err(_) => Err(anyhow::anyhow!("工具执行超时({}s): {}{}", approve_outer_secs, approval.tool_name, if approval.tool_name == "run_command" && cfg!(windows) { "\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)" } else { "" })), @@ -1965,6 +1978,12 @@ pub async fn ai_chat_force_send( conv.notify.notify_one(); conv.loop_epoch.fetch_add(1, Ordering::SeqCst) + 1 }; + // spawn_agent 级联取消:强制发送复位父 loop,其派生的子代理同步置停。 + // force_send 随后会把父 stop_flag 复位 false(新 loop 起跑),子循环不能依赖父 + // flag 长期置位,own stop_flag 是子循环主判据(父条目消失为兜底)。 + crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked( + &mut session, &state.sub_agent_registry, &target, + ); // ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截—— // 这是"强制"语义本身,前面已主动复位) let conv = session.conv(&target); @@ -2176,6 +2195,12 @@ pub async fn ai_chat_stop( conv.stop_flag.store(true, Ordering::SeqCst); conv.notify.clone() }; + // spawn_agent 级联取消:父 loop 收到停止,其派生的子代理同步置停 + // (子循环停止检查点读 own stop_flag 即退出,不等挂钟超时)。 + // 短临界区纯内存操作(无 await),不延长 session 持锁。 + crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked( + &mut session, &state.sub_agent_registry, &target, + ); // 置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!, // 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。 // Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。 diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 5e95a5d..2f51a66 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -630,6 +630,12 @@ pub async fn ai_conversation_delete( super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已删除"); // 阶段3a 单真相源合并:单表 retain(kind 不区分)。 session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); + // spawn_agent 级联取消:父会话删除,其派生的子代理同步置停(子循环停止检查点 + // 读 own stop_flag 即退出;父 per_conv 条目随下方 remove 消失是兜底判据)。 + // 短临界区纯内存操作(无 await),不延长 session 持锁。 + crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked( + &mut session, &state.sub_agent_registry, &conversation_id, + ); // 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。 session.per_conv.remove(&conversation_id); // 顺带清理该 conv 的 save 串行锁条目(防 HashMap 无限增长)。 diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 91a748b..6dc799d 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -222,6 +222,23 @@ pub enum AiChatEvent { /// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮 /// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false) AiMaxRoundsReached { conversation_id: Option }, + /// 子代理结束(spawn_agent v1):每个子循环统一出口经挂父会话 emit 一条, + /// 汇总子代理终态供前端展示(消费二期)。报告本体不经本事件传输(经父 tool_result + /// 返回值交付),此处 summary 仅为终态快照摘要。 + /// + /// 字段: + /// - `agent_id`:子会话 id(形态 `{parent}::agent-{n}`,与 Started 无对应卡,独立事件) + /// - `conversation_id`:父会话 id(路由键,挂父不挂子,子会话不在侧栏) + /// - `status`:"completed" | "truncated" | "failed" + /// - `rounds`/`tokens`:实际执行轮数与累计 token(启动失败为 0) + AiAgentFinished { + agent_id: String, + conversation_id: Option, + summary: String, + status: String, + rounds: u32, + tokens: u64, + }, /// 流式调用自动重试提示流前失败重试中) /// /// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。 diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 25fa30b..d9d4c3b 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -516,6 +516,7 @@ pub fn build_ai_tool_registry( data_dir: PathBuf, get_app_config_ctx: super::get_app_config::GetAppConfigCtx, local_proxy_ctx: super::tools::local_proxy::LocalProxyCtx, + spawn_agent_ctx: super::tools::spawn_agent::SpawnAgentCtx, ) -> AiToolRegistry { let mut registry = AiToolRegistry::new(); register_data_tools(&mut registry, db); @@ -527,9 +528,27 @@ pub fn build_ai_tool_registry( register_get_app_config_tool(&mut registry, get_app_config_ctx); register_local_proxy_tools(&mut registry, local_proxy_ctx); register_read_skill_tool(&mut registry); + register_spawn_agent_tool(&mut registry, spawn_agent_ctx); registry } +/// spawn_agent AI 工具注册(1 个:派发子代理,隔离上下文执行专项任务)。 +/// +/// 工具职责:LLM 调 spawn_agent(task, tools?, max_rounds?, model?) 派发子代理, +/// 子代理在纯内存子会话跑独立 agentic 循环(agentic/sub_agent.rs),报告经 tool_result +/// 返回值交付。风险 Medium(派发需用户审批,与 create_task 同档)。 +/// +/// ctx 绕 AppState 自引用(同 GetAppConfigCtx 先例):session/conv_states/llm_concurrency/ +/// db/sub_agents 全 Arc 字段,由 state.rs init 在 build registry 前提前构建注入, +/// AppState struct 字段复用同一 Arc。工具注册表自身经进程级 OnceLock +/// (init_global_tool_registry)在 state.rs init 构建完成后回注,供白名单钳制查询。 +fn register_spawn_agent_tool( + registry: &mut AiToolRegistry, + ctx: super::tools::spawn_agent::SpawnAgentCtx, +) { + super::tools::spawn_agent::register(registry, ctx); +} + /// read_skill AI 工具注册(1 个:按名读本机 Claude 技能规程正文,只读)— thin 委托。 /// /// 工具职责:LLM 主动调 read_skill(name) 获取技能 SKILL.md 剥 frontmatter 正文, @@ -959,6 +978,7 @@ pub(crate) fn ensure_tool_meta_seeded() { PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), + make_test_spawn_agent_ctx(&db), ); }); }); @@ -1340,6 +1360,21 @@ fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::Local } } +/// build_ai_tool_registry 第 6 参(spawn_agent 派发 ctx),测试调用共用。 +/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。 +#[cfg(test)] +fn make_test_spawn_agent_ctx(db: &Arc) -> crate::commands::ai::tools::spawn_agent::SpawnAgentCtx { + crate::commands::ai::tools::spawn_agent::SpawnAgentCtx { + session: Arc::new(tokio::sync::Mutex::new(crate::commands::ai::AiSession::new())), + conv_states: Arc::new( + crate::commands::ai::agentic::conv_state::ConvStateStore::new(), + ), + llm_concurrency: LlmConcurrency::new(3, 2), + db: db.clone(), + sub_agents: crate::commands::ai::agentic::sub_agent::new_registry(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1353,7 +1388,7 @@ mod tests { // 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。 // ============================================================ - /// build_ai_tool_registry 应注册恰好 77 个工具(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill),且工具名集合稳定。 + /// build_ai_tool_registry 应注册恰好 78 个工具(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent),且工具名集合稳定。 /// /// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db, // 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。 @@ -1364,7 +1399,7 @@ mod tests { // F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参, // 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。 let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db)); // 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。 // file 层 10→11(新增 grep 跨文件内容搜索工具)。 @@ -1428,10 +1463,14 @@ mod tests { // 与 / 技能注入链共用 skills.rs 进程内缓存;未命中返明确错误,LLM 可向用户澄清。 // 声明式注册在 tools/read_skill.rs,register_read_skill_tool thin 委托)。 // 77 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill。 + // spawn_agent(2026-10-01): spawn_agent 层 0→1(派发子代理隔离上下文执行专项任务, + // Medium 审批档;独立 agentic 循环在 agentic/sub_agent.rs,报告经返回值交付。 + // SpawnAgentCtx 绕 AppState 自引用同 GetAppConfigCtx 先例,注册表自引用走进程级 OnceLock)。 + // 78 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent。 assert_eq!( registry.len(), - 77, - "工具总数应为 77(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill),实际 {}", registry.len() + 78, + "工具总数应为 78(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent),实际 {}", registry.len() ); // 工具名集合基线:防 rename / 漏注册 / 误删除。 @@ -1499,6 +1538,9 @@ mod tests { // ── read_skill 层 (1) ──(按名读本机 Claude 技能规程正文,只读 Low, // 与 / 技能注入链共用 skills.rs 进程内缓存,声明式注册 tools/read_skill.rs) "read_skill", + // ── spawn_agent 层 (1) ──(派发子代理隔离上下文执行专项任务,Medium 审批档, + // 独立 agentic 循环 agentic/sub_agent.rs,声明式注册 tools/spawn_agent.rs) + "spawn_agent", ]; expected.sort_unstable(); @@ -1718,7 +1760,7 @@ mod tests { let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Arc::new(db); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db)); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let args = serde_json::json!({ "path": canon_file, "limit": 15 }); let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); @@ -1745,7 +1787,7 @@ mod tests { let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Arc::new(db); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db)); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let args = serde_json::json!({ "path": canon_file }); let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); @@ -1824,7 +1866,7 @@ mod tests { persistent.insert(tmp.clone()); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() })); let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败")); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db)); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let args = serde_json::json!({ "path": canon_file }); @@ -1853,7 +1895,7 @@ mod tests { persistent.insert(tmp.clone()); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() })); let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败")); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db)); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let args = serde_json::json!({ "path": canon_file }); @@ -2335,7 +2377,7 @@ mod tests { .await .unwrap(); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db)); (db, registry) } @@ -2452,6 +2494,7 @@ mod tests { let registry = build_ai_tool_registry( &db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), + make_test_spawn_agent_ctx(&db), ); ensure_tool_meta_seeded(); diff --git a/src-tauri/src/commands/ai/tools/mod.rs b/src-tauri/src/commands/ai/tools/mod.rs index c139324..f2be288 100644 --- a/src-tauri/src/commands/ai/tools/mod.rs +++ b/src-tauri/src/commands/ai/tools/mod.rs @@ -27,3 +27,6 @@ pub mod file; pub mod download_file; pub mod entity_resolve; pub mod read_skill; +// spawn_agent 子代理派发(独立 agentic 循环入口,audit 与 ai_approve 经本模块 +// scope_caller_conv 注入父会话 task-local,故需 pub 可达)。 +pub mod spawn_agent; diff --git a/src-tauri/src/commands/ai/tools/spawn_agent.rs b/src-tauri/src/commands/ai/tools/spawn_agent.rs new file mode 100644 index 0000000..b1e5cfb --- /dev/null +++ b/src-tauri/src/commands/ai/tools/spawn_agent.rs @@ -0,0 +1,347 @@ +//! spawn_agent AI 工具声明式注册(派发子代理,隔离上下文执行专项任务,报告经返回值交付)。 +//! +//! 架构:子代理 = 纯内存子会话 + 独立 agentic 循环(agentic/sub_agent.rs),本工具是唯一入口: +//! 1. 解析参数(task/tools/max_rounds/model),白名单钳制写死在入口(tools ∩ Low 风险 ∖ spawn_agent); +//! 2. 组装任务书(父上下文头 + 白名单说明 + 被裁剪尾注),注册级联取消条目(parent→children); +//! 3. 同步等待子循环跑完,报告作为 tool_result 返回值交付(Claude Code Task 语义同构)。 +//! +//! ## 自引用绕法(两条进程级 OnceLock,先例 GLOBAL_TOOL_META) +//! - 工具注册表:白名单钳制需要 AiToolRegistry 自身,而注册期无法捕获自身 Arc, +//! state.rs init 在 build_ai_tool_registry 返回后调 init_global_tool_registry 注入; +//! - AppHandle:子循环 emit/guard 需要,lib.rs setup 阶段调 init_app_handle 注入。 +//! +//! ## 父会话定位 +//! 工具 handler 签名只收 args,父 loop 的 conv_id 经 task-local 注入(复用 command_stream +//! 的 task-local 范式):process_tool_calls / ai_approve 执行工具前 scope_caller_conv 注入, +//! handler 内 caller_conv() 读取;缺省兜底 active_conversation_id(直调防御)。 + +use std::sync::Arc; + +use tokio::sync::Mutex; + +use df_ai::ai_tools::{ + AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta, +}; +use df_ai::declare_tool; +use df_ai::provider::ToolDefinition; + +use crate::commands::ai::agentic::conv_state::ConvStateStore; +use crate::commands::ai::agentic::sub_agent::{ + clamp_tool_whitelist, next_child_id, run_sub_agent_loop, sub_agent_system_prompt, + sub_agents_register, sub_agents_remove, SubAgentRegistry, + SUB_AGENT_DEFAULT_MAX_ROUNDS, SUB_AGENT_MAX_ROUNDS_CAP, +}; +use crate::commands::ai::AiSession; +use crate::state::LlmConcurrency; + +use df_storage::db::Database; + +/// spawn_agent 工具捕获上下文(Arc 全字段,Clone 廉价;先例 GetAppConfigCtx)。 +/// +/// 各 Arc 由 state.rs init 在 build_ai_tool_registry **之前**提前构建并 clone 注入, +/// AppState struct 字段复用同一 Arc(注册先于 ai_session 构建的自引用绕法)。 +/// derive Clone:declare_tool! 把捕获变量 clone 进工具闭包。 +#[derive(Clone)] +pub struct SpawnAgentCtx { + /// 父/子会话状态(读父 model_override / active_conversation_id,子会话建条) + pub session: Arc>, + /// 会话生成态存储(子循环 guard 经 AppHandle 侧使用,此处预留一致生命周期; + /// 与 AppState.conv_states 共享同一 Arc,随子代理消费场景接入启用) + #[allow(dead_code)] + pub conv_states: Arc, + /// LLM 并发控制(子循环 global + per_conv(child) 双份额) + pub llm_concurrency: LlmConcurrency, + /// 数据库句柄(子循环 provider 解析) + pub db: Arc, + /// 级联取消注册表(parent→children,与 AppState.sub_agent_registry 共享同一 Arc) + pub sub_agents: SubAgentRegistry, +} + +// ── 进程级 OnceLock:工具注册表 + AppHandle(注册期无法捕获,运行期注入,见模块头) ── + +static GLOBAL_SPAWN_TOOL_REGISTRY: std::sync::OnceLock> = + std::sync::OnceLock::new(); +static GLOBAL_APP: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// state.rs init 注入工具注册表(白名单钳制数据源;重复注入忽略,首次生效)。 +pub(crate) fn init_global_tool_registry(registry: Arc) { + let _ = GLOBAL_SPAWN_TOOL_REGISTRY.set(registry); +} + +/// handler 读取工具注册表(未初始化返 None,handler 报明确错误)。 +fn tool_registry() -> Option> { + GLOBAL_SPAWN_TOOL_REGISTRY.get().cloned() +} + +/// lib.rs setup 注入 AppHandle(子循环 emit/guard 所需;重复注入忽略,首次生效)。 +pub(crate) fn init_app_handle(app: tauri::AppHandle) { + let _ = GLOBAL_APP.set(app); +} + +/// handler 读取 AppHandle(未初始化返 None,handler 报明确错误)。 +fn app_handle() -> Option { + GLOBAL_APP.get().cloned() +} + +// ── 父会话 task-local(复用 command_stream 的 task-local 范式) ── + +tokio::task_local! { + static CALLER_CONV: String; +} + +/// 在注入父会话 id 的作用域内执行 future(process_tool_calls / ai_approve 调用工具前包裹)。 +pub(crate) async fn scope_caller_conv(conv_id: String, fut: F) -> R +where + F: std::future::Future, +{ + CALLER_CONV.scope(conv_id, fut).await +} + +/// handler 读取发起调用的父会话 id(未经 scope 直调时返 None,调用方走兜底)。 +pub(crate) fn caller_conv() -> Option { + CALLER_CONV.try_with(|c| c.clone()).ok() +} + +// ── 参数解析与任务书组装(纯函数,单测锁定) ── + +/// 解析 max_rounds:缺省默认 6,合法域钳制 [1, 20](防参数放大预算失控)。 +fn parse_max_rounds(raw: Option) -> u32 { + raw.map(|v| v as u32) + .unwrap_or(SUB_AGENT_DEFAULT_MAX_ROUNDS) + .clamp(1, SUB_AGENT_MAX_ROUNDS_CAP) +} + +/// 组装子代理任务书:父上下文头 + 任务正文 + 工具白名单说明 + 被裁剪项尾注。 +fn build_task_prompt(task: &str, allowed: &[String], trimmed: &[String]) -> String { + let mut p = String::new(); + p.push_str("[父任务上下文] 你是主会话派生的子代理,以下是父任务交办的任务书。\n\n\n"); + p.push_str(task); + p.push_str("\n\n\n"); + if allowed.is_empty() { + p.push_str("可用工具:无(白名单为空),请直接基于推理输出结论报告。\n"); + } else { + p.push_str(&format!( + "可用工具白名单(仅限以下工具,且需授权/高风险操作会被自动拒绝):{}\n", + allowed.join(", ") + )); + } + if !trimmed.is_empty() { + p.push_str(&format!( + "(以下请求的工具因超出子代理权限或未注册被剔除,如确需请写入报告交由主会话完成:{})\n", + trimmed.join(", ") + )); + } + p +} + +/// 注册 spawn_agent 工具(RiskLevel::Medium,派发需用户审批,与 create_task 同档)。 +pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) { + // schema 手写 JSON(task 必填 + 三个可选参数,描述面向 LLM) + let schema = serde_json::json!({ + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "自包含任务书:背景/目标/完成标准。子代理看不到父对话,任务书必须自带全部所需上下文" + }, + "tools": { + "type": "array", + "items": { "type": "string" }, + "description": "可选,工具名白名单。仅低风险只读工具可保留,其余被剔除并在任务书尾注说明;缺省=全部低风险工具" + }, + "max_rounds": { + "type": "integer", + "description": "可选,工具循环轮数上限,默认 6,最大 20" + }, + "model": { + "type": "string", + "description": "可选,子代理用模型 id;缺省继承当前会话模型" + } + }, + "required": ["task"] + }); + declare_tool!( + registry, + ctx: SpawnAgentCtx, + "spawn_agent", + "派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 6);model 可选模型(缺省继承当前会话)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回", + RiskLevel::Medium, + meta: ToolMeta { + // 审批卡文案模板(模板 {} 按位置填充,键取 task) + approval_hint: Some(("派发子代理: {}", &["task"])), + display_verb: Some("派发"), + // G1 目标推理:任务书截断展示(Truncate 25 字符,对齐 run_command 风格) + goal: Some(GoalMeta { + verb: "派发子代理", + keys: &["task"], + style: GoalStyle::Truncate, + fallback: Some("派发子代理执行专项任务"), + }), + // G2 探索熔断签名:task 进签名,同任务书重复派发会被判重复打转熔断 + sig: Some(SigMeta { + groups: &[(&["task"], SigValKind::Str)], + }), + ..Default::default() + }, + schema: schema, + args => { + spawn_agent_handler(&ctx, &args).await + } + ); +} + +/// spawn_agent handler:钳制白名单 → 注册级联条目 → 同步跑子循环 → 报告作返回值。 +async fn spawn_agent_handler( + ctx: &SpawnAgentCtx, + args: &serde_json::Value, +) -> anyhow::Result { + // 参数解析 + let task = args + .get("task") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("参数 task 不能为空:请传自包含任务书(背景/目标/完成标准)"))?; + let max_rounds = parse_max_rounds(args.get("max_rounds").and_then(|v| v.as_u64())); + let model = args + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let requested: Vec = args + .get("tools") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + + // 白名单钳制(写死在入口,不依赖 LLM 自觉)+ 工具定义投影 + let registry = tool_registry() + .ok_or_else(|| anyhow::anyhow!("子代理工具注册表未就绪(应用初始化未完成)"))?; + let (allowed, trimmed) = clamp_tool_whitelist(®istry, &requested); + let tool_defs: Vec = registry + .tool_definitions() + .into_iter() + .filter(|d| allowed.contains(&d.function.name)) + .collect(); + + // 父会话定位:task-local 注入优先,兜底 active(直调防御) + let parent_conv_id = match caller_conv() { + Some(p) if !p.is_empty() => p, + _ => ctx + .session + .lock() + .await + .active_conversation_id + .clone() + .unwrap_or_default(), + }; + if parent_conv_id.is_empty() { + return Err(anyhow::anyhow!("spawn_agent 无法定位父会话:只能在会话内调用")); + } + + // model 不传 → 继承父会话模型 override(与父对话同模型语义) + let child_model = match model { + Some(m) => Some(m), + None => ctx + .session + .lock() + .await + .conv_read(&parent_conv_id) + .and_then(|c| c.model_override.clone()), + }; + + let child_id = next_child_id(&parent_conv_id); + let task_prompt = build_task_prompt(&task, &allowed, &trimmed); + let system_prompt = sub_agent_system_prompt(); + + // 级联注册(父停止/强制发送/删除会遍历 children 级联取消) + sub_agents_register(&ctx.sub_agents, &parent_conv_id, &child_id); + + let app = match app_handle() { + Some(h) => h, + None => { + sub_agents_remove(&ctx.sub_agents, &parent_conv_id, &child_id); + return Err(anyhow::anyhow!("应用句柄未就绪(启动初始化未完成)")); + } + }; + + tracing::info!( + parent = %parent_conv_id, + agent = %child_id, + task_chars = task.chars().count(), + tools = allowed.len(), + max_rounds, + model = ?child_model, + "[sub-agent] 派发子代理" + ); + + // 同步等待子循环(报告经返回值交付;外层超时对 spawn_agent 取子代理挂钟预算同款上限) + let outcome = run_sub_agent_loop( + ctx.session.clone(), + registry.clone(), + ctx.db.clone(), + app, + parent_conv_id.clone(), + child_id.clone(), + system_prompt, + task_prompt, + tool_defs, + max_rounds, + child_model, + ctx.llm_concurrency.clone(), + ) + .await; + + // 级联注销(空父条目整条清除) + sub_agents_remove(&ctx.sub_agents, &parent_conv_id, &child_id); + + Ok(serde_json::json!({ + "status": outcome.status.as_str(), + "agent_id": child_id, + "rounds": outcome.rounds, + "tokens": outcome.tokens, + "report": outcome.summary, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// parse_max_rounds:缺省 6,合法域钳制 [1,20]。 + #[test] + fn parse_max_rounds_clamps() { + assert_eq!(parse_max_rounds(None), SUB_AGENT_DEFAULT_MAX_ROUNDS); + assert_eq!(parse_max_rounds(Some(1)), 1); + assert_eq!(parse_max_rounds(Some(6)), 6); + assert_eq!(parse_max_rounds(Some(0)), 1, "0 钳到下界 1"); + assert_eq!(parse_max_rounds(Some(999)), SUB_AGENT_MAX_ROUNDS_CAP, "超上限钳到 20"); + } + + /// 任务书:含父上下文头、任务正文、白名单说明与被裁剪尾注。 + #[test] + fn task_prompt_carries_context_and_notes() { + let p = build_task_prompt( + "统计 crates 下 TODO 数量", + &["read_file".to_string(), "grep".to_string()], + &["run_command".to_string()], + ); + assert!(p.contains("[父任务上下文]"), "应有父上下文头"); + assert!(p.contains("\n统计 crates 下 TODO 数量\n"), "任务正文应入 task 标签"); + assert!(p.contains("read_file, grep"), "应列白名单"); + assert!(p.contains("run_command"), "被裁剪项应有尾注说明"); + // 空白名单走"无工具"说明 + let empty = build_task_prompt("t", &[], &[]); + assert!(empty.contains("白名单为空")); + } + + /// task-local 父会话注入/读取:scope 内可读,scope 外读不到。 + #[tokio::test] + async fn caller_conv_scoped() { + assert!(caller_conv().is_none(), "scope 外应读不到"); + let seen = scope_caller_conv("conv-p::agent-9".to_string(), async { + caller_conv() + }).await; + assert_eq!(seen.as_deref(), Some("conv-p::agent-9")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c70df3f..fe9e466 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -84,6 +84,9 @@ pub fn run() { // L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state let session_for_handshake = app_state.ai_session.clone(); app.manage(app_state); + // spawn_agent 子代理循环需要 AppHandle(emit/guard),工具注册期无法捕获, + // setup 阶段回注进程级 OnceLock(重复注入忽略,首次生效)。 + crate::commands::ai::tools::spawn_agent::init_app_handle(app.handle().clone()); // ── Phase3: 内嵌 HTTP MCP server + 托盘常驻 + 单实例 ── // 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。 diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index b29b117..eb7d3d1 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -244,6 +244,14 @@ pub struct AppState { /// workspace_root 始终在白名单(向后兼容)。 /// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。 pub allowed_dirs: Arc>, + /// spawn_agent 级联取消注册表(parent conv_id → children child conv_ids)。 + /// + /// spawn 前注册、子循环结束注销(tools/spawn_agent.rs);父会话被停止/强制发送/删除时, + /// ai_chat_stop / ai_chat_force_send / ai_conversation_delete 遍历 children 置 + /// stop_flag + notify 级联取消(子循环停止检查点兼查 own_stop/父 stop/父条目消失)。 + /// 与 SpawnAgentCtx.sub_agents 共享同一 Arc(state.rs init 提前构建)。 + pub sub_agent_registry: + crate::commands::ai::agentic::sub_agent::SubAgentRegistry, // ── 跨端隧道(Phase3 Layer1)── /// 桌面端出站 WS 隧道客户端,连 df-relay(/ws/device)透传 ai_event_bus → miniapp /// + 转发 miniapp 下行指令 → remote_bridge。lib.rs setup spawn task 解析 @@ -337,9 +345,26 @@ impl AppState { gateway_url: std::env::var("DF_LOCAL_PROXY_BASE") .unwrap_or_else(|_| "http://127.0.0.1:8899".to_string()), }; + // spawn_agent(2026-10-01):子代理派发工具 ctx 提前构建(同 get_app_config 绕 AppState + // 自引用先例)。session/conv_states/sub_agents 三个 Arc 先建,下方 state struct 字段 + // 复用同一实例(级联取消注册表三方共享:AppState / SpawnAgentCtx / 级联入口)。 + let ai_session = Arc::new(Mutex::new(AiSession::new())); + let conv_states = Arc::new(crate::commands::ai::agentic::conv_state::ConvStateStore::new()); + let sub_agent_registry = crate::commands::ai::agentic::sub_agent::new_registry(); + let spawn_agent_ctx = crate::commands::ai::tools::spawn_agent::SpawnAgentCtx { + session: ai_session.clone(), + conv_states: conv_states.clone(), + llm_concurrency: llm_concurrency.clone(), + db: db.clone(), + sub_agents: sub_agent_registry.clone(), + }; let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry( &db, &allowed_dirs, data_dir.clone(), get_app_config_ctx, local_proxy_ctx, + spawn_agent_ctx, )); + // spawn_agent 白名单钳制需要注册表自身 Arc,注册期无法捕获(自引用), + // 构建完成后回注进程级 OnceLock(handler 运行期读取)。 + crate::commands::ai::tools::spawn_agent::init_global_tool_registry(ai_tools.clone()); // Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。 // resolver 持 Arc(非 AppState,避免循环依赖:AppState 持 Arc), // 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。 @@ -364,11 +389,13 @@ impl AppState { ai_conversations: AiConversationRepo::new(&db), ai_messages: AiMessageRepo::new(&db), ai_tool_executions: AiToolExecutionRepo::new(&db), - ai_session: Arc::new(Mutex::new(AiSession::new())), + // 复用上方提前构建的 Arc(spawn_agent ctx 注入同一实例,共享底层会话状态) + ai_session, // L3 批2b:AI 事件总线入 AppState(独立于工作流 event_bus)。emit 双写留批3。 ai_event_bus: crate::commands::ai::event_bus::EventBus::new(), // B-Phase1: ConvStateStore 无锁存储(空构造;双轨期无写点,B-Phase2 写侧切入)。 - conv_states: Arc::new(crate::commands::ai::agentic::conv_state::ConvStateStore::new()), + // 复用上方提前构建的 Arc(spawn_agent ctx 注入同一实例)。 + conv_states, // Phase3 Layer1:tunnel 客户端未连接实例,lib.rs setup 内 connect tunnel: std::sync::Arc::new(df_tunnel::WsTunnelClient::new()), knowledge: KnowledgeRepo::new(&db), @@ -384,6 +411,9 @@ impl AppState { workflow_state_registry: Arc::new(Mutex::new(HashMap::new())), // F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄) allowed_dirs: allowed_dirs.clone(), + // spawn_agent 级联取消注册表(parent→children):ai_chat_stop / ai_chat_force_send / + // ai_conversation_delete 命中父 conv 时遍历 children 置 stop_flag,与 SpawnAgentCtx 共享。 + sub_agent_registry, db, event_bus: EventBus::new(), registry, diff --git a/src-tauri/src/state/llm_concurrency.rs b/src-tauri/src/state/llm_concurrency.rs index 4b8960d..7b4bfdc 100644 --- a/src-tauri/src/state/llm_concurrency.rs +++ b/src-tauri/src/state/llm_concurrency.rs @@ -133,6 +133,18 @@ impl LlmConcurrency { sema.acquire_owned().await.expect("llm global semaphore closed") } + /// 取全局 LLM 调用并发 permit 的**非阻塞**版本(对齐 try_acquire_per_conv 语义)。 + /// + /// 用途:spawn_agent 子代理循环入口一次性取 global + per_conv(child) 双份额, + /// 任一耗尽即 failed(子代理不阻塞等待,防占满父 loop 的执行槽)。 + /// 锁持有短(不含 try_acquire)。 + pub async fn try_acquire_global(&self) -> Option { + let sema = self.global.lock().await.clone(); + // try_acquire_owned 非阻塞:Err(NoPermits) 返 None,不阻塞调用方。 + // Semaphore 不会 close(无 close() 调用),Err 仅可能为 NoPermits。 + sema.try_acquire_owned().ok() + } + /// 取单对话内并发 permit(F-09 B 批5):按 conv_id 取/建 Semaphore,permits=当前 per_conv_permits(默认 2)。 /// lock HashMap → 无则建 → clone Arc → 释放 lock → acquire_owned。锁持有短(不含 await acquire)。 pub async fn acquire_per_conv(&self, conv_id: &str) -> tokio::sync::OwnedSemaphorePermit {