Skip to content
Code-byte404Public

About

๐Ÿพ๐Ÿ” The tool-call detective for small models on Apple Silicon โ€” attributes every tool-calling failure to one of four causes (template bug / parser gap / model format / model decision). MLX-native, reproducible, bootstrap CIs on every metric.

Topics

Resources

Contributing

Stars

7 stars

Watchers

0 watching

Forks

Latest commit

ย 

History

53 Commits

Folders and files

NameName
Last commit message
Last commit date
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

Toolhound

Toolhound

The tool-call detective for small models on Apple Silicon.
When a small model botches a tool call, Toolhound tells you who did it โ€” the chat template, the framework parser, or the model itself.

MLX native Python 3.11+ tests license status

Toolhound attributes every tool-call failure to one of four causes โ€” a chat-template bug, a framework parser gap, a model format failure, or a model decision failure โ€” separating the model's fault from the framework's.


The 60-second pitch

Everyone benchmarks tool-calling with a single number: "Model X gets 71% of function calls right." That number is a lie of omission. It can't tell you why the other 29% failed โ€” and the why is the only thing that tells you what to do next.

Three models can look like they fail tool-calling for the same reason โ€” and be wrong three different ways. Here's what the 2026-07 lineup actually surfaced (real numbers below):

Model Its "failure" looks likeโ€ฆ โ€ฆbut the real culprit is So the fix isโ€ฆ
Granite-3.3-2B ๐Ÿงฉ can't do multi-turn tool calls the chat template silently drops the prior call File upstream / faithful rendering
Gemma-4-12B ๐Ÿ”ง emits schema-invalid calls the harness parser missed its array syntax Fix the parser โ€” not the model
Qwen3.5-2B ๐Ÿง  picks the wrong arguments genuine model judgment (args 0.76) Better model or prompt โ€” grammar can't fix this

Two of those three are not the model's fault. A plain accuracy score would have blamed the model for all three. Toolhound doesn't โ€” it caught a template bug and a parser gap, and only pinned the third on the model.

Toolhound is a reproducible diagnostic harness that runs entirely on your Mac (via MLX) and attributes every single failure to one of four causes โ€” with bootstrap confidence intervals on every metric.


The four suspects ๐Ÿ”

Every failed tool call gets pinned on exactly one culprit:

Cause Whose fault Reportable?
framework_template_bug The chat template / tokenizer mangled the tool tokens โœ… Upstream bug
framework_parser_gap The model emitted a rescuable call; the framework parser missed it โœ… Upstream bug
model_format_failure The model can't emit a parseable call at all The model
model_decision_failure Valid format, but wrong tool or wrong arguments The model

The trick that makes this attribution valid: the parser is lenient ("ๅฎฝ่ฟ›"), the scorer is strict ("ไธฅๅˆค"). We decouple "could any reasonable parser rescue this output?" from "is this the correct answer?" โ€” so a format failure is never confused with a judgment failure, and an upstream parser gap is never blamed on the model.


Why this exists (and what it is not)

Toolhound is a measuring stick, not another schema-adaptation method. Its value is honest measurement:

  • โœ… It finds chat-template bugs and parser gaps โ€” and gives you a minimal repro to file upstream.
  • โœ… It separates "the model can't format" from "the model can't decide" โ€” because grammar-constrained decoding fixes the first and can never fix the second.
  • โœ… It quantifies quantization damage (bf16 vs. q4) without confounding it with template differences.
  • โœ… In v2, it benchmarks existing zero-training fixes (PA-Tool, grammar-constrained decoding) on a held-out test set โ€” never claiming an improvement unless its confidence interval is disjoint from baseline. The measuring stick already earned its keep: it caught that PA-Tool hurts small models, and confirmed that constrained decoding credibly helps โ€” but only where the failure is syntactic (see below).

We are not the first to notice that chat templates break tool tokens, and we don't claim to be. Toolhound's contribution is making that failure legible, attributable, and reproducible on consumer Apple hardware.


Quickstart

Requires an Apple Silicon Mac (M1 or newer), macOS 14+, Python 3.11+. MLX runs only on Apple Silicon โ€” your conda env must be arm64, not Rosetta (conda info โ†’ platform should read osx-arm64).

git clone https://github.com/Code-byte404/toolhound.git
cd toolhound

conda create -n toolhound python=3.11 && conda activate toolhound
pip install -e ".[dev]"

# Smoke test: MLX loads a tiny non-gated model and generates one call
python scripts/smoke.py

Then run the detective on a model:

