AI coding

A Compatibility Test for Tool Schemas Before Agents Switch

Antonio Evans8 min read

A split workbench: left, a printed tool schema card labeled v1 with a parameter list; right, a v2 card with one renamed field and a type change circled.
Key points

Key points

  1. 1Advertise the old and new tool schema at the same time and route every call by an explicit version field so agents never guess which shape to use.
  2. 2The only compatibility assertion that matters is that identical arguments parse to identical internal requests, field for field, including nulls, defaults and error text.
  3. 3Run a fixed observation window with zero parse mismatches and zero old-version traffic before you retire the old schema, and keep a working fallback path.
  4. 4The riskiest changes are renames and retypes, because they can silently succeed with coerced values instead of failing loudly.

Why a schema change is a contract change for agents

An agent tool is a name, a human description and a JSON schema for its inputs; the model reads the description, decides whether the tool fits, and calls it with arguments per Lunar.dev's analysis of tool calling. That framing matters because it tells you what a schema is: not a hint, but the interface the model targets. When you rename path to file_path or turn timeout from a string into a number, you are changing the interface the model writes against.

The same idea shows up in CLI design guidance: the stdout of a command is its API contract, structured output should be consistent, and types should not drift between commands per this guide to agent-friendly CLI tools. Tools built for humans and machines at once keep JSON output as a first-class code path and treat error text as documentation as OpenStatus describes in its CLI rebuild. A tool schema is the same contract one layer up.

Two failure modes follow. First, the model sends arguments in the old shape because that shape is what it saw, and your new parser rejects them with a message the model cannot act on. Second, worse, the new parser accepts them with a silent coercion, so nothing errors and the request goes out with the wrong value. A compatibility test exists to catch the second case, because logging and dashboards catch the first.

There is also a governance reason to care. Evidence that a change was checked, not just asserted, is what separates a real quality gate from an agent saying "done" as the definition-of-done material for coding agents argues. That article cites an audit of 516 agent "done" claims where no passing test run backed 65 to 69% of them, depending on how strictly they were counted. Your schema migration deserves the same skepticism.

The mechanical part is simple: keep both schemas registered, give each a distinct name or a required version field, and route on that field rather than inferring from the presence of arguments. Inference is where most dual-schema setups break, because an argument that happens to exist in both shapes makes the router's guess arbitrary.

Design choiceWhat it looks likeWhen to pick it
Two tool namescreate_issue (old) and create_issue_v2 (new)Models that do not reliably supply a version field; easiest to observe in logs
One tool, required version fieldcreate_issue with "schema_version": 2 requiredYou control the caller and can enforce a required property
One tool, version in a header or envelopeVersion travels outside the argumentsWrapper layers you own, such as a gateway that rewrites calls

Whichever you pick, make the version explicit and required in the new shape. If the old shape has no version field, treat "absent" as version 1 for the duration of the window, and log it. Then record every call with its version, its raw arguments and the parsed internal request. That record is what makes the retirement decision auditable rather than a judgement call, which is the same reasoning behind authorization decision records that bind identity, tool, resource and policy version per Permit.io's audit trail guidance. For a schema migration the analogue is: version, raw arguments, parsed request, outcome.

One cost to budget for: tool definitions are loaded into context, and a server exposing dozens of tools burns tokens before any call is made as this comparison of MCP and CLI notes. Two versions of the same tool is the cheapest window you will ever run, so accept the duplication for the observation period and do not extend it out of habit.

The core assertion: identical arguments, identical internal request

The test you want is not "does the new parser accept the old payload." It is "do both parsers, given the same logical call, produce byte-identical internal requests." Write it as a table of cases, parse each case through both code paths, and compare the normalized internal request.

import json

CASES = [
    # (label, old_arguments, new_arguments)
    ("minimal", {"title": "Crash on save"}, {"title": "Crash on save"}),
    ("same logical call", {"path": "/tmp/a.txt"}, {"file_path": "/tmp/a.txt"}),
    ("explicit null", {"title": "x", "body": None}, {"title": "x", "body": None}),
    ("number as string", {"timeout": "30"}, {"timeout": 30}),
    ("extra field", {"title": "x", "legacy_flag": True}, {"title": "x"}),
]

