技术5/15/2026·8 min

TypedTool:把 tool 的 schema 与参数一致性提到编译期

每个 agent tool 都手写一份 JSON schema,再手写一份解析器,两者各管各的、会漂移。一个 Rust trait 让参数 struct 成为两者唯一的来源。

schema 与解析器之间的漂移

一个暴露给 LLM 的 agent tool 有两份必须一致的契约。一份是交给模型的 JSON schema,让它知道该产出哪些参数;另一份是把模型返回的 JSON 重新读出来的解析代码。在原来的 Tool trait 里,这两份都靠手写,分散在不同方法里,彼此之间没有任何约束:

fn parameters_schema(&self) -> Value {
    json!({
        "type": "object",
        "required": ["file_path", "old_string", "new_string"],
        "properties": {
            "file_path":  { "type": "string", "description": "..." },
            "old_string": { "type": "string", "description": "..." },
            "new_string": { "type": "string", "description": "..." },
            "replace_all": { "type": "boolean", "description": "..." }
        }
    })
}

async fn execute(&self, input: Value, ctx: &ToolContext) -> Result<ToolResult, LoopalError> {
    let file_path = input["file_path"].as_str().ok_or_else(|| /* InvalidInput */)?;
    let old_string = input["old_string"].as_str().ok_or_else(|| /* InvalidInput */)?;
    let new_string = input["new_string"].as_str().ok_or_else(|| /* InvalidInput */)?;
    let replace_all = input["replace_all"].as_bool().unwrap_or(false);
    // ...
}

改个字段名、把某个参数从 string 改成 integer、把某项标成可选——你得记得把两块都改掉。编译器两边看到的都是 Value,无从校验。一旦不一致,只会在运行期暴露:要么是模型已经花了 token 产出的请求解析失败,要么更糟,某个字段被当成错误的类型静默读走。在整个 tool 集合上,这不是偶发问题,而是一类反复出现的缺陷。

让 struct 成为 schema

PR #158 引入了 TypedTool<P> trait。每个 tool 声明一个参数 struct,由这个 struct 同时生成 schema 和完成解析,不再有第二处需要同步。

pub trait Params: DeserializeOwned + JsonSchema + Send + 'static {}
impl<T: DeserializeOwned + JsonSchema + Send + 'static> Params for T {}

#[async_trait]
pub trait TypedTool<P: Params>: Send + Sync {
    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn permission(&self) -> PermissionLevel;
    fn precheck(&self, _input: &P) -> Option<String> { None }
    fn secret_eligible_params(&self) -> &'static [&'static str];
    async fn execute(&self, input: P, ctx: &ToolContext) -> Result<ToolResult, LoopalError>;
}

Params 这个 bound 是关键承重点:DeserializeOwned(来自 serde)负责解析,JsonSchema(来自 schemars)负责生成 schema,二者都从同一个 struct 定义 derive 出来。Edit tool 因此收缩成这样:

#[derive(Deserialize, JsonSchema)]
pub struct EditParams {
    pub file_path: String,
    pub old_string: String,
    pub new_string: String,
    #[serde(default)]
    pub replace_all: Option<bool>,
}

execute 现在收到的是 EditParams 而不是 Value。必选/可选的划分只表达一次,落在类型上——Option<T>#[serde(default)] 即可选,裸字段即必选——schemars 据此生成对应的 required 数组。手写的 schema 方法和三段 .as_str().ok_or_else(...) 守卫全部消失。仅 Edit 这一个 tool,就删掉了约 50 行样板。

把 typed tool 桥接进 dyn registry

registry 存的是 Box<dyn Tool>,分发时用 serde_json::Value,因为在 registry 边界上并不知道具体参数类型。TypedTool<P>P 泛型,不能直接做成 trait object。适配器 TypedBridge<T, P> 解决了这点:它持有一个 typed tool,并实现对象安全的 Tool trait。

pub struct TypedBridge<T, P: Params> {
    inner: T,
    schema: OnceLock<Value>,
    _phantom: PhantomData<fn() -> P>,
}

