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.
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.
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.
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.
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 readosx-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.pyThen 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,constrainedBoth 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.)
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.)
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.)
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 constrainednow 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.
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."
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
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).
- v1 โ diagnostic harness + four-cause attribution + bootstrap CIs
- v2 (in progress) โ 304-case dev/test dataset with slot-disjoint splits;
PA-Toolmethod integration - Grammar-constrained decoding โ abstention-safe, trigger-gated
--decode free,constrainedviaoutlines-core(torch-free); first fix to clear the credible bar on held-outtest.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
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.
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.
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.
- ๐ Issues & ideas: open a GitHub issue
- โ๏ธ Reach the maintainer: frankfish1984@gmail.com
Released under the Apache License 2.0. See LICENSE.
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. ๐พ