def assert_equivalent(old_args, new_args):
    old_req = parse_old(old_args)   # raises on invalid input
    new_req = parse_new(new_args)
    assert normalize(old_req) == normalize(new_req), (old_req, new_req)

for label, old_args, new_args in CASES:
    assert_equivalent(old_args, new_args)

normalize should sort keys, drop fields the internal request does not own, and render missing values as an explicit sentinel so that "absent" and "null" never compare equal by accident. Run this as a gate that executes after the agent's last edit and before merge, the same position a quality gate occupies in a definition-of-done pipeline described here.

Assert on the exit code of the test command, not on the agent's summary. Exit status 0 means success, and the reserved codes above it carry meaning worth using: 1 for general failure, 2 for usage errors, 126 for a command that cannot execute and 127 for a command that was not found, with 128+n for termination by signal n per the exit code table. A schema compatibility gate that returns 2 when the fixture file is malformed saves you an hour of debugging a red build.

What to assert: the six categories that break

Most regressions land in a short list. Cover all six deliberately.

Required fields. Moving a field into or out of the required set is a breaking change in both directions. Make one case per required field where it is missing, and assert the exact error text, not just that an error occurred. Agents read error strings and retry against them, which is why actionable error messages are treated as documentation in CLI design per OpenStatus.

Extra fields. Decide explicitly whether unknown properties are rejected or ignored, and hold that decision across both versions. OpenAI-compatible clients and wrapper layers often add fields; silently dropping an unknown field that a caller believed was meaningful is the quiet failure mode.

Type coercion. "30" and 30 are not the same argument. Assert both that the intended coercion works and that the unintended one fails. This is the category where a rename plus a retype can pass review and still be wrong, because the value arrives, parses and looks plausible.

Nulls and defaults. Three distinct states exist: field absent, field present and null, field present with a value. Assert all three for every optional field, because null and "use the default" are frequently conflated by JSON schema handling in different stacks.

Nested objects. Deep structures hide breakage, because a top-level comparison passes while an inner field changed meaning. Compare the normalized nested request, and add at least one case with a nested list of two or more items so ordering and merging rules are exercised.

Error text. Pin the exact messages for a small set of cases, and accept that they will change, which is the point: a changed message should require a deliberate diff. The CLI guidance is blunt about this: an error should name what went wrong and what to do next, and missing-argument errors should list exactly which flags to supply per this CLI guide. The same applies to tool arguments.

A checklist you can paste into the pull request template:

CheckEvidence recorded
Both schemas registered and versionedRouter config diff
Fixture table covers minimal, full, null, default, nested, extra-field, retyped casesTest file path and case count
Old and new parse to identical normalized requestsTest run exit code 0 on final code
Error text pinned for at least three invalid casesSnapshot files in the diff
Traffic split by version is visible in logsQuery or dashboard link

Worked example: renaming and retyping one parameter

Take a tool that reads a file. The old schema takes path (string) and max_bytes (string, because the first version passed it through a shell). The new schema takes file_path (string) and max_bytes (integer). Three separate changes ride together: a rename, a retype, and a constraint.

Parse both into the same internal request:

{"file": "/tmp/a.txt", "limit": 4096}

The cases that matter, with what each must prove:

Input to oldInput to newExpected result
path: "/tmp/a.txt", max_bytes: "4096"file_path: "/tmp/a.txt", max_bytes: 4096Identical internal request; pass
path: "/tmp/a.txt" omitted limitfile_path: "/tmp/a.txt" omitted limitIdentical request with default limit; pass
path: nullfile_path: nullIdentical error naming the required field; pass
path: "/tmp/a.txt", max_bytes: "4k"file_path: "/tmp/a.txt", max_bytes: "4k"Old may coerce or reject; new must reject with the same message; pass
path: "/tmp/a.txt", max_bytes: -1file_path: "/tmp/a.txt", max_bytes: -1Both reject with the range error; pass
path: "/tmp/a.txt", max_bytes: "4096"file_path: "/tmp/a.txt", max_bytes: "4096"New must reject the string, or explicitly accept it; a silent drop is a fail