#[async_trait]
impl<T: TypedTool<P>, P: Params> Tool for TypedBridge<T, P> {
    fn parameters_schema(&self) -> Value { self.cached_schema().clone() }

    async fn execute(&self, mut input: Value, ctx: &ToolContext) -> Result<ToolResult, LoopalError> {
        strip_empty_optionals(&mut input, &self.required_fields());
        let parsed: P = serde_json::from_value(input)
            .map_err(|e| LoopalError::Tool(ToolError::InvalidInput(e.to_string())))?;
        self.inner.execute(parsed, ctx).await
    }
}

Value 解析依然存在,但全仓只剩这一处,落在 bridge 里,对每个 tool 统一施加——而不是每个 tool 内部各写一份略有差异的解析。schema 通过 schema_for!(P) 生成一次,归一化后缓存在 OnceLock 里。注册时显式写出参数类型:

registry.register(Box::new(
    TypedBridge::<_, loopal_tool_edit::EditParams>::new(loopal_tool_edit::EditTool),
));

生成的 schema 需要两步归一化

从 struct derive 出来的 schema,并不直接是 LLM API 想要的那份。schemars 产出的是一份 JSON Schema 文档——带 $schema、每个字段都有 title、嵌套类型藏在 $ref 指向的 definitions 块里。normalize_schema 把它压平成模型期望的形状:去掉 $schema,按 definitions/$defs 递归内联所有 $ref(递归是为了让嵌套的 struct 数组完全展开),剥掉 title,并保证 object 类型上一定有 properties。集成测试断言输出里不含 $refdefinitions$defs,且一个 Vec<NestedItem> 字段会展开到 properties.items.items.properties.label 是个具体的 object。

输入侧还有个更小但真实的坑。模型经常对一个它无话可说的可选字符串直接给 ""。对 Option<String> 而言,这会反序列化成 Some("") 而非 None,于是 tool 不得不专门 special-case。strip_empty_optionals 会在解析前剔除任何值为 "" 的字段——除非该字段是必选的——让空的可选项变回 None;而必选字段上的空字符串则保留。这个不对称由两个独立的测试分别守护。

强制每个 tool 做一次安全决策

secret_eligible_params 在两个 trait 里都没有默认实现。每个 tool 必须显式声明:哪些参数可以承载 <secret_ref:NAME> 占位符,让运行时在 execute 之前替换成明文。只读和写文件类的 tool 返回 &[],Bash 返回 &["command", "env"]。漏写是一个编译错误,而不是一个宽松的兜底——新增 tool 不可能静默继承一套没人选过的密钥处理行为。

同一个 PR 顺着这条线拆了 Bash tool。旧的单一 tool 把参数集合重载了:一个 command 字段用于执行,外加 process_id/block/stop 字段去操作后台进程,靠检查哪些字段存在来分发。这种 flag-argument 形态恰恰是 typed struct 最别扭的地方,于是拆开——BashToolBashParams { command, timeout, run_in_background, description })和 BashProcessToolBashProcessParams { process_id, block, timeout, stop })——两组各自自洽的参数集,取代一组互斥的半边。

结果

这次改动触及 109 个文件:+1980 / −1507。净值为负,不是因为功能缩水,而是每个 tool 的 schema JSON 和每个 tool 的 Value 拆解都被删掉,换成一个 struct 加一个共享的 bridge。漂移现在无法被表达——根本没有第二份产物可供漂移。配套新增了覆盖:7 个 bridge 集成测试、13 个 schema 归一化测试、7 个输入归一化测试。

可迁移的约束是:当两份产物必须一致、而语言能从一份声明里 derive 出二者时,收益不在于少敲代码,而在于彻底消掉第二份产物,让"两者不一致"不再是程序可能进入的状态。代价是一个从泛型到 dyn 的适配器,外加一道把 derive 出的 schema 调和成消费方期望形状的归一化;二者都只写一次,被所有 tool 摊薄。