# 1) Reliability report: how often does each model get tool calls right?
toolprobe run \
  --model qwen3.5-2b,granite-3.3-2b,gemma4-12b \
  --quant q4 \
  --cases cases/test.jsonl \
  --out reports/

# 2) Attribution: for every failure, name the culprit (run under strict + lenient parsers)
toolprobe attribute --model qwen3.5-2b

# 3) Compare a zero-training fix against baseline (v2)
toolprobe run --model qwen3.5-2b --cases cases/dev.jsonl --method baseline,pa_tool

# 4) Free vs. grammar-constrained decoding โ€” adds a "Decode comparison" table (v2)
#    (validated on the reference JSON-family lineup; see the constrained-decoding section)
toolprobe run --model qwen2.5-0.5b --cases cases/dev.jsonl --decode free,constrained

Both commands write matching *.json (machine-readable) and *.md (human-readable) reports into reports/, each stamped with a full reproducibility header: chip, RAM, macOS version, exact mlx / mlx-lm / mlx-vlm versions, model repo + revision, and the injected date.

(The bundled model keys โ€” qwen3.5-2b, granite-3.3-2b, gemma4-12b (2026-07 refresh) โ€” are registered in src/toolprobe/cli.py; each family has a distinct native tool-call format. Add your own there.)


What a report looks like

Reliability โ€” layered scoring, so you see exactly where each model drops off:

Model: qwen3.5-2b  (q4)                       95% bootstrap CI ยท Apple M2 Pro
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
parse_ok          โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘  0.99  [0.98, 1.00]
schema_valid      โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘  0.99  [0.98, 1.00]
tool_correct      โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘  0.99  [0.98, 1.00]
args_correct      โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘  0.76  [0.69, 0.83]   โ† syntax solved, judgment isn't

The current (2026-07) lineup on held-out test.jsonl (q4, free decoding) shows the layered story cleanly โ€” the newer models have solved the syntax layer, so the only differentiator left is judgment:

model            parse_ok   schema_valid  tool_correct  args_correct   (held-out test.jsonl, q4)
qwen3.5-2b         0.99         0.99          0.99          0.76        โ† syntax solved; decision-bound
granite-3.3-2b     1.00         1.00          1.00          0.75        โ† syntax solved; decision-bound
gemma4-12b         1.00         1.00          1.00          0.97        โ† the 12B also nails the decisions

Three families, three distinct native formats (Qwen3.5 XML ยท Granite JSON-list ยท Gemma-4 VLM), and every one drives parse/schema/tool to โ‰ˆ1.0 โ€” which is exactly why grammar-constrained decoding has no room to help here (see below). The full report, with bootstrap CIs, is in reports/.

Attribution โ€” every failure pinned to a suspect, shown under both parser tiers so you can see the conclusion doesn't flip when the parser gets more lenient:

Failure attribution (strict parser)           Failure attribution (lenient parser)
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€             โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
framework_template_bug     4                   framework_template_bug     4
framework_parser_gap       6   โ† rescuable!    framework_parser_gap       1
model_format_failure       9                   model_format_failure       8
model_decision_failure    22                   model_decision_failure    22

(Reliability numbers above are from a real bundled 3-model run on an Apple M2 Pro; the attribution counts show the two-tier layout โ€” run toolprobe attribute for your own CI-backed figures.)

Does a proposed fix actually help? (v2)

When you benchmark a method against baseline, Toolhound doesn't just print a delta โ€” it flags each metric credible only when the method's bootstrap CI is disjoint from baseline's. In a bundled 3-model demo run, PA-Tool (a real zero-training tool-renaming method) didn't clear that bar on any metric โ€” and on one model it measurably hurt argument accuracy:

Method comparison โ€” qwen2.5-1.5b (q4) ยท pa_tool vs. baseline
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
metric          baseline   pa_tool    delta    credible
tool_correct      0.96       0.96      +0.00      no
args_correct      0.71       0.43      โˆ’0.28      no   โ† caught, not rubber-stamped

That's the entire point of a measuring stick: it tells you when a fix doesn't work, with the statistics to back it up. (Demonstration on the exploratory default.jsonl; real method selection uses the held-out dev / test split so a gain has to generalize to unseen slots.)

And when a fix does work โ€” grammar-constrained decoding (v2)

