DEV Community

Mukunda Rao Katta
Mukunda Rao Katta

Posted on

tool-arg-fuzzy-rs: Fuzzy Match LLM Enum Args to Valid Values in Rust

The afternoon the enum stopped working

The tool schema said region must be one of "NorthAmerica", "SouthAmerica", "Europe", or "Asia". The enum was right there in the description. The model had seen it before. But on this call it came back with "north_america".

That one character difference blew up the JSON validator. The agent returned an error to the user. Totally avoidable. The model clearly knew what it meant. It just wrote it differently.

This is not a rare edge case. It happens regularly once you have agents in production calling tools that accept enum parameters. The model generates valid-looking JSON. The field name is right. The intent is correct. But the casing is snake_case where the schema expected PascalCase, or it used a hyphen where the schema used nothing, or it wrote the full phrase where the schema had an abbreviation. Every model has its own conventions. When you switch models or upgrade a version, the conventions can shift. The validators downstream do not care about intent. They reject the value.

You can fix this with a strict reject-and-retry loop. But that costs another round trip. With a long system prompt and tools, that adds latency and token spend. And it will happen again next time the model picks a slightly different casing or underscore style. The pattern is predictable. The fix should be too.

That afternoon I wrote tool-arg-fuzzy-rs. It handles this recovery before the validator ever sees the value.


Shape of the fix

The crate gives you a FuzzyMatcher that holds the valid enum variants. You call match_str with whatever the model sent. It cascades through four strategies and returns the canonical form or None.

use tool_arg_fuzzy_rs::FuzzyMatcher;

let matcher = FuzzyMatcher::new(vec![
    "NorthAmerica",
    "SouthAmerica",
    "Europe",
    "Asia",
]);

match matcher.match_str("north_america") {
    Some(canonical) => println!("Matched: {}", canonical),  // "NorthAmerica"
    None => println!("Ambiguous or no match"),
}
Enter fullscreen mode Exit fullscreen mode

The cascade is: exact match first, then case-insensitive after stripping underscores and hyphens, then prefix match, then substring match. If two candidates tie at any level, it returns None rather than guessing. You decide what to do with None. You can reject, retry, or ask the user.

For a list of values pulled from a config or registry at runtime, FuzzyMatcher::from_strings takes owned strings:

let variants: Vec<String> = load_from_db();
let matcher = FuzzyMatcher::from_strings(variants);

let result = matcher.match_str("eu");
// Returns Some("Europe") if "Europe" is the only prefix match for "eu"
Enter fullscreen mode Exit fullscreen mode

What it does not do

This crate does not compute edit distance. There is no Levenshtein, no Jaro-Winkler, no phonetic matching. If the model sends "Erope" for "Europe", this crate returns None. That is intentional. Edit-distance matching adds false positives. An agent handling "Asia" and "Australia" with a one-character typo in the input would be dangerous to auto-correct. The fuzzy ladder here is safe precisely because it only accepts structurally close matches, not phonetically similar ones.


Inside the lib

The core data structure is a HashMap<String, String> where keys are pre-normalized forms and values are the original canonical strings. On construction, every variant is normalized three ways: lowercased, lowercased with underscores stripped, and lowercased with hyphens stripped. All three map back to the original.

This means lookups are O(1). There is no iteration at match time for exact or case-insensitive checks. Only prefix and substring checks require scanning the candidate list, and in practice enum lengths are short enough that this never shows up in profiles.

The ambiguity check matters. When two candidates share a prefix or contain the same substring, returning either one would be a correctness bug. The crate keeps a candidate list during prefix and substring phases. If more than one candidate survives, the call returns None. You get the miss, not a random winner.

Error handling is minimal by design. FuzzyMatcher::new never panics. An empty candidate list simply means every call returns None. The caller controls what that means. Some agents retry. Some log and continue. Some surface it to the user. The crate does not pick a policy.

Thread safety is handled by construction. FuzzyMatcher is Send + Sync because the internal map is built once and never mutated. You can put one matcher in an Arc and share it across request threads without a lock. For agents that handle concurrent tool calls, this matters. One matcher instance per enum type, shared across all workers, is the intended usage pattern.


When useful

  • Enum arguments in tool schemas where the model has a history of casing or separator variation.
  • Region, status, category, or tier fields pulled from a config file that the model has seen in docs but not always in the exact casing.
  • Any tool call pipeline where a retry-on-invalid-enum loop is adding measurable latency.
  • Multi-provider setups where different models format the same enum differently. Some models prefer snake_case. Some prefer PascalCase. Some produce neither and go with what they saw most often in training. The crate handles all of them against the same matcher.
  • Offline environments where you cannot make a second call to re-ask the model.
  • Validation layers sitting in front of strict downstream APIs where the upstream model output needs cleanup before it reaches the schema enforcer.

When not useful

  • Free-text fields. This is not a spell checker.
  • Numeric enums or boolean flags. No value added.
  • Enums with single-character variants where prefix matching would be meaningless.
  • Cases where the application requires strict rejection so the model learns to be precise. Some production systems want the model penalized for imprecision, not silently corrected. Fuzzy recovery trades feedback signal for user experience. Which matters more depends on the product.
  • Very large variant sets (hundreds of entries) where substring matches across many similar strings would need careful ambiguity review before trusting.

Install

[dependencies]
tool-arg-fuzzy-rs = "0.1"
Enter fullscreen mode Exit fullscreen mode
cargo add tool-arg-fuzzy-rs
Enter fullscreen mode Exit fullscreen mode

Siblings

Crate / Package Language What it does
tool-arg-fuzzy Python Same cascade logic for Python tool pipelines
agentvet-rs Rust Validate tool call shape and required fields
tool-arg-coerce-py Python Coerce LLM tool args to expected Python types
tool-arg-defaults Python Fill missing tool args from schema defaults
agentvet Python Agent-level call validation and health checks

What is next

The next version will expose a configurable match depth so callers can opt out of substring matching while keeping prefix. I also want to add a batch API so you can match multiple args in one call without re-normalizing the candidate list per field. After that, a small proc macro for deriving FuzzyMatcher from an enum type is on the list, so you can annotate a Rust enum and get a matcher for free without listing the variant strings manually.

The crate is at crates.io/crates/tool-arg-fuzzy-rs and the source is at github.com/MukundaKatta/tool-arg-fuzzy-rs. Issues and PRs welcome.

Top comments (0)