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

- 纯内存子会话隔离上下文,一轮回传结论报告
- 白名单钳制低风险并恒剔 spawn_agent 防递归
- 子代理策略下高风险自动拒绝,不挂审批防卡死
- 轮数令牌挂钟三重熔断
- 父停止与删会话级联取消子代理
- AiAgentFinished 事件挂父会话
This commit is contained in:
lxy
2026-10-01 19:35:21 +08:00
parent 524120b110
commit 2187ffe173
13 changed files with 1407 additions and 21 deletions
@@ -0,0 +1,347 @@
//! spawn_agent AI 工具声明式注册(派发子代理,隔离上下文执行专项任务,报告经返回值交付)。
//!
//! 架构:子代理 = 纯内存子会话 + 独立 agentic 循环(agentic/sub_agent.rs),本工具是唯一入口:
//! 1. 解析参数(task/tools/max_rounds/model),白名单钳制写死在入口(tools ∩ Low 风险 ∖ spawn_agent);
//! 2. 组装任务书(父上下文头 + 白名单说明 + 被裁剪尾注),注册级联取消条目(parent→children);
//! 3. 同步等待子循环跑完,报告作为 tool_result 返回值交付(Claude Code Task 语义同构)。
//!
//! ## 自引用绕法(两条进程级 OnceLock,先例 GLOBAL_TOOL_META)
//! - 工具注册表:白名单钳制需要 AiToolRegistry 自身,而注册期无法捕获自身 Arc,
//! state.rs init 在 build_ai_tool_registry 返回后调 init_global_tool_registry 注入;
//! - AppHandle:子循环 emit/guard 需要,lib.rs setup 阶段调 init_app_handle 注入。
//!
//! ## 父会话定位
//! 工具 handler 签名只收 args,父 loop 的 conv_id 经 task-local 注入(复用 command_stream
//! 的 task-local 范式):process_tool_calls / ai_approve 执行工具前 scope_caller_conv 注入,
//! handler 内 caller_conv() 读取;缺省兜底 active_conversation_id(直调防御)。
use std::sync::Arc;
use tokio::sync::Mutex;
use df_ai::ai_tools::{
AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta,
};
use df_ai::declare_tool;
use df_ai::provider::ToolDefinition;
use crate::commands::ai::agentic::conv_state::ConvStateStore;
use crate::commands::ai::agentic::sub_agent::{
clamp_tool_whitelist, next_child_id, run_sub_agent_loop, sub_agent_system_prompt,
sub_agents_register, sub_agents_remove, SubAgentRegistry,
SUB_AGENT_DEFAULT_MAX_ROUNDS, SUB_AGENT_MAX_ROUNDS_CAP,
};
use crate::commands::ai::AiSession;
use crate::state::LlmConcurrency;
use df_storage::db::Database;
/// spawn_agent 工具捕获上下文(Arc 全字段,Clone 廉价;先例 GetAppConfigCtx)。
///
/// 各 Arc 由 state.rs init 在 build_ai_tool_registry **之前**提前构建并 clone 注入,
/// AppState struct 字段复用同一 Arc(注册先于 ai_session 构建的自引用绕法)。
/// derive Clone:declare_tool! 把捕获变量 clone 进工具闭包。
#[derive(Clone)]
pub struct SpawnAgentCtx {
/// 父/子会话状态(读父 model_override / active_conversation_id,子会话建条)
pub session: Arc<Mutex<AiSession>>,
/// 会话生成态存储(子循环 guard 经 AppHandle 侧使用,此处预留一致生命周期;
/// 与 AppState.conv_states 共享同一 Arc,随子代理消费场景接入启用)
#[allow(dead_code)]
pub conv_states: Arc<ConvStateStore>,
/// LLM 并发控制(子循环 global + per_conv(child) 双份额)
pub llm_concurrency: LlmConcurrency,
/// 数据库句柄(子循环 provider 解析)
pub db: Arc<Database>,
/// 级联取消注册表(parent→children,与 AppState.sub_agent_registry 共享同一 Arc)
pub sub_agents: SubAgentRegistry,
}
// ── 进程级 OnceLock:工具注册表 + AppHandle(注册期无法捕获,运行期注入,见模块头) ──
static GLOBAL_SPAWN_TOOL_REGISTRY: std::sync::OnceLock<Arc<AiToolRegistry>> =
std::sync::OnceLock::new();
static GLOBAL_APP: std::sync::OnceLock<tauri::AppHandle> = std::sync::OnceLock::new();
/// state.rs init 注入工具注册表(白名单钳制数据源;重复注入忽略,首次生效)。
pub(crate) fn init_global_tool_registry(registry: Arc<AiToolRegistry>) {
let _ = GLOBAL_SPAWN_TOOL_REGISTRY.set(registry);
}
/// handler 读取工具注册表(未初始化返 None,handler 报明确错误)。
fn tool_registry() -> Option<Arc<AiToolRegistry>> {
GLOBAL_SPAWN_TOOL_REGISTRY.get().cloned()
}
/// lib.rs setup 注入 AppHandle(子循环 emit/guard 所需;重复注入忽略,首次生效)。
pub(crate) fn init_app_handle(app: tauri::AppHandle) {
let _ = GLOBAL_APP.set(app);
}
/// handler 读取 AppHandle(未初始化返 None,handler 报明确错误)。
fn app_handle() -> Option<tauri::AppHandle> {
GLOBAL_APP.get().cloned()
}
// ── 父会话 task-local(复用 command_stream 的 task-local 范式) ──
tokio::task_local! {
static CALLER_CONV: String;
}
/// 在注入父会话 id 的作用域内执行 future(process_tool_calls / ai_approve 调用工具前包裹)。
pub(crate) async fn scope_caller_conv<F, R>(conv_id: String, fut: F) -> R
where
F: std::future::Future<Output = R>,
{
CALLER_CONV.scope(conv_id, fut).await
}
/// handler 读取发起调用的父会话 id(未经 scope 直调时返 None,调用方走兜底)。
pub(crate) fn caller_conv() -> Option<String> {
CALLER_CONV.try_with(|c| c.clone()).ok()
}
// ── 参数解析与任务书组装(纯函数,单测锁定) ──
/// 解析 max_rounds:缺省默认 6,合法域钳制 [1, 20](防参数放大预算失控)。
fn parse_max_rounds(raw: Option<u64>) -> u32 {
raw.map(|v| v as u32)
.unwrap_or(SUB_AGENT_DEFAULT_MAX_ROUNDS)
.clamp(1, SUB_AGENT_MAX_ROUNDS_CAP)
}
/// 组装子代理任务书:父上下文头 + 任务正文 + 工具白名单说明 + 被裁剪项尾注。
fn build_task_prompt(task: &str, allowed: &[String], trimmed: &[String]) -> String {
let mut p = String::new();
p.push_str("[父任务上下文] 你是主会话派生的子代理,以下是父任务交办的任务书。\n\n<task>\n");
p.push_str(task);
p.push_str("\n</task>\n\n");
if allowed.is_empty() {
p.push_str("可用工具:无(白名单为空),请直接基于推理输出结论报告。\n");
} else {
p.push_str(&format!(
"可用工具白名单(仅限以下工具,且需授权/高风险操作会被自动拒绝):{}\n",
allowed.join(", ")
));
}
if !trimmed.is_empty() {
p.push_str(&format!(
"(以下请求的工具因超出子代理权限或未注册被剔除,如确需请写入报告交由主会话完成:{})\n",
trimmed.join(", ")
));
}
p
}
/// 注册 spawn_agent 工具(RiskLevel::Medium,派发需用户审批,与 create_task 同档)。
pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) {
// schema 手写 JSON(task 必填 + 三个可选参数,描述面向 LLM)
let schema = serde_json::json!({
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "自包含任务书:背景/目标/完成标准。子代理看不到父对话,任务书必须自带全部所需上下文"
},
"tools": {
"type": "array",
"items": { "type": "string" },
"description": "可选,工具名白名单。仅低风险只读工具可保留,其余被剔除并在任务书尾注说明;缺省=全部低风险工具"
},
"max_rounds": {
"type": "integer",
"description": "可选,工具循环轮数上限,默认 6,最大 20"
},
"model": {
"type": "string",
"description": "可选,子代理用模型 id;缺省继承当前会话模型"
}
},
"required": ["task"]
});
declare_tool!(
registry,
ctx: SpawnAgentCtx,
"spawn_agent",
"派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 6);model 可选模型(缺省继承当前会话)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回",
RiskLevel::Medium,
meta: ToolMeta {
// 审批卡文案模板(模板 {} 按位置填充,键取 task)
approval_hint: Some(("派发子代理: {}", &["task"])),
display_verb: Some("派发"),
// G1 目标推理:任务书截断展示(Truncate 25 字符,对齐 run_command 风格)
goal: Some(GoalMeta {
verb: "派发子代理",
keys: &["task"],
style: GoalStyle::Truncate,
fallback: Some("派发子代理执行专项任务"),
}),
// G2 探索熔断签名:task 进签名,同任务书重复派发会被判重复打转熔断
sig: Some(SigMeta {
groups: &[(&["task"], SigValKind::Str)],
}),
..Default::default()
},
schema: schema,
args => {
spawn_agent_handler(&ctx, &args).await
}
);
}
/// spawn_agent handler:钳制白名单 → 注册级联条目 → 同步跑子循环 → 报告作返回值。
async fn spawn_agent_handler(
ctx: &SpawnAgentCtx,
args: &serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
// 参数解析
let task = args
.get("task")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("参数 task 不能为空:请传自包含任务书(背景/目标/完成标准)"))?;
let max_rounds = parse_max_rounds(args.get("max_rounds").and_then(|v| v.as_u64()));
let model = args
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let requested: Vec<String> = args
.get("tools")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
// 白名单钳制(写死在入口,不依赖 LLM 自觉)+ 工具定义投影
let registry = tool_registry()
.ok_or_else(|| anyhow::anyhow!("子代理工具注册表未就绪(应用初始化未完成)"))?;
let (allowed, trimmed) = clamp_tool_whitelist(&registry, &requested);
let tool_defs: Vec<ToolDefinition> = registry
.tool_definitions()
.into_iter()
.filter(|d| allowed.contains(&d.function.name))
.collect();
// 父会话定位:task-local 注入优先,兜底 active(直调防御)
let parent_conv_id = match caller_conv() {
Some(p) if !p.is_empty() => p,
_ => ctx
.session
.lock()
.await
.active_conversation_id
.clone()
.unwrap_or_default(),
};
if parent_conv_id.is_empty() {
return Err(anyhow::anyhow!("spawn_agent 无法定位父会话:只能在会话内调用"));
}
// model 不传 → 继承父会话模型 override(与父对话同模型语义)
let child_model = match model {
Some(m) => Some(m),
None => ctx
.session
.lock()
.await
.conv_read(&parent_conv_id)
.and_then(|c| c.model_override.clone()),
};
let child_id = next_child_id(&parent_conv_id);
let task_prompt = build_task_prompt(&task, &allowed, &trimmed);
let system_prompt = sub_agent_system_prompt();
// 级联注册(父停止/强制发送/删除会遍历 children 级联取消)
sub_agents_register(&ctx.sub_agents, &parent_conv_id, &child_id);
let app = match app_handle() {
Some(h) => h,
None => {
sub_agents_remove(&ctx.sub_agents, &parent_conv_id, &child_id);
return Err(anyhow::anyhow!("应用句柄未就绪(启动初始化未完成)"));
}
};
tracing::info!(
parent = %parent_conv_id,
agent = %child_id,
task_chars = task.chars().count(),
tools = allowed.len(),
max_rounds,
model = ?child_model,
"[sub-agent] 派发子代理"
);
// 同步等待子循环(报告经返回值交付;外层超时对 spawn_agent 取子代理挂钟预算同款上限)
let outcome = run_sub_agent_loop(
ctx.session.clone(),
registry.clone(),
ctx.db.clone(),
app,
parent_conv_id.clone(),
child_id.clone(),
system_prompt,
task_prompt,
tool_defs,
max_rounds,
child_model,
ctx.llm_concurrency.clone(),
)
.await;
// 级联注销(空父条目整条清除)
sub_agents_remove(&ctx.sub_agents, &parent_conv_id, &child_id);
Ok(serde_json::json!({
"status": outcome.status.as_str(),
"agent_id": child_id,
"rounds": outcome.rounds,
"tokens": outcome.tokens,
"report": outcome.summary,
}))
}
#[cfg(test)]
mod tests {
use super::*;
/// parse_max_rounds:缺省 6,合法域钳制 [1,20]。
#[test]
fn parse_max_rounds_clamps() {
assert_eq!(parse_max_rounds(None), SUB_AGENT_DEFAULT_MAX_ROUNDS);
assert_eq!(parse_max_rounds(Some(1)), 1);
assert_eq!(parse_max_rounds(Some(6)), 6);
assert_eq!(parse_max_rounds(Some(0)), 1, "0 钳到下界 1");
assert_eq!(parse_max_rounds(Some(999)), SUB_AGENT_MAX_ROUNDS_CAP, "超上限钳到 20");
}
/// 任务书:含父上下文头、任务正文、白名单说明与被裁剪尾注。
#[test]
fn task_prompt_carries_context_and_notes() {
let p = build_task_prompt(
"统计 crates 下 TODO 数量",
&["read_file".to_string(), "grep".to_string()],
&["run_command".to_string()],
);
assert!(p.contains("[父任务上下文]"), "应有父上下文头");
assert!(p.contains("<task>\n统计 crates 下 TODO 数量\n</task>"), "任务正文应入 task 标签");
assert!(p.contains("read_file, grep"), "应列白名单");
assert!(p.contains("run_command"), "被裁剪项应有尾注说明");
// 空白名单走"无工具"说明
let empty = build_task_prompt("t", &[], &[]);
assert!(empty.contains("白名单为空"));
}
/// task-local 父会话注入/读取:scope 内可读,scope 外读不到。
#[tokio::test]
async fn caller_conv_scoped() {
assert!(caller_conv().is_none(), "scope 外应读不到");
let seen = scope_caller_conv("conv-p::agent-9".to_string(), async {
caller_conv()
}).await;
assert_eq!(seen.as_deref(), Some("conv-p::agent-9"));
}
}