Toolhound runs a second axis, --decode free,constrained, that masks generation to a model's own tool-call grammar via outlines-core โ€” trigger-gated, so a model can still decline to call a tool (abstention survives). Selected on the dev split, then reported once on held-out test.jsonl, it was the first integrated fix to clear the credible bar โ€” on 2 of 3 models of the reference JSON-family lineup (Qwen2.x / Llama-3.x):

Constrained vs free decoding โ€” reference lineup, held-out test.jsonl (q4) ยท Apple M2 Pro
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
model          metric          free โ†’ constrained    credible
qwen2.5-0.5b   parse/schema    0.50 โ†’ 0.76  (+0.27)    yes    โ† format-bound: grammar rescues it
qwen2.5-0.5b   args_correct    0.31 โ†’ 0.52  (+0.21)    yes
llama-3.2-3b   schema_valid    0.68 โ†’ 1.00  (+0.32)    yes    โ† emits invalid arg types; grammar forbids them
qwen2.5-1.5b   (every metric)  no credible change      no     โ† already well-formed; its errors are judgment

The null result on the 1.5B is the whole point. Constrained decoding fixes the syntax layer (is it a parseable, schema-valid call?) and can never fix the decision layer (is it the right tool with the right values?). So it credibly helps the two models whose failures are syntactic, and does nothing for the model that already emits clean calls and just picks wrong. Toolhound is what lets you tell those two situations apart โ€” and it even surfaces the honest cost (constrained decoding can degenerate into repetition on unbounded string fields).

Why the current (2026-07) lineup is free-decoding only. When we refreshed the registry to newer models (qwen3.5-2b, granite-3.3-2b, gemma4-12b), Toolhound measured that none of them has syntactic headroom for constrained decoding to fix: Qwen3.5's syntax layer is already saturated (its misses are all judgment), Granite's only failures were a multi-turn template bug (see below โ€” the fix is rendering, not grammar), and Gemma is a VLM with no logits hook to constrain. So --decode constrained now fails fast on the current models by design โ€” the honest finding is that newer 2B+ models have outgrown the format failures constrained decoding was built for. The capability stays implemented and tested for when a format-bound model is added back.

Field notes: two bugs the 2026-07 refresh caught ๐Ÿ›

Swapping in three brand-new models immediately flushed out two bugs โ€” and the useful part is neither was the model's fault. Each maps onto one of the four suspects, which is exactly what Toolhound exists to prove.

