新增: spawn_agent 子代理派发与级联取消
- 纯内存子会话隔离上下文,一轮回传结论报告 - 白名单钳制低风险并恒剔 spawn_agent 防递归 - 子代理策略下高风险自动拒绝,不挂审批防卡死 - 轮数令牌挂钟三重熔断 - 父停止与删会话级联取消子代理 - AiAgentFinished 事件挂父会话
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(®, &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("审批通道"), "应说明无审批通道(防挂死设计的前提告知)");
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,57 @@ pub(crate) use idea_source::maybe_fill_idea_source;
|
||||
// 工具调用心跳 + 审批处理
|
||||
// ============================================================
|
||||
|
||||
/// 工具执行策略(子代理 v1 防挂死的核心闸门)。
|
||||
///
|
||||
/// Normal:主对话既有流程(Low 自动执行 / Medium+High 进审批门控 / 路径授权挂起等人工介入)。
|
||||
/// SubAgent:子代理无审批通道也无用户交互面,任何需要人工介入的路径(路径授权挂起/
|
||||
/// 黑名单拒绝/Medium/High 风险)一律就地产出失败 tool_result,绝不 insert pending。
|
||||
/// 挂起对主对话是「等用户」,对子代理是永久停摆(没人能审批它),故必须就地拒绝。
|
||||
/// 调用方(spawn_agent 子循环)断言本策略下返回的 pending 恒为 0,非 0 即内部错误。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ToolExecPolicy {
|
||||
/// 主对话默认策略(行为与历史版本完全一致)。
|
||||
Normal,
|
||||
/// 子代理策略(需人工介入的路径全部就地拒绝,零挂起)。
|
||||
SubAgent,
|
||||
}
|
||||
|
||||
/// SubAgent 策略的就地拒绝单点:失败 tool_result(结构化 envelope,断路器可判)+
|
||||
/// emit AiToolCallCompleted + 审计留痕(decided_by=sub_agent_denied)。
|
||||
/// 不 insert pending_approvals(子代理无审批通道,挂起即永久停摆)。
|
||||
async fn deny_sub_agent_tool(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
draft: &ToolCallDraft,
|
||||
tools_arc: &Arc<AiToolRegistry>,
|
||||
audit_repo: &AiToolExecutionRepo,
|
||||
reason: &str,
|
||||
current_message_id: Option<&str>,
|
||||
app_handle: &AppHandle,
|
||||
) {
|
||||
let msg = serde_json::json!({
|
||||
"status": "rejected",
|
||||
"reason": "sub_agent_denied",
|
||||
"message": format!("子代理无权执行该工具({}):仅低风险只读且路径已授权的工具可在子代理中自动执行,如确需该操作请写入报告交由主会话完成", reason)
|
||||
}).to_string();
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg));
|
||||
log_slow_lock(__lock_t, "process_tool_calls:deny_sub_agent");
|
||||
}
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
emit_ai_chat_event(app_handle, ev);
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await;
|
||||
}
|
||||
|
||||
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
|
||||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||||
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
|
||||
@@ -159,7 +210,12 @@ async fn execute_with_heartbeat(
|
||||
// run_command 分支从 args.timeout_secs 取值(clamp [60,600],默认 60,与 tool_registry.rs:2707
|
||||
// run_command 内部 clamp 同源),其余工具仍 60s。run_command 内部还有自己的子 timeout,
|
||||
// 外层只需 >= 内部 timeout 即不抢断(run_command 内部超时会返带语义的 tool_result,优于外层裸中止)。
|
||||
let outer_secs: u64 = if name == "run_command" {
|
||||
let outer_secs: u64 = if name == "spawn_agent" {
|
||||
// 子代理:外层取子循环挂钟预算(SUB_AGENT_WALL_TIMEOUT_SECS)加缓冲。
|
||||
// 外层必须晚于子循环自身三重熔断(轮次/token/挂钟)触发,让超预算的子代理
|
||||
// 以截断报告优雅收尾,而非被外层硬腰斩成裸超时错误(报告蒸发)。
|
||||
crate::commands::ai::agentic::sub_agent::SUB_AGENT_WALL_TIMEOUT_SECS + 60
|
||||
} else if name == "run_command" {
|
||||
args.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(60)
|
||||
@@ -180,6 +236,12 @@ async fn execute_with_heartbeat(
|
||||
None
|
||||
};
|
||||
let exec_fut = crate::commands::ai::command_stream::scope(sink, tools.execute(name, args));
|
||||
// 父会话 task-local 注入:spawn_agent handler 经 caller_conv() 定位发起调用的父会话
|
||||
// (直调防御兜底用)。与上方 command_stream 的 SINK scope 嵌套,两个 task-local 互不干扰。
|
||||
let exec_fut = crate::commands::ai::tools::spawn_agent::scope_caller_conv(
|
||||
conv_id.to_string(),
|
||||
exec_fut,
|
||||
);
|
||||
match tokio::time::timeout(Duration::from_secs(outer_secs), exec_fut).await {
|
||||
Ok(result) => result,
|
||||
Err(_elapsed) => {
|
||||
@@ -202,7 +264,10 @@ pub(crate) async fn process_tool_calls(
|
||||
db: &Arc<Database>,
|
||||
app_handle: &AppHandle,
|
||||
conv_id: &str,
|
||||
policy: ToolExecPolicy,
|
||||
) -> usize {
|
||||
// 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档)
|
||||
let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent);
|
||||
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
|
||||
tc_list.sort_unstable_by_key(|(i, _)| *i);
|
||||
// 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶,
|
||||
@@ -335,6 +400,8 @@ pub(crate) async fn process_tool_calls(
|
||||
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
|
||||
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
|
||||
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||
// SubAgent 策略:须授权/黑名单拒绝统一收集,后续就地拒绝(绝不挂起)
|
||||
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
|
||||
for (idx, draft, args) in drafts {
|
||||
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
|
||||
@@ -342,6 +409,11 @@ pub(crate) async fn process_tool_calls(
|
||||
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_auth_bypass) {
|
||||
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
|
||||
FileToolAuthOutcome::NeedsAuth(req) => {
|
||||
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
|
||||
if is_sub_agent {
|
||||
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string()));
|
||||
continue;
|
||||
}
|
||||
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
|
||||
// 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。
|
||||
// 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。
|
||||
@@ -351,7 +423,13 @@ pub(crate) async fn process_tool_calls(
|
||||
path_auth_pending.push((draft, args, req));
|
||||
}
|
||||
}
|
||||
FileToolAuthOutcome::Denied(reason) => path_denied.push((draft, reason)),
|
||||
FileToolAuthOutcome::Denied(reason) => {
|
||||
if is_sub_agent {
|
||||
sub_agent_path_denied.push((draft, reason));
|
||||
} else {
|
||||
path_denied.push((draft, reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let drafts = authorized_drafts;
|
||||
@@ -388,6 +466,14 @@ pub(crate) async fn process_tool_calls(
|
||||
).await;
|
||||
}
|
||||
|
||||
// SubAgent 策略:须授权/黑名单拒绝就地产出失败 tool_result(零挂起,防子循环永久停摆)。
|
||||
for (draft, reason) in sub_agent_path_denied {
|
||||
deny_sub_agent_tool(
|
||||
session_arc, conv_id, &draft, tools_arc, &audit_repo,
|
||||
&reason, current_message_id, app_handle,
|
||||
).await;
|
||||
}
|
||||
|
||||
// Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
|
||||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
||||
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
||||
@@ -499,6 +585,15 @@ pub(crate) async fn process_tool_calls(
|
||||
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
|
||||
for (_, draft, args) in drafts {
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
// SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。
|
||||
// 白名单钳制已使子代理 LLM 只见 Low 工具定义,此处拦的是幻觉调用非白名单工具的残面。
|
||||
if is_sub_agent && risk_level != RiskLevel::Low {
|
||||
deny_sub_agent_tool(
|
||||
session_arc, conv_id, &draft, tools_arc, &audit_repo,
|
||||
"工具风险等级超出子代理权限", current_message_id, app_handle,
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
|
||||
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
|
||||
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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 无限增长)。
|
||||
|
||||
@@ -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<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」。
|
||||
|
||||
@@ -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<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)]
|
||||
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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(®istry, &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"));
|
||||
}
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
+32
-2
@@ -244,6 +244,14 @@ pub struct AppState {
|
||||
/// workspace_root 始终在白名单(向后兼容)。
|
||||
/// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。
|
||||
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)──
|
||||
/// 桌面端出站 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<Database>(非 AppState,避免循环依赖:AppState 持 Arc<ResolverRegistry>),
|
||||
// 在 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,
|
||||
|
||||
@@ -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<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)。
|
||||
/// lock HashMap → 无则建 → clone Arc → 释放 lock → acquire_owned。锁持有短(不含 await acquire)。
|
||||
pub async fn acquire_per_conv(&self, conv_id: &str) -> tokio::sync::OwnedSemaphorePermit {
|
||||
|
||||
Reference in New Issue
Block a user