新增: spawn_agent 子代理派发与级联取消
- 纯内存子会话隔离上下文,一轮回传结论报告 - 白名单钳制低风险并恒剔 spawn_agent 防递归 - 子代理策略下高风险自动拒绝,不挂审批防卡死 - 轮数令牌挂钟三重熔断 - 父停止与删会话级联取消子代理 - AiAgentFinished 事件挂父会话
This commit is contained in:
@@ -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("审批通道"), "应说明无审批通道(防挂死设计的前提告知)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user