โ‘  framework_template_bug โ€” Granite's chat template drops multi-turn tool calls. Granite 3.3 was perfect on every single-turn category but cratered on multi-turn cases, all with the same mangled shape <|tool_call|>tool[{โ€ฆargsโ€ฆ}]. The cause was not the model: Granite's chat template has no tool_calls branch, so it silently dropped the prior assistant tool-call from the history โ€” the model was fed a tool result with no preceding call. The fix is faithful rendering (serialize the prior call in Granite's own native format), not a smarter model or a grammar:

Granite 3.3 ยท 16 affected multi-turn cases (test.jsonl, q4)   before โ†’ after the rendering fix
parse_ok        0.38  โ†’  1.00        (6/16 โ†’ 16/16)
tool_correct    0.38  โ†’  1.00
args_correct    0.00  โ†’  0.94        (0/16 โ†’ 15/16)   โ† broken context made every call wrong

โ‘ก framework_parser_gap โ€” Toolhound's own parser missed Gemma's array syntax. Gemma-4 looked like it emitted 15% schema-invalid calls. On inspection the calls were fine โ€” Gemma writes list arguments as attendees:[<|"|>a<|"|>,<|"|>b<|"|>], and Toolhound's Gemma parser was truncating the list at the first comma. That is textbook framework_parser_gap (a rescuable call the parser fumbled) โ€” the harness caught it in itself, and the honest move was to fix the parser, not dock the model:

Gemma-4-12B ยท full test.jsonl, 131 non-abstention (q4)        before โ†’ after the parser fix
schema_valid    0.85  โ†’  1.00        (112/131 โ†’ 131/131)
args_correct    0.85  โ†’  0.97        (111/130 โ†’ 127/131)

That is the whole value proposition, twice. A plain accuracy score would have quietly blamed Granite and Gemma for 17 failures that belonged to a chat template and a parser gap. Toolhound's layered metrics flagged the discrepancy, and its four-cause discipline forced the honest attribution โ€” fix the harness / file it upstream, not "the model is bad."


How the attribution works

                       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
   raw model output โ†’  โ”‚  template_sanity check   โ”‚  tokens survived round-trip?
                       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                          no โ”‚             โ”‚ yes
                             โ–ผ             โ–ผ
              framework_template_bug   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                       โ”‚  parse_framework (strict) โ”‚  did the framework see a call?
                                       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                          no   โ”‚           โ”‚ yes
                                               โ–ผ           โ–ผ
                              โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   scorer (strict):
                              โ”‚  parse_rescue (lenient) โ”‚   right tool? right args?
                              โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”˜        โ”‚
                             rescuedโ”‚          garbageโ”‚         โ–ผ
                                    โ–ผ               โ–ผ    model_decision_failure
                          framework_parser_gap   model_format_failure

The pipeline is a clean, testable data flow โ€” each stage is a pure function with one job:

case โ†’ templates โ†’ runner โ†’ parser โ†’ scorer โ†’ attribution โ†’ report

Only one module (backend.py) is allowed to import mlx / mlx_lm / mlx_vlm; a hygiene test enforces it. That keeps the parser, scorer, attribution, and grammar-builder logic 100% pure and unit-testable on any machine (no Mac required for the logic tier โ€” 124 tests run in <1s in CI; the constrained-decoding e2e tests are the Mac-only tier).


Roadmap

  • v1 โ€” diagnostic harness + four-cause attribution + bootstrap CIs
  • v2 (in progress) โ€” 304-case dev/test dataset with slot-disjoint splits; PA-Tool method integration
  • Grammar-constrained decoding โ€” abstention-safe, trigger-gated --decode free,constrained via outlines-core (torch-free); first fix to clear the credible bar on held-out test.jsonl (2 of 3 models)
  • Model refresh (2026-07) โ€” registry now spans three current families with three distinct native formats: Qwen3.5 (XML), Granite 3.3 (JSON list), Gemma-4 (VLM via mlx-vlm, bespoke format)
  • More methods โ€” TSCG (integrate & measure existing work โ€” not invent new)
  • A constrained-decoding string guard โ€” bounded-length / repetition-penalized string fields, to remove the degeneration cost the harness surfaced on unbounded string args
  • PNG report export for dropping straight into issues and blog posts

Contributing โ€” we want detectives ๐Ÿ•ต๏ธ

This is a young project with a clear mission and a lot of well-scoped, self-contained ways to help. If any of these sound fun, open an issue and say hi:

  • ๐Ÿงฉ Add a model to the registry and file the template/parser bugs Toolhound finds upstream.
  • ๐Ÿงช Add test cases โ€” especially tricky abstention traps (utterances that look like tool requests but aren't).
  • ๐Ÿ”ฌ Harden constrained decoding โ€” bound string-field length / add a repetition penalty so unbounded string args (e.g. translate.target_lang) can't degenerate into a repetition loop under the grammar.
  • ๐Ÿ“Š Add a method โ€” wire an existing zero-training tool-calling fix into the methods/ framework and let the benchmark judge it fairly.
  • ๐Ÿ“– Docs โ€” help port the methodology notes to English.

Every metric in this project has a confidence interval and every logic path has a test. Please keep it that way โ€” ruff check . && pytest is the bar, and any claimed improvement must show non-overlapping CIs vs. baseline.

New here? โ†’ CONTRIBUTING.md walks you from clone to merged PR (dev setup, the hard rules, the bar), and the good first issues are concrete places to start.


Reproducibility, by design

  • temperature=0, top_p=1, fixed seed โ€” deterministic generation.
  • Confidence intervals come from bootstrap resampling over the case set, not seed variance.
  • bf16 vs. q4 comparisons assert an identical tokenizer + chat template first, so quantization damage is never confounded with template differences.
  • Every model is tested with its own chat template's tool format โ€” never a hand-rolled one.
  • Dates like "today / Friday" are pinned to a fixed injected date so runs are reproducible forever.

The whole report table is re-runnable with a single command.


Contact & collaboration

This project is actively looking for collaborators. Whether you want to add a model, contribute test cases, port a method, or just compare notes on small-model tool-calling reliability โ€” I'd love to hear from you.

License

Released under the Apache License 2.0. See LICENSE.

Acknowledgements

Built on MLX and mlx-lm by Apple. Method integrations credit their original authors (see each file in src/toolprobe/methods/).

Toolhound ships as the mlx-toolprobe package with the toolprobe CLI. ๐Ÿพ

About

๐Ÿพ๐Ÿ” The tool-call detective for small models on Apple Silicon โ€” attributes every tool-calling failure to one of four causes (template bug / parser gap / model format / model decision). MLX-native, reproducible, bootstrap CIs on every metric.

Topics

Resources

Contributing

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages