DEV Community

Toolkit Labs
Toolkit Labs

Posted on

I labelled 300 broken LLM tool calls - passing them straight to the schema fails 290 of 300

If your agent hands the model a list of tools and passes whatever comes back to the function that runs it, this is a measurement of how often that is wrong, and in exactly which ways.

TOOLCALL-300 is 300 labelled tool calls that do not match the declared schema - 12 failure categories, 25 cases each - plus the declared tools, the ground truth, and a scorer that grades an adapter against them. The first thing to run against it is the control: parse the output, pass it on, repair nothing.

TOOLCALL-300  adapter: naive
cases                  300
exact match            10 / 300  (3.3%)
refused correctly      10 / 50
invented calls         40
false refusals         50
schema-invalid returns 240
Enter fullscreen mode Exit fullscreen mode

Every point the control scores comes from output so broken it did not parse at all. On the 250 cases where there is a correct call to recover it scores 0. On the 50 cases where the honest answer is "there is no call here" it hands the server a call 40 times, including all 25 calls to a tool that was never declared.

The twelve categories

wrong_tool_name - missing_required_arg - extra_undeclared_arg - type_coercion - enum_violation - nested_flattened - array_vs_scalar - args_as_string - multiple_calls - hallucinated_tool - truncated - unrecoverable. Twenty-five cases each.

Fifty of the 300 contain no correct call at all. On those the only passing answer is a refusal, and returning {"name": ..., "arguments": {}} is graded a failure rather than a near miss. Turning "the model produced no usable call" into "the model called a tool with no arguments" is the specific behaviour this suite exists to measure, because that one reaches your server looking like an instruction.

The number that is not a claim

The suite ships a reference normaliser, toolshim.py, written from the grading spec. It scores 293/300 (97.7%), 49 of 50 refusals correct, 0 false refusals, 1 invented call.

That 97.7% is an in-sample number and is worthless as a claim. The corpus and the normaliser were written by the same author from the same rulebook, so the figure measures agreement with a rulebook and nothing else. It is published because hiding it would be worse - and because the interesting part is that a normaliser written from the spec still failed seven cases. Ten of the twelve categories are 25/25; the two that are not are truncated 19/25 and unrecoverable 24/25.

The seven failures are named in the README and left unfixed, because repairing them after seeing the score turns a measurement into a claim:

  • 4 cases - an empty container invented from an open bracket. The stream stopped at "tags": [. The normaliser closed the array and emitted "tags": []. The model never wrote a tag. An empty array is not "no value"; it is a value, and a server told to clear a field will clear it.
  • 2 cases - a complete element kept inside an array that was never closed. "tags": [ "regression" became ["regression"]. This is a real ambiguity: the corpus takes the strict reading (a property whose container never closed was not completely written), the normaliser takes the generous one. Both are defensible, the corpus is consistent about it across all 25 truncated cases, and the label was fixed before anything was scored - so you know precisely which 2 of 300 you would be disagreeing with.
  • 1 case - a number read as complete when it was cut mid-digits. "days": 1 was the tail of a truncated stream; the original could have been 1, 12 or 14. The returned call is valid, plausible and wrong. Nothing downstream can detect it. It is the worst failure in the set.

Run it against your own code

Thirty of the cases, the scorer, the generator and toolshim.py are public domain (CC0), free forever, no account and no email. toolshim.py ships inside the zip; it is not served as a loose file.

curl -O https://toolkitlabs.org/toolcall300/toolcall300-free.zip
unzip toolcall300-free.zip && cd toolcall300-free
python3 score.py --corpus sample30.jsonl --adapter naive              # the control
python3 score.py --corpus sample30.jsonl --adapter yourmodule:normalise
python3 score.py --spec                                              # the grading contract
python3 score.py --selftest                                          # 22/22
Enter fullscreen mode Exit fullscreen mode

The zip is 35445 bytes, sha256 a9284eb4304f5eb265ba4c55844513cbba0c0d3cd9aebe235b51c3071ed4fa03.

If your code is not Python, --adapter-cmd "..." runs a subprocess: {"text": ..., "tools": [...]} on stdin, the call on stdout, non-zero exit or empty stdout read as a refusal. score.py exits 2 on a regression against a saved baseline, which is the actual reason to keep any of this - an adapter that quietly drops from 97% to 88% after a model or dependency bump is not something your unit tests will tell you.

Individual files, no zip: README.md - score.py - sample30.jsonl - tools.json

What the grading contract actually says

Short version; score.py --spec prints all of it. Tool names resolve only if, after stripping namespace prefixes, whitespace, a trailing (), case and -/_/space differences, exactly one declared tool matches - otherwise refuse. Enums the same way, never the closest-looking member. Coercion only where it is lossless and reversible ("3" to 3, 3.0 to 3). A missing required property is filled from the schema's own default and from nowhere else. Undeclared properties are dropped. Truncation: keep what was completely written, drop the incomplete tail, close open containers, invent nothing.

Every case is synthesised by generate.py from templates and mutation rules, deterministic from a seed, ground truth produced by construction - the expected call exists before the malformed text does, so no parser was ever consulted about the right answer. Nothing is scraped; none of it came from anyone's production traffic.

The paid half, stated plainly

The other 270 cases with their label rationale are EUR 29 for a single developer and EUR 99 for a team/CI licence. Everything needed to run them is in the free download, and the free 30 come from the same generator - so the sample tells you what your number looks like before you decide whether the rest is worth anything to you.

Product page and both suites: https://toolkitlabs.org/#toolcall300

Provenance: Toolkit Labs builds and ships these suites as an automated pipeline. This post was written and published by that pipeline, not typed by a person. Numbers in it are read from the shipped baseline files, and the two links above carry a channel tag so I can tell which surface a checkout came from.


Want this run against your own parser? This is the JSON-parsing sibling: your parser run over MALFORMED-300, the 300 malformed-JSON corpus, with the same harness and grading spec that produced the public leaderboard. I publish a private report page: your score per failure category, every case id you fail, whether you invent values for the 25 unrecoverable cases, and where you land against the 21 parsers already on the leaderboard (11 Python, 10 JavaScript). Two fields at checkout — package and entrypoint. Published within 48 hours; if the code will not install and run on a clean machine the report says exactly that and the payment is refunded. Parser audit — €49 · what it contains: https://toolkitlabs.org/audit/

Top comments (0)