The delete that should not have run
The agent loop was supposed to let the LLM call a delete_record tool. The tool took two required fields: table and record_id. The LLM sent a call where record_id was missing. The field was not in its JSON at all.
The tool function ran anyway. The validation was inside the implementation, after the query to the database. When the missing field caused a panic during the SQL build, the transaction had already started. Rollback worked, but the error that came back was a Rust thread panic with a stack trace, not a message the LLM could parse and retry from. The agent stalled.
This is the problem agentvet-rs solves. Validate the arguments before any side effects happen. Return a structured, LLM-friendly error. Let the agent retry with corrected arguments instead of swallowing a crash.
Shape of the fix
Add the crate:
[dependencies]
agentvet-rs = "0.1"
serde_json = "1"
Define your schema and wrap the tool:
use agentvet_rs::{VettedTool, ToolArgError};
use serde_json::json;
let schema = json!({
"type": "object",
"required": ["table", "record_id"],
"properties": {
"table": { "type": "string" },
"record_id": { "type": "integer" }
}
});
let tool = VettedTool::new(
"delete_record",
schema,
|args| {
// Only called if validation passes.
// args is guaranteed to have "table" and "record_id" here.
delete_record_impl(args)
}
);
Call it from your agent loop:
match tool.call(json!({"table": "users"})) {
Ok(result) => println!("{}", result),
Err(ToolArgError::ValidationFailed { hint, fields, .. }) => {
// hint: "Field 'record_id' is required but missing."
// fields: ["record_id"]
// Feed this back to the LLM as the tool_result content.
println!("Retry hint: {}", hint);
}
Err(ToolArgError::ExecutionFailed { source, .. }) => {
// Validation passed, the impl itself failed.
eprintln!("Impl error: {}", source);
}
}
The hint field is a complete English sentence describing what is wrong. It is designed to be passed directly into the tool result block so the LLM sees it on the next turn and corrects the argument.
Multiple validation failures are collected into a single ToolArgError::ValidationFailed with all field names listed. The LLM gets one retry message that describes all the problems, not one message per field.
What it does NOT do
This crate does not do semantic validation. JSON Schema validates structure and types. It does not know that record_id: 0 is not a valid record ID in your database, or that the table value must be one of a known list. For that kind of business-rule validation, you write your own check inside the wrapped function after the structural validation has already passed. The crate draws a clean line between structural correctness and business correctness. Both matter, and they need to be handled in different places.
Inside the lib
The validator is built on top of jsonschema (the Rust crate, not the JavaScript one). It evaluates a serde_json::Value against a JSON Schema serde_json::Value. The schema is stored on the VettedTool at construction time and applied on every call.
The design choice to collect all errors at once rather than failing on the first one is intentional. LLMs in tool-use loops tend to produce all their missing fields at once, not one at a time. If you return a single "field X is missing" error, the LLM will add field X and send another call. If field Y was also missing, you get another round trip. Collecting all missing and mistyped fields into one error cuts the retry round trips when the LLM had multiple problems at once.
The hint string is assembled by the crate from the raw validation errors. The raw errors from jsonschema are technical strings. The crate maps common error patterns to English sentences:
-
requiredviolation becomes "Field '{name}' is required but missing." -
typeviolation becomes "Field '{name}' expected {expected_type}, got {actual_type}." -
enumviolation becomes "Field '{name}' must be one of: {values}." - Unknown patterns fall back to the raw validator output.
The implementation itself is just a closure stored in the struct. There is no reflection, no macro magic, and no code generation. The closure captures any context the function needs from the surrounding scope (database connections, config, etc.) at construction time. This is plain Rust closure ownership.
The VettedTool struct is not Send by default because closures that capture &mut references are not Send. If you need to share a VettedTool across threads, capture Arc<Mutex<T>> inside the closure instead of a bare mutable reference. This is not a crate design decision, just how Rust closures and the Send trait work.
When useful
- Tool functions with required fields where a missing field triggers a panic or a confusing error deeper in the stack.
- Agent loops where you want every tool error to be a structured, parseable type rather than a mixed bag of panics and application errors.
- Multi-field tools where collecting all validation errors in one pass saves LLM retry round trips.
- Any place where you want a hard barrier between the LLM's unvalidated JSON output and your actual business logic.
When NOT
- Single-field tools with a simple null check. The overhead of a JSON Schema evaluation is not worth it for a function that takes one string and checks if it is empty.
- Tools that already have a strong type boundary at a gRPC or REST layer. If the LLM calls a strongly typed API, the API validation handles the structural check.
- High-frequency, low-latency tools where even a small per-call allocation budget matters. The JSON Schema validation allocates. For sub-microsecond tools, inline checks are faster.
Install
[dependencies]
agentvet-rs = "0.1"
serde_json = "1"
crates.io: agentvet-rs
GitHub: MukundaKatta/agentvet-rs
Siblings
| Crate / Package | What it does |
|---|---|
| agentvet (npm) | Same idea in TypeScript for Node.js agent loops |
| agentcast-rs | Enforce structured output schemas on LLM responses |
| agentsnap-rs | Snapshot agent traces for replay and debugging |
| agentguard-rs | Egress allowlist; controls which hosts tools can reach |
| tool-arg-defaults | Fill missing optional args from schema defaults before validation |
What is next
The most useful missing piece is a strict mode that rejects any field in the LLM's JSON that is not listed in the schema properties. Right now, extra fields pass through silently. Strict mode would catch cases where the LLM produces the right fields plus a hallucinated field that should not be there.
A schema_from_fn adapter that reads a Rust function signature and derives the JSON Schema automatically is also on the list. Right now you write the schema by hand as a serde_json::Value. That is fine for simple tools but gets tedious for tools with eight fields. Generating the schema from the function's argument struct with a derive macro would remove the duplication.
A third addition worth considering is a VettedToolSet that registers multiple named tools and dispatches by name. Right now each VettedTool is an independent struct. An agent that wraps 20 tools would hold 20 separate structs. A registry that maps tool names to VettedTool instances and exposes a single call(name, args) entry point would clean up the dispatch boilerplate in large agent implementations.
Part of the Hermes Agent Challenge sprint. All crates shipped on crates.io.
Top comments (0)