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。集成测试断言输出里不含 $ref、definitions、$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 最别扭的地方,于是拆开——BashTool(BashParams { command, timeout, run_in_background, description })和 BashProcessTool(BashProcessParams { 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 摊薄。