Tech5/15/2026·8 min

TypedTool: Moving Tool Schema/Parameter Consistency to Compile Time

Each agent tool carried a hand-written JSON schema and a separate hand-written parser. The two drifted. A Rust trait made the parameter struct the single source for both.

The drift between schema and parser

An agent tool exposed to an LLM has two contracts that must agree. One is the JSON schema we hand the model so it knows what arguments to produce. The other is the code that reads those arguments back out of the JSON the model returns. In the original Tool trait both were written by hand, in different methods, with nothing tying them together:

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);
    // ...
}

Rename a field, change a type from string to integer, mark something optional — you have to remember to edit both blocks. The compiler sees Value on both sides and has nothing to check. A mismatch surfaces only at runtime, as a parse failure on a request the model already paid tokens to produce, or worse, as a field silently read as the wrong type. Across the tool set this was a recurring class of bug rather than a one-off.

The struct is the schema

PR #158 introduced a TypedTool<P> trait. Each tool declares a parameter struct, and that struct generates the schema and does the parsing. There is no second place to keep in sync.

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>;
}

The Params bound is the load-bearing part: DeserializeOwned (from serde) gives the parse, JsonSchema (from schemars) gives the schema, and both are derived from one struct definition. The Edit tool collapses to this:

#[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 now receives EditParams, not Value. The required/optional split is expressed once, in the types — Option<T> with #[serde(default)] is optional, a bare field is required — and schemars emits the matching required array. The hand-written schema method and the three .as_str().ok_or_else(...) guards are gone. In the Edit tool that was roughly 50 lines of boilerplate deleted.

Bridging typed tools into a dyn registry

The registry stores Box<dyn Tool> and dispatches with serde_json::Value, because at the registry boundary the concrete parameter type is not known. TypedTool<P> cannot be made into a trait object directly — it is generic over P. The adapter TypedBridge<T, P> resolves this: it holds a typed tool and implements the object-safe 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 parsing still exists, but now there is exactly one of it, inside the bridge, applied uniformly to every tool — instead of a bespoke, slightly different parser inside each tool. The schema is produced once via schema_for!(P), normalized, and cached in a OnceLock. Registration names the parameter type explicitly:

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

Two normalization steps that the generated schema needs

A schema derived from a struct is not directly the schema an LLM API wants. schemars emits a JSON Schema document — with $schema, title on every field, and nested types behind $ref into a definitions block. normalize_schema flattens that into the shape the model expects: it drops $schema, inlines every $ref against definitions/$defs (recursively, so nested arrays of structs fully expand), strips title, and guarantees properties exists on object types. The integration test asserts the output contains no $ref, definitions, or $defs, and that a Vec<NestedItem> field expands so properties.items.items.properties.label is a concrete object.

The input side has a smaller but real wrinkle. Models frequently emit "" for an optional string they have nothing to say about. With Option<String> that deserializes to Some(""), not None, which a tool then has to special-case. strip_empty_optionals removes any field whose value is "" unless that field is required, so empty optionals become None before parsing — while an empty string on a required field is preserved. That asymmetry is checked by two separate tests.

Forcing one security decision per tool

secret_eligible_params carries no default in either trait. Every tool must state, explicitly, which parameters may contain a <secret_ref:NAME> placeholder that the runtime substitutes with plaintext before execute. Read-only and file-writing tools return &[]; Bash returns &["command", "env"]. Omitting it is a compile error, not a permissive fallback — adding a tool cannot silently inherit secret-handling behavior nobody chose.

The same PR split the Bash tool along these lines. The old single tool overloaded its argument set: a command field for execution plus process_id/block/stop fields to poke at background processes, dispatched by inspecting which were present. That flag-argument shape is exactly what a typed struct makes awkward, so it was separated — BashTool (BashParams { command, timeout, run_in_background, description }) and BashProcessTool (BashProcessParams { process_id, block, timeout, stop }) — two coherent parameter sets instead of one with mutually exclusive halves.

Result

The change touched 109 files: +1980 / −1507. The net is negative not because functionality shrank but because per-tool schema JSON and per-tool Value unpacking were deleted in favor of one struct and one shared bridge. The drift is now unrepresentable — there is no second artifact to drift against. New coverage came with it: 7 bridge integration tests, 13 schema-normalization tests, 7 input-normalization tests.

The transferable constraint: when two artifacts must agree and the language can derive both from one declaration, the win is not less typing — it is removing the second artifact entirely, so "they disagree" stops being a state the program can be in. The cost is a generic-to-dyn adapter and a normalization pass to reconcile the derived schema with what the consumer expects; both are written once and amortized across every tool.