新增: 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
+52 -9
View File
@@ -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();