- Schema-valid is the floor, not the goal: constrained decoding guarantees shape, while most costly failures are well-formed arguments with wrong values.
- Test arguments in five layers: JSON Schema conformance, value boundaries, cross-field rules, units, and provenance from upstream steps.
- In our failure catalogue, 32.8% of multi-step failures were tool-argument schema or boundary violations (GONATURELLE platform telemetry), which makes this the cheapest high-yield test you can add.
- Keep tool schemas in one place, version them with the tools, and run the same schemas in tests that the agent sees at runtime.
Most agent failures are argument failures
When we classify the multi-step failures in our catalogue, the single largest group is not reasoning, retrieval or tone. It is tool arguments: 32.8% of catalogued multi-step failures were schema or boundary violations in the arguments an agent passed to a tool (GONATURELLE platform telemetry). A dose in milligrams passed to a field that expects micrograms per kilogram. A loan amount above the product cap. A claim date in the future. A member ID that looks valid and belongs to nobody.
These failures are attractive targets for testing because they are cheap to detect. Nearly all of them can be caught deterministically, without a judge, by checking the recorded tool call against rules you already know. The hard part is being systematic about which rules, and making sure the rules your tests enforce are the same ones your tools enforce.
Structured outputs solve the easy half
Most model providers now offer some form of structured output or constrained decoding for function calling, and agent frameworks validate tool inputs against a schema before execution. Use them. They remove a whole class of malformed JSON, missing required fields and wrong types. But they solve the syntactic half of the problem. A schema that says dose_mg is a number accepts 5000. A schema that says start_date is a date string accepts 2031-02-30 in some validators and any future date in all of them. Constrained decoding makes the model produce an argument that parses; it does nothing to make the argument true.
There is also a coverage trap. Many teams write schemas that are looser than the tool's real contract, because the schema was generated from a type signature. The tool's implementation then rejects or, worse, silently clamps values the schema allowed. Your tests should exercise the stricter contract, and ideally that contract should be pushed back into the schema the model sees.
Five layers of argument testing
We test tool arguments in five layers, cheapest first. Each layer catches failures the previous one cannot.
- Schema conformance: types, required fields, enums, formats and additionalProperties: false, validated against the exact JSON Schema registered for that tool version.
- Value boundaries: numeric ranges and allowed sets that come from policy rather than type, such as a maximum courtesy credit, a weight-based dose range or a coverage limit.
- Cross-field rules: constraints between arguments, such as end_date after start_date, or a pediatric dose table applying when the patient's age is under 18.
- Units and formats: values expressed in the unit the tool expects, with an explicit conversion step in the trajectory when the source used a different one.
- Provenance: identifiers and amounts must appear in an earlier tool result or the user input. Anything else is a hallucinated argument, however plausible.
Layer one, in plain pytest
You do not need any platform to start. If your agent writes its tool calls to a trace file, a short pytest module with the jsonschema library catches the first layer today. The important design choice is to load schemas from the same registry the tools use, keyed by tool name and version, so a schema change and a test change cannot drift apart.
import json
from pathlib import Path
import pytest
from jsonschema import Draft202012Validator
SCHEMAS = {
p.stem: Draft202012Validator(json.loads(p.read_text()))
for p in Path('tools/schemas').glob('*.json')
}
TRACES = [json.loads(line) for line in Path('traces/golden_run.jsonl').read_text().splitlines()]
@pytest.mark.parametrize('trace', TRACES, ids=lambda t: t['case_id'])
def test_every_tool_call_matches_its_schema(trace):
for call in trace['tool_calls']:
validator = SCHEMAS[f"{call['name']}@{call['version']}"]
errors = [e.message for e in validator.iter_errors(call['arguments'])]
assert not errors, f"{call['name']} step {call['step']}: {errors}"Boundaries and provenance come from policy
Layers two to five need knowledge that does not live in a type signature. The maximum courtesy credit lives in a servicing policy. The weight-based dose range lives in a formulary or a pediatric dosing reference that a pharmacist maintains. The rule that a claim reserve cannot exceed the per-occurrence limit lives in the policy document the agent fetched a few steps earlier. Encoding those rules as assertions forces the conversation between engineering and the policy owner that should happen anyway, and it leaves a record the policy owner can review.
Units are the layer teams forget until it bites them. A medication agent reads a dose in milligrams from a note and passes it to a calculator that expects micrograms per kilogram per minute. A claims agent reads an estimate in Canadian dollars and sets a reserve in US dollars. The argument is the right type and inside the allowed range, so layers one and two pass, and the value is still wrong by a factor that matters. We require an explicit conversion step in the trajectory whenever the source unit differs from the tool's unit, and we assert that the step happened, rather than trusting the model to convert in its head.
Provenance deserves special attention because it is the failure most likely to look correct. A model that has seen thousands of account numbers in training can produce a well-formed one with a valid check digit. The only way to know it is wrong is to check that it came from somewhere in this trajectory. We treat any identifier, monetary amount or clinical code that cannot be traced to an upstream step as a failure, even when the downstream tool happened to accept it.
from gonaturelle import EvalSuite, assertions as A
from agents.claims import fnol_agent
def test_claims_tool_arguments():
suite = EvalSuite.load('insurance/fnol-property-v1')
results = suite.evaluate(agent=fnol_agent, max_concurrency=10)
results.assert_tool_schema_valid()
results.assert_all([
A.param_within('set_reserve.amount', max_from='fetch_policy.limits.per_occurrence'),
A.param_in('open_claim.loss_type', values=['water', 'fire', 'wind', 'theft']),
A.param_before('open_claim.loss_date', ref='case.report_date'),
A.no_hallucinated_args(tool='set_reserve', field='policy_number'),
A.unit_converted(tool='set_reserve', field='amount', unit='USD'),
])Generate the edge cases, do not wait for them
Golden cases drawn from real traffic tend to have well-behaved arguments, because most real requests are ordinary. Boundary failures show up at the edges: the member at the coverage limit, the patient at the weight band boundary, the claim reported on the last day of the policy period. We expand each boundary rule into a set of synthetic variants on both sides of the limit, plus variants where the needed value is missing and the correct behaviour is to ask or escalate rather than guess. A single real edge case typically becomes a few hundred variants this way.
Finally, version schemas with the tools. When a tool adds a field or tightens a range, bump its version, register the new schema and let CI run the golden set against both. A schema change that silently invalidates a third of your recorded trajectories is something you want to find on the pull request that made it, not in production. If you want to see these five layers run against your own traces, the 14-day trial includes trajectory capture and schema assertions on the Developer plan.
Led ML platform engineering at Infosys and later a US lending fintech, where he owned the CI pipeline for credit models. IIT Bombay (CSE). Designs the trajectory assertion engine.