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

- 纯内存子会话隔离上下文,一轮回传结论报告
- 白名单钳制低风险并恒剔 spawn_agent 防递归
- 子代理策略下高风险自动拒绝,不挂审批防卡死
- 轮数令牌挂钟三重熔断
- 父停止与删会话级联取消子代理
- AiAgentFinished 事件挂父会话
This commit is contained in:
lxy
2026-10-01 19:35:21 +08:00
parent 524120b110
commit 2187ffe173
13 changed files with 1407 additions and 21 deletions
+10 -3
View File
@@ -245,6 +245,9 @@ pub enum ToolDomain {
/// 命令执行:run_command(shell 命令)。基本盘成员:读写执行为 agent 基本能力 /// 命令执行:run_command(shell 命令)。基本盘成员:读写执行为 agent 基本能力
/// (2026-09-30 用户拍板,不受意图收敛限制),安全由执行层 validate_path/授权/RiskLevel 兜底 /// (2026-09-30 用户拍板,不受意图收敛限制),安全由执行层 validate_path/授权/RiskLevel 兜底
Exec, Exec,
/// 编排:spawn_agent 子代理派发。基本盘成员:委派是编排类基本能力,
/// 高置信意图收敛下不登记则工具对 LLM 不可见,安全由子代理侧白名单钳制兜底
Agent,
/// 网络:HTTP /// 网络:HTTP
Http, Http,
} }
@@ -288,6 +291,8 @@ impl ToolDomain {
// 2026-09-30 用户拍板读写执行为 agent 基本能力,不再仅 Debug 暴露; // 2026-09-30 用户拍板读写执行为 agent 基本能力,不再仅 Debug 暴露;
// 安全由执行层兜底(RiskLevel 审批/授权三道门/路径黑名单),藏工具≠安全。 // 安全由执行层兜底(RiskLevel 审批/授权三道门/路径黑名单),藏工具≠安全。
ToolDomain::Exec => &["run_command"], ToolDomain::Exec => &["run_command"],
// Agent domain:spawn_agent 单列,编排委派基本盘成员(子代理侧白名单钳制兜底安全)。
ToolDomain::Agent => &["spawn_agent"],
ToolDomain::Http => &["http_request"], ToolDomain::Http => &["http_request"],
// DataReadOnly:Data domain 的只读子集。 // DataReadOnly:Data domain 的只读子集。
// 源于 Code 意图收敛需求:用户在编码场景说"先 list_projects 看下项目结构" // 源于 Code 意图收敛需求:用户在编码场景说"先 list_projects 看下项目结构"
@@ -440,7 +445,8 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。 /// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。
/// ///
/// 设计(2026-09-30 起):基本盘 + 场景增量。 /// 设计(2026-09-30 起):基本盘 + 场景增量。
/// 基本盘 = File + Exec:读、写、执行命令是 agent 基本能力,不受意图收敛限制(用户拍板), /// 基本盘 = File + Exec + Agent:读、写、执行命令、委派子代理是 agent 基本能力,
/// 不受意图收敛限制(用户拍板),
/// 所有非空子集意图(Code/Debug/File/Project/Task/Idea/Search/Http)都可见。 /// 所有非空子集意图(Code/Debug/File/Project/Task/Idea/Search/Http)都可见。
/// 安全防线在执行层:validate_path 黑名单 + 授权三道门 + RiskLevel 审批,藏工具≠安全; /// 安全防线在执行层:validate_path 黑名单 + 授权三道门 + RiskLevel 审批,藏工具≠安全;
/// 藏工具只会复现断手(Project 砍 File 致 read_file 不可见、Debug 独占 run_command 致 /// 藏工具只会复现断手(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> { pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
// 基本盘:读写执行(RiskLevel/审批/路径授权兜底,藏工具≠安全);场景增量=业务域收敛减少分心 // 基本盘:读写执行 + 编排委派(RiskLevel/审批/路径授权/子代理白名单钳制兜底,藏工具≠安全);
const BASE_DOMAINS: &[ToolDomain] = &[ToolDomain::File, ToolDomain::Exec]; // 场景增量=业务域收敛减少分心
const BASE_DOMAINS: &[ToolDomain] = &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Agent];
let extra: &[ToolDomain] = match intent { let extra: &[ToolDomain] = match intent {
Intent::Code => &[ToolDomain::Http, ToolDomain::DataReadOnly], Intent::Code => &[ToolDomain::Http, ToolDomain::DataReadOnly],
Intent::Debug => &[ToolDomain::Http, ToolDomain::Data], Intent::Debug => &[ToolDomain::Http, ToolDomain::Data],
+6 -3
View File
@@ -39,7 +39,7 @@ use df_storage::models::AiProviderRecord;
// ProviderAcquire 已随 stream_round 迁至 stream_relay.rs。 // ProviderAcquire 已随 stream_round 迁至 stream_relay.rs。
use crate::state::{AppState, LlmConcurrency}; 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; use super::event_bus::emit_ai_chat_event;
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。 // compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
use super::conversation::{save_conversation, TokenAccumulator}; use super::conversation::{save_conversation, TokenAccumulator};
@@ -361,6 +361,9 @@ pub(crate) mod stall_detector;
// (纯搬运语义等价,锁边界/emit 顺序逐字保留,见 stream_relay.rs 文件头)。 // (纯搬运语义等价,锁边界/emit 顺序逐字保留,见 stream_relay.rs 文件头)。
// ============================================================ // ============================================================
pub(crate) mod stream_relay; 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 stream_relay::{merge_volatile_tail, stream_round, RoundOutcome};
pub(crate) use router_setup::resolve_model_with_override; pub(crate) use router_setup::resolve_model_with_override;
pub(crate) use stall_detector::{ pub(crate) use stall_detector::{
@@ -1644,8 +1647,8 @@ async fn process_round_tools(
) -> ToolsOutcome { ) -> ToolsOutcome {
// AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。 // AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。
let round_tool_call_count = tool_calls_acc.len(); let round_tool_call_count = tool_calls_acc.len();
// 处理工具调用(Low 自动执行 / Medium+High 待审批) // 处理工具调用(Low 自动执行 / Medium+High 待审批;主对话恒 Normal 策略,子循环走 SubAgent)
let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id).await; 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, // DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug,
// 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。 // 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。
tracing::debug!( tracing::debug!(
@@ -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<String>) -> 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<std::sync::Mutex<HashMap<String, Vec<String>>>>;
/// 构造空注册表(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<String> {
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<String>, Vec<String>) {
let low_set: std::collections::BTreeSet<String> = 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<String> = if requested.is_empty() {
low_set.iter().cloned().collect()
} else {
requested
.iter()
.filter(|n| low_set.contains(*n))
.cloned()
.collect()
};
let allowed: Vec<String> = base
.into_iter()
.filter(|n| n != "spawn_agent")
.collect();
let trimmed: Vec<String> = 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<String> {
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<Mutex<crate::commands::ai::AiSession>>,
tools_arc: Arc<AiToolRegistry>,
db: Arc<Database>,
app_handle: AppHandle,
parent_conv_id: String,
child_conv_id: String,
system_prompt: String,
task_prompt: String,
tool_defs: Vec<ToolDefinition>,
max_rounds: u32,
model_override: Option<String>,
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<Mutex<crate::commands::ai::AiSession>>,
tools_arc: Arc<AiToolRegistry>,
db: Arc<Database>,
app_handle: AppHandle,
parent_conv_id: String,
child_conv_id: String,
system_prompt: String,
task_prompt: String,
tool_defs: Vec<ToolDefinition>,
max_rounds: u32,
model_override: Option<String>,
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<String> = None;
let mut provider_saturation: HashMap<String, usize> = HashMap::new();
let mut fail_window: VecDeque<String> = 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<Mutex<crate::commands::ai::AiSession>>,
child_id: &str,
parent_id: &str,
) -> Option<String> {
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<Mutex<crate::commands::ai::AiSession>>,
child_id: &str,
) -> Option<String> {
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(&reg, &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(&reg, &[]);
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(&reg, "parent", "parent::agent-1");
sub_agents_register(&reg, "parent", "parent::agent-2");
sub_agents_register(&reg, "parent", "parent::agent-1"); // 幂等
assert_eq!(
sub_agents_children(&reg, "parent"),
vec!["parent::agent-1".to_string(), "parent::agent-2".to_string()]
);
assert!(sub_agents_children(&reg, "other").is_empty(), "无条目父返空");
sub_agents_remove(&reg, "parent", "parent::agent-1");
assert_eq!(
sub_agents_children(&reg, "parent"),
vec!["parent::agent-2".to_string()]
);
sub_agents_remove(&reg, "parent", "parent::agent-2");
// 父条目空后整条清除,不残留空键
{
let map = reg.lock().unwrap();
assert!(map.is_empty(), "父条目空应整条清除");
}
// 注销不存在的条目不 panic
sub_agents_remove(&reg, "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("审批通道"), "应说明无审批通道(防挂死设计的前提告知)");
}
}
+97 -2
View File
@@ -96,6 +96,57 @@ pub(crate) use idea_source::maybe_fill_idea_source;
// 工具调用心跳 + 审批处理 // 工具调用心跳 + 审批处理
// ============================================================ // ============================================================
/// 工具执行策略(子代理 v1 防挂死的核心闸门)。
///
/// Normal:主对话既有流程(Low 自动执行 / Medium+High 进审批门控 / 路径授权挂起等人工介入)。
/// SubAgent:子代理无审批通道也无用户交互面,任何需要人工介入的路径(路径授权挂起/
/// 黑名单拒绝/Medium/High 风险)一律就地产出失败 tool_result,绝不 insert pending。
/// 挂起对主对话是「等用户」,对子代理是永久停摆(没人能审批它),故必须就地拒绝。
/// 调用方(spawn_agent 子循环)断言本策略下返回的 pending 恒为 0,非 0 即内部错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToolExecPolicy {
/// 主对话默认策略(行为与历史版本完全一致)。
Normal,
/// 子代理策略(需人工介入的路径全部就地拒绝,零挂起)。
SubAgent,
}
/// SubAgent 策略的就地拒绝单点:失败 tool_result(结构化 envelope,断路器可判)+
/// emit AiToolCallCompleted + 审计留痕(decided_by=sub_agent_denied)。
/// 不 insert pending_approvals(子代理无审批通道,挂起即永久停摆)。
async fn deny_sub_agent_tool(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
draft: &ToolCallDraft,
tools_arc: &Arc<AiToolRegistry>,
audit_repo: &AiToolExecutionRepo,
reason: &str,
current_message_id: Option<&str>,
app_handle: &AppHandle,
) {
let msg = serde_json::json!({
"status": "rejected",
"reason": "sub_agent_denied",
"message": format!("子代理无权执行该工具({}):仅低风险只读且路径已授权的工具可在子代理中自动执行,如确需该操作请写入报告交由主会话完成", reason)
}).to_string();
// 短 lock 段:push tool_result(纯写,无 await)
{
let __lock_t = std::time::Instant::now();
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg));
log_slow_lock(__lock_t, "process_tool_calls:deny_sub_agent");
}
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(msg.clone()),
conversation_id: Some(conv_id.to_string()),
};
emit_ai_chat_event(app_handle, ev);
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await;
}
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控 /// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
/// 返回待审批的工具数量(0 = 全部自动执行完成) /// 返回待审批的工具数量(0 = 全部自动执行完成)
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs /// 工具执行 + 心跳保活: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 分支从 args.timeout_secs 取值(clamp [60,600],默认 60,与 tool_registry.rs:2707
// run_command 内部 clamp 同源),其余工具仍 60s。run_command 内部还有自己的子 timeout, // run_command 内部 clamp 同源),其余工具仍 60s。run_command 内部还有自己的子 timeout,
// 外层只需 >= 内部 timeout 即不抢断(run_command 内部超时会返带语义的 tool_result,优于外层裸中止)。 // 外层只需 >= 内部 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") args.get("timeout_secs")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(60) .unwrap_or(60)
@@ -180,6 +236,12 @@ async fn execute_with_heartbeat(
None None
}; };
let exec_fut = crate::commands::ai::command_stream::scope(sink, tools.execute(name, args)); 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 { match tokio::time::timeout(Duration::from_secs(outer_secs), exec_fut).await {
Ok(result) => result, Ok(result) => result,
Err(_elapsed) => { Err(_elapsed) => {
@@ -202,7 +264,10 @@ pub(crate) async fn process_tool_calls(
db: &Arc<Database>, db: &Arc<Database>,
app_handle: &AppHandle, app_handle: &AppHandle,
conv_id: &str, conv_id: &str,
policy: ToolExecPolicy,
) -> usize { ) -> usize {
// 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档)
let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent);
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect(); let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
tc_list.sort_unstable_by_key(|(i, _)| *i); tc_list.sort_unstable_by_key(|(i, _)| *i);
// 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶, // 治本兜底: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 循环) // 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new(); let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
let mut path_denied: Vec<(ToolCallDraft, String)> = 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(); let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
for (idx, draft, args) in drafts { for (idx, draft, args) in drafts {
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空), // 会话级临时授权目录在 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) { match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_auth_bypass) {
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)), FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
FileToolAuthOutcome::NeedsAuth(req) => { FileToolAuthOutcome::NeedsAuth(req) => {
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
if is_sub_agent {
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string()));
continue;
}
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。 // all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
// 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。 // 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。
// 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。 // 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。
@@ -351,7 +423,13 @@ pub(crate) async fn process_tool_calls(
path_auth_pending.push((draft, args, req)); 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; let drafts = authorized_drafts;
@@ -388,6 +466,14 @@ pub(crate) async fn process_tool_calls(
).await; ).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)。 // Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。 // 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。 // 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(); let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
for (_, draft, args) in drafts { for (_, draft, args) in drafts {
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); 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 目录列举绕行检测(最保守版,只警告不硬拒)。 // AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录, // 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常 // 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
+27 -2
View File
@@ -1018,14 +1018,27 @@ pub async fn ai_approve(
)) ))
} else { None }; } else { None };
let exec_fut = super::super::command_stream::scope(sink, state.ai_tools.execute(&approval.tool_name, args.clone())); 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( match tokio::time::timeout(
std::time::Duration::from_secs(60), std::time::Duration::from_secs(approve_outer_secs),
exec_fut, exec_fut,
).await { ).await {
Ok(r) => r, Ok(r) => r,
// 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。 // 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。
// 仅对 run_command 追加(其余工具无 shell 适配问题),避免污染其他工具错误语义。 // 仅对 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) { if approval.tool_name == "run_command" && cfg!(windows) {
"\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)" "\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)"
} else { "" })), } else { "" })),
@@ -1965,6 +1978,12 @@ pub async fn ai_chat_force_send(
conv.notify.notify_one(); conv.notify.notify_one();
conv.loop_epoch.fetch_add(1, Ordering::SeqCst) + 1 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,无生成态拦截—— // ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截——
// 这是"强制"语义本身,前面已主动复位) // 这是"强制"语义本身,前面已主动复位)
let conv = session.conv(&target); let conv = session.conv(&target);
@@ -2176,6 +2195,12 @@ pub async fn ai_chat_stop(
conv.stop_flag.store(true, Ordering::SeqCst); conv.stop_flag.store(true, Ordering::SeqCst);
conv.notify.clone() 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!, // 置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!,
// 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。 // 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。
// Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。 // Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。
@@ -630,6 +630,12 @@ pub async fn ai_conversation_delete(
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已删除"); super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已删除");
// 阶段3a 单真相源合并:单表 retain(kind 不区分)。 // 阶段3a 单真相源合并:单表 retain(kind 不区分)。
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); 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 不存在即退出)。 // 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。
session.per_conv.remove(&conversation_id); session.per_conv.remove(&conversation_id);
// 顺带清理该 conv 的 save 串行锁条目(防 HashMap 无限增长)。 // 顺带清理该 conv 的 save 串行锁条目(防 HashMap 无限增长)。
+17
View File
@@ -222,6 +222,23 @@ pub enum AiChatEvent {
/// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮 /// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮
/// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false) /// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false)
AiMaxRoundsReached { conversation_id: Option<String> }, AiMaxRoundsReached { conversation_id: Option<String> },
/// 子代理结束(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<String>,
summary: String,
status: String,
rounds: u32,
tokens: u64,
},
/// 流式调用自动重试提示流前失败重试中) /// 流式调用自动重试提示流前失败重试中)
/// ///
/// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。 /// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。
+52 -9
View File
@@ -516,6 +516,7 @@ pub fn build_ai_tool_registry(
data_dir: PathBuf, data_dir: PathBuf,
get_app_config_ctx: super::get_app_config::GetAppConfigCtx, get_app_config_ctx: super::get_app_config::GetAppConfigCtx,
local_proxy_ctx: super::tools::local_proxy::LocalProxyCtx, local_proxy_ctx: super::tools::local_proxy::LocalProxyCtx,
spawn_agent_ctx: super::tools::spawn_agent::SpawnAgentCtx,
) -> AiToolRegistry { ) -> AiToolRegistry {
let mut registry = AiToolRegistry::new(); let mut registry = AiToolRegistry::new();
register_data_tools(&mut registry, db); 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_get_app_config_tool(&mut registry, get_app_config_ctx);
register_local_proxy_tools(&mut registry, local_proxy_ctx); register_local_proxy_tools(&mut registry, local_proxy_ctx);
register_read_skill_tool(&mut registry); register_read_skill_tool(&mut registry);
register_spawn_agent_tool(&mut registry, spawn_agent_ctx);
registry 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 委托。 /// read_skill AI 工具注册(1 个:按名读本机 Claude 技能规程正文,只读)— thin 委托。
/// ///
/// 工具职责:LLM 主动调 read_skill(name) 获取技能 SKILL.md 剥 frontmatter 正文, /// 工具职责:LLM 主动调 read_skill(name) 获取技能 SKILL.md 剥 frontmatter 正文,
@@ -959,6 +978,7 @@ pub(crate) fn ensure_tool_meta_seeded() {
PathBuf::from(""), PathBuf::from(""),
make_test_get_app_config_ctx(&db), make_test_get_app_config_ctx(&db),
make_test_local_proxy_ctx(), 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<Database>) -> 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1353,7 +1388,7 @@ mod tests {
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。 // 任一层漏移 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, /// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。 // 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
@@ -1364,7 +1399,7 @@ mod tests {
// F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参, // F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参,
// 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。 // 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_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)。拆分前后必须一致。 // 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
// file 层 10→11(新增 grep 跨文件内容搜索工具)。 // file 层 10→11(新增 grep 跨文件内容搜索工具)。
@@ -1428,10 +1463,14 @@ mod tests {
// 与 / 技能注入链共用 skills.rs 进程内缓存;未命中返明确错误,LLM 可向用户澄清。 // 与 / 技能注入链共用 skills.rs 进程内缓存;未命中返明确错误,LLM 可向用户澄清。
// 声明式注册在 tools/read_skill.rs,register_read_skill_tool thin 委托)。 // 声明式注册在 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。 // 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!( assert_eq!(
registry.len(), registry.len(),
77, 78,
"工具总数应为 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(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 / 漏注册 / 误删除。 // 工具名集合基线:防 rename / 漏注册 / 误删除。
@@ -1499,6 +1538,9 @@ mod tests {
// ── read_skill 层 (1) ──(按名读本机 Claude 技能规程正文,只读 Low, // ── read_skill 层 (1) ──(按名读本机 Claude 技能规程正文,只读 Low,
// 与 / 技能注入链共用 skills.rs 进程内缓存,声明式注册 tools/read_skill.rs) // 与 / 技能注入链共用 skills.rs 进程内缓存,声明式注册 tools/read_skill.rs)
"read_skill", "read_skill",
// ── spawn_agent 层 (1) ──(派发子代理隔离上下文执行专项任务,Medium 审批档,
// 独立 agentic 循环 agentic/sub_agent.rs,声明式注册 tools/spawn_agent.rs)
"spawn_agent",
]; ];
expected.sort_unstable(); expected.sort_unstable();
@@ -1718,7 +1760,7 @@ mod tests {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(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 canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file, "limit": 15 }); let args = serde_json::json!({ "path": canon_file, "limit": 15 });
let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); 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 = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(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 canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file }); let args = serde_json::json!({ "path": canon_file });
let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
@@ -1824,7 +1866,7 @@ mod tests {
persistent.insert(tmp.clone()); persistent.insert(tmp.clone());
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() })); 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 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 canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file }); let args = serde_json::json!({ "path": canon_file });
@@ -1853,7 +1895,7 @@ mod tests {
persistent.insert(tmp.clone()); persistent.insert(tmp.clone());
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() })); 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 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 canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
let args = serde_json::json!({ "path": canon_file }); let args = serde_json::json!({ "path": canon_file });
@@ -2335,7 +2377,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_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));
(db, registry) (db, registry)
} }
@@ -2452,6 +2494,7 @@ mod tests {
let registry = build_ai_tool_registry( let registry = build_ai_tool_registry(
&db, &allowed_dirs, PathBuf::from(""), &db, &allowed_dirs, PathBuf::from(""),
make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(),
make_test_spawn_agent_ctx(&db),
); );
ensure_tool_meta_seeded(); ensure_tool_meta_seeded();
+3
View File
@@ -27,3 +27,6 @@ pub mod file;
pub mod download_file; pub mod download_file;
pub mod entity_resolve; pub mod entity_resolve;
pub mod read_skill; pub mod read_skill;
// spawn_agent 子代理派发(独立 agentic 循环入口,audit 与 ai_approve 经本模块
// scope_caller_conv 注入父会话 task-local,故需 pub 可达)。
pub mod spawn_agent;
@@ -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<Mutex<AiSession>>,
/// 会话生成态存储(子循环 guard 经 AppHandle 侧使用,此处预留一致生命周期;
/// 与 AppState.conv_states 共享同一 Arc,随子代理消费场景接入启用)
#[allow(dead_code)]
pub conv_states: Arc<ConvStateStore>,
/// LLM 并发控制(子循环 global + per_conv(child) 双份额)
pub llm_concurrency: LlmConcurrency,
/// 数据库句柄(子循环 provider 解析)
pub db: Arc<Database>,
/// 级联取消注册表(parent→children,与 AppState.sub_agent_registry 共享同一 Arc)
pub sub_agents: SubAgentRegistry,
}
// ── 进程级 OnceLock:工具注册表 + AppHandle(注册期无法捕获,运行期注入,见模块头) ──
static GLOBAL_SPAWN_TOOL_REGISTRY: std::sync::OnceLock<Arc<AiToolRegistry>> =
std::sync::OnceLock::new();
static GLOBAL_APP: std::sync::OnceLock<tauri::AppHandle> = std::sync::OnceLock::new();
/// state.rs init 注入工具注册表(白名单钳制数据源;重复注入忽略,首次生效)。
pub(crate) fn init_global_tool_registry(registry: Arc<AiToolRegistry>) {
let _ = GLOBAL_SPAWN_TOOL_REGISTRY.set(registry);
}
/// handler 读取工具注册表(未初始化返 None,handler 报明确错误)。
fn tool_registry() -> Option<Arc<AiToolRegistry>> {
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<tauri::AppHandle> {
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<F, R>(conv_id: String, fut: F) -> R
where
F: std::future::Future<Output = R>,
{
CALLER_CONV.scope(conv_id, fut).await
}
/// handler 读取发起调用的父会话 id(未经 scope 直调时返 None,调用方走兜底)。
pub(crate) fn caller_conv() -> Option<String> {
CALLER_CONV.try_with(|c| c.clone()).ok()
}
// ── 参数解析与任务书组装(纯函数,单测锁定) ──
/// 解析 max_rounds:缺省默认 6,合法域钳制 [1, 20](防参数放大预算失控)。
fn parse_max_rounds(raw: Option<u64>) -> 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<task>\n");
p.push_str(task);
p.push_str("\n</task>\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<serde_json::Value> {
// 参数解析
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<String> = 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(&registry, &requested);
let tool_defs: Vec<ToolDefinition> = 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("<task>\n统计 crates 下 TODO 数量\n</task>"), "任务正文应入 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"));
}
}
+3
View File
@@ -84,6 +84,9 @@ pub fn run() {
// L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state // L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state
let session_for_handshake = app_state.ai_session.clone(); let session_for_handshake = app_state.ai_session.clone();
app.manage(app_state); 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 + 托盘常驻 + 单实例 ── // ── Phase3: 内嵌 HTTP MCP server + 托盘常驻 + 单实例 ──
// 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。 // 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。
+32 -2
View File
@@ -244,6 +244,14 @@ pub struct AppState {
/// workspace_root 始终在白名单(向后兼容)。 /// workspace_root 始终在白名单(向后兼容)。
/// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。 /// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。
pub allowed_dirs: Arc<RwLock<AllowedDirs>>, pub allowed_dirs: Arc<RwLock<AllowedDirs>>,
/// 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)── // ── 跨端隧道(Phase3 Layer1)──
/// 桌面端出站 WS 隧道客户端,连 df-relay(/ws/device)透传 ai_event_bus → miniapp /// 桌面端出站 WS 隧道客户端,连 df-relay(/ws/device)透传 ai_event_bus → miniapp
/// + 转发 miniapp 下行指令 → remote_bridge。lib.rs setup spawn task 解析 /// + 转发 miniapp 下行指令 → remote_bridge。lib.rs setup spawn task 解析
@@ -337,9 +345,26 @@ impl AppState {
gateway_url: std::env::var("DF_LOCAL_PROXY_BASE") gateway_url: std::env::var("DF_LOCAL_PROXY_BASE")
.unwrap_or_else(|_| "http://127.0.0.1:8899".to_string()), .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( 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, &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。 // Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。
// resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>), // resolver 持 Arc<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>),
// 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。 // 在 struct 字段 `db` move 前 clone 注入,与 build_ai_tool_registry 同侧。
@@ -364,11 +389,13 @@ impl AppState {
ai_conversations: AiConversationRepo::new(&db), ai_conversations: AiConversationRepo::new(&db),
ai_messages: AiMessageRepo::new(&db), ai_messages: AiMessageRepo::new(&db),
ai_tool_executions: AiToolExecutionRepo::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。 // L3 批2b:AI 事件总线入 AppState(独立于工作流 event_bus)。emit 双写留批3。
ai_event_bus: crate::commands::ai::event_bus::EventBus::new(), ai_event_bus: crate::commands::ai::event_bus::EventBus::new(),
// B-Phase1: ConvStateStore 无锁存储(空构造;双轨期无写点,B-Phase2 写侧切入)。 // 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 // Phase3 Layer1:tunnel 客户端未连接实例,lib.rs setup 内 connect
tunnel: std::sync::Arc::new(df_tunnel::WsTunnelClient::new()), tunnel: std::sync::Arc::new(df_tunnel::WsTunnelClient::new()),
knowledge: KnowledgeRepo::new(&db), knowledge: KnowledgeRepo::new(&db),
@@ -384,6 +411,9 @@ impl AppState {
workflow_state_registry: Arc::new(Mutex::new(HashMap::new())), workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
// F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄) // F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄)
allowed_dirs: allowed_dirs.clone(), 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, db,
event_bus: EventBus::new(), event_bus: EventBus::new(),
registry, registry,
+12
View File
@@ -133,6 +133,18 @@ impl LlmConcurrency {
sema.acquire_owned().await.expect("llm global semaphore closed") 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<tokio::sync::OwnedSemaphorePermit> {
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)。 /// 取单对话内并发 permit(F-09 B 批5):按 conv_id 取/建 Semaphore,permits=当前 per_conv_permits(默认 2)。
/// lock HashMap → 无则建 → clone Arc → 释放 lock → acquire_owned。锁持有短(不含 await acquire)。 /// lock HashMap → 无则建 → clone Arc → 释放 lock → acquire_owned。锁持有短(不含 await acquire)。
pub async fn acquire_per_conv(&self, conv_id: &str) -> tokio::sync::OwnedSemaphorePermit { pub async fn acquire_per_conv(&self, conv_id: &str) -> tokio::sync::OwnedSemaphorePermit {