The fifth row is the one people forget. Retyping from string to integer opens a new range of values the old schema could not express, and negative sizes or zero limits are exactly where the old "4096" path never went. The sixth row is the decision you must write down: strict rejection is safer, lenient coercion is friendlier, and either is defensible as long as the test asserts which one you chose.

Then run the pair in production for a fixed window, and watch two numbers: mismatches between old and new parsed requests for the same logical call, and the count of calls carrying the old version. Retire when both are zero across the whole window.

Retiring the old shape

Retirement is a policy, not an event. Write it down before the window opens so nobody negotiates with it at the end.

  1. Fixed observation window. Pick a duration long enough to cover the slowest caller you know about, for example one full billing cycle for a scheduled job. Do not extend it because of a hunch, and do not shorten it because a dashboard is green.
  2. Zero mismatches. Every recorded call parsed through both paths and compared. Mismatches, not errors, are the metric; a silent coercion never shows up as an error.
  3. Deprecation notice. Announce the removal date and the new shape, and put a warning in the old parser's response so an agent reading the result sees the deadline. Machine-readable warnings are the same pattern as structured errors on stdout with everything else on stderr per this CLI guide.
  4. Working fallback. Keep the old parser reachable behind a flag for a short grace period, and make the fallback emit a warning rather than going silent. A fallback that hides itself is indistinguishable from a caller that never migrated.

Then delete the old schema in a separate change from the new one, so a rollback restores exactly one thing. Record the retirement with the same envelope you used during the window: version, window dates, mismatch count, and the final old-version call count. That record answers a future "why did we think this was safe" question without a memory of the incident.

If the agent that reads your tool schemas runs against several model providers, the compatibility surface multiplies: one model may emit "4096" where another emits 4096 for the same field. Testing the parsers against recorded transcripts from each provider you actually use is cheaper than discovering the difference in production. A single API across text, image and other model types reduces how many transcripts you have to collect; Quanta Inference (https://quantaintelligence.ai/inference) exposes them through one OpenAI-compatible endpoint, which keeps the recorded-call set small.

FAQ

FAQ

Do I need two tool names or one tool with a version field?

One tool with a required version field is cleaner when you control every caller, because the version cannot be omitted by accident. Two names work better when the model chooses tools by description, since the version is visible in what it selects. Either way, log the version on every call so the retirement decision rests on counts rather than impressions. Whichever you choose, keep it in place for the whole window.

What if the new schema is stricter and legitimately rejects old arguments?

That is allowed, but the rejection must be identical across both paths for the same logical input, and the error text must name the field and the expected type. Add a fixture for each rejected case and pin the message. The failure you are protecting against is not rejection, it is the old path accepting something the new path would have refused while both appear healthy in logs.

How long should the observation window be?

Long enough to include every recurring caller you know about, which often means a full cycle of whatever scheduled job drives the tool. Mark the start and end dates in the migration ticket, and define the exit predicate as zero mismatches plus zero old-version calls across that entire span. A window you cannot state in one sentence is a window you will quietly extend.

Should a mismatch block the merge or just alert?

Block it, once the fixture set is stable. An alert-only gate gets muted during a busy week, and a coercion bug will not announce itself later. The same case applies to any check that an agent's self-report could substitute for: the check has to run separately and leave a record, which is the core argument for a definition of done with evidence behind it per this guide.

What breaks first in practice, the rename or the retype?

The retype, because a rename usually fails loudly when the old key is missing, while a retype can pass through a lenient parser and change behavior downstream. Type coercion is the first thing to assert and the last thing to trust. Add negative cases (negative numbers, empty strings, oversized values) before you add more positive ones.

Sources

Sources

  1. CLI vs MCP: You're Asking the Wrong Question - Lunar.dev
  2. Writing CLI Tools That AI Agents Actually Want to Use
  3. Building a CLI That Works for Humans and Machines
  4. Definition of done for AI coding agents
  5. AI Agent Audit Trail: Proving What an Agent Did and Why - Permit.io
  6. MCP vs CLI: I Tested Both and Here's What Won - YouTube
  7. Appendix E. Exit Codes With Special Meanings
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles