重构: tool_registry 声明式注册基础设施(declare_tool! 宏 + list_projects 试点,零回归)

This commit is contained in:
lxy
2026-08-01 12:56:19 +08:00
parent 438f9692f2
commit c002f0b352
6 changed files with 239 additions and 18 deletions
+154
View File
@@ -0,0 +1,154 @@
//! 声明式工具注册基础设施(tool_registry 拆分第一步)
//!
//! 背景:`tool_registry.rs` 4224 行,每加一个工具手写一段
//! `registry.register(name, desc, object_schema(...), RiskLevel::X, { ... Box::new ... })`,
//! 5 段重复样板 + 闭包捕获 + 缩进极易出错。本模块提供声明式宏 `declare_tool!`,
//! 把工具定义收敛为「名字 / 描述 / schema / 风险 / handler 块」五要素一行式声明。
//!
//! 设计取舍(三选一权衡):
//! - A. proc-macro `#[ai_tool]` 属性宏:需新建 proc-macro crate + syn/quote 重依赖,违反
//! 「不引新依赖」。否决。
//! - B. `inventory` crate 自动收集:新外部依赖 + 拉入 linkme/ctor 运行期注册语义,
//! 与现有 `register_*(&mut registry)` 命令式收集并存需双重 source-of-truth。否决。
//! - C. **`macro_rules!` 声明式宏(本方案)**:零新依赖(纯 std `macro_rules!`),
//! 展开为等价的 `registry.register(...)` 调用——与现有 48 个 `register` 调用**完全并存**,
//! 旧工具零改动,新工具可选声明式。handler 块就地书写,`$db`/`$registry` 等捕获变量
//! 原样透传,语义 1:1 等价(schema/risk/handler 同源不变)。
//!
//! 试点:`list_projects` 已迁至声明式(见 commands/ai/tools/list_projects.rs),验证编译过 +
//! 行为等价 + 48 工具基线不破。其余 47 个工具后续批次渐进迁移,不在此步。
//!
//! 宏展开示例(输入):
//! ```ignore
//! declare_tool!(registry, db: Arc<Database>, "list_projects",
//! "列出所有项目...", RiskLevel::Low,
//! schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
//! args => {
//! let repo = ProjectRepo::new(&db);
//! let items = repo.list_active().await?;
//! // ...
//! Ok(json!({ "items": items, ... }))
//! });
//! ```
//! 展开后等价于现有手写的:
//! ```ignore
//! registry.register(
//! "list_projects", "...",
//! object_schema(vec![...]), RiskLevel::Low,
//! { let db = db.clone(); Box::new(move |args: serde_json::Value| {
//! let db = db.clone();
//! Box::pin(async move { /* handler body */ })
//! })},
//! );
//! ```
/// 声明式注册一个 AI 工具到 `$registry`。
///
/// 与手写 `registry.register(name, desc, schema, risk, handler)` 语义 1:1 等价,
/// 仅消除闭包包装样板(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); Box::pin(async move { ... }) }) }`)。
///
/// 形参:
/// - `$registry`: `&mut AiToolRegistry` 注册表句柄。
/// - `$capture: $cap_ty`: handler 需捕获的外部变量(如 `db: Arc<Database>`)。宏自动 clone 进闭包,
/// handler body 内以 `$capture` 名访问。无捕获工具用 `_` 占位(并保证 body 不引用它)。
/// - `$name`: 工具名 `&str`。
/// - `$desc`: 工具描述 `&str`(发给 LLM)。
/// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。
/// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。
/// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result<serde_json::Value>`。
/// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。
/// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」,
/// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。
///
/// 注意:此宏不替代 `register`,而是包装它——`register` 仍是 `AiToolRegistry` 的唯一注册入口,
/// 宏仅是语法糖。现有 48 个手写 `register` 调用不动,新工具改用 `declare_tool!`。
#[macro_export]
macro_rules! declare_tool {
(
$registry:expr,
$capture:ident : $cap_ty:ty,
$name:expr,
$desc:expr,
$risk:expr,
schema: $schema:expr,
$args:ident => $handler:block
) => {{
let __cap: $cap_ty = $capture.clone();
$registry.register(
$name, $desc, $schema, $risk,
{
let $capture = __cap.clone();
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
let $capture = $capture.clone();
::std::boxed::Box::pin(async move {
let $capture: $cap_ty = $capture;
$handler
})
as ::std::pin::Pin<::std::boxed::Box<
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
+ ::std::marker::Send,
>>
})
},
);
}};
}
#[cfg(test)]
mod tests {
use crate::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
/// 声明式宏注册的工具与手写 register 行为等价(name/schema/risk/执行结果一致)。
/// 这是「现有工具零回归 + 语义等价」的最小验证(试点 list_projects 迁移的微观镜像)。
#[tokio::test]
async fn declare_tool_equivalent_to_register() {
let mut reg = AiToolRegistry::new();
// 声明式注册(新基础设施)
let counter: std::sync::Arc<std::sync::atomic::AtomicUsize> =
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
declare_tool!(
reg,
counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
"echo_decl",
"声明式 echo 工具",
RiskLevel::Low,
schema: object_schema(vec![("msg", "string", true)]),
args => {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let msg = args["msg"].as_str().unwrap_or("");
Ok(serde_json::json!({ "echo": msg }))
}
);
// 等价断言:定义层(name 存在 / risk 正确 / schema 透传)
assert_eq!(reg.len(), 1, "声明式宏应注册 1 个工具");
let tool = reg.get("echo_decl").expect("echo_decl 应已注册");
assert_eq!(tool.risk_level, RiskLevel::Low);
assert_eq!(tool.definition.function.name, "echo_decl");
assert_eq!(tool.definition.function.parameters["properties"]["msg"]["type"], "string");
// 执行等价:handler 收到 args、捕获变量可访问、返回 handler body 的 Value
let out = reg.execute("echo_decl", serde_json::json!({ "msg": "hi" })).await.unwrap();
assert_eq!(out["echo"], "hi");
assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1, "捕获变量应在执行时递增");
}
/// 无捕获工具(纯计算/常量返回)用占位 capture,handler 不引用它。
#[tokio::test]
async fn declare_tool_no_capture() {
let mut reg = AiToolRegistry::new();
let dummy: std::sync::Arc<()> = std::sync::Arc::new(()); // 无真实捕获,占位
declare_tool!(
reg,
dummy: std::sync::Arc<()>,
"const_tool",
"无捕获常量工具",
RiskLevel::Medium,
schema: object_schema(vec![]),
_args => { Ok(serde_json::json!({ "ok": true })) }
);
let out = reg.execute("const_tool", serde_json::json!({})).await.unwrap();
assert_eq!(out["ok"], true);
}
}