Tool Schema Versioning for Production LLM Agents in October 2026: Evolve Function Signatures Without Breaking In-Flight Runs
Production LLM agents depend on tool schemas the way APIs depend on OpenAPI specs. The model sees a function name, a parameter list, and a short description, then emits JSON that must match that contract. When you rename a field, add a required argument, or retire a tool, every in-flight run that still carries the old schema in its conversation history can break: the model keeps calling the old shape, your dispatcher rejects the payload, and the agent loops or fails mid-task.
This guide covers how to version tool schemas so you can evolve signatures safely, keep long-running agents alive across deploys, and avoid silent dual-write bugs when old and new shapes coexist.
Why tool schemas break agents in practice
Unlike a normal HTTP client that you control, the "client" here is a model that was conditioned on whatever tool definitions you sent earlier in the turn—or, for multi-turn agents, on definitions that may have been baked into a checkpointed transcript hours ago. Three failure modes show up repeatedly:
- Mid-run deploy: An agent started with
create_ticket(title, body). You shipcreate_ticket(title, body, priority)withpriorityrequired. The next tool call from that run omitspriorityand your validator rejects it. - Cached system prompts: Prompt caching or a sticky router keeps serving an old system message that still lists the previous schema, while your executor only accepts the new one.
- Multi-agent handoffs: A supervisor agent hands a worker a tool allowlist keyed by name only. The worker's gateway has a newer schema for that name, so the worker's first call fails schema validation even though the name matched.
Treating the tool surface as an unversioned global map is what creates these races. Treat each tool as a versioned contract instead.
Version the contract, not just the name
Pick one explicit versioning scheme and stick to it. Two patterns work well in production:
Suffix versions on the tool name
Expose create_ticket_v1 and create_ticket_v2 as separate tools in the model's tool list. The model cannot accidentally mix versions because each name is a distinct function. Retire _v1 only after traffic and in-flight runs drain.
TOOLS = {
"create_ticket_v1": {
"schema": {"type": "object", "required": ["title", "body"],
"properties": {"title": {"type": "string"},
"body": {"type": "string"}}},
"handler": handle_create_ticket_v1,
"status": "deprecated",
"sunset_at": "2026-11-01T00:00:00Z",
},
"create_ticket_v2": {
"schema": {"type": "object",
"required": ["title", "body", "priority"],
"properties": {
"title": {"type": "string"},
"body": {"type": "string"},
"priority": {"type": "string",
"enum": ["low", "medium", "high"]}}},
"handler": handle_create_ticket_v2,
"status": "active",
},
}
When you register tools for a new session, include only active tools plus any deprecated tools still needed for runs that started on the old set. Do not silently swap the schema behind an unchanged name.
Stable name with an explicit schema_version field
Keep the display name stable for the model (create_ticket) but require a schema_version integer inside the arguments, or attach version metadata in your dispatcher keyed by (tool_name, schema_version). This is useful when product copy and docs refer to one tool name and you do not want the model to see a zoo of suffixes. The tradeoff is that you must validate the version field on every call and return a clear error when it is missing or unknown.
Either scheme beats "latest wins." The important part is that the version the model was told about is the version your executor will accept for that conversation.
Pin schemas for the life of a run
At session start—or when you create a checkpoint for a long-running agent—snapshot the exact tool definitions you sent to the model:
run.tool_bundle = {
"bundle_id": "tb_20261012_a3f9",
"tools": [
{"name": "create_ticket_v2", "schema": {...}, "description": "..."},
{"name": "search_docs_v1", "schema": {...}, "description": "..."},
],
}
On every subsequent model call for that run, resend the same bundle (or the same cached prefix). On every tool dispatch, resolve the handler through bundle_id, not through a global "current" registry. If a deploy removes a tool, old runs keep using the handlers still registered under their bundle until they finish or hit a hard sunset.
This is the same idea as pinning a model version for a conversation: the agent sees a stable world for the duration of the task.
Compatibility rules when you must change a live tool
Sometimes you cannot add a new name and must evolve an existing one. Apply API-style compatibility rules:
- Additive optional fields: Safe. Add optional properties with defaults in the handler. Old model outputs still validate.
- New required fields: Breaking. Ship as a new version. If you must force the field, accept the old payload for a transition window and fill the field server-side with a documented default, while returning a warning in the tool result so the model starts including it.
- Renames: Breaking. Keep the old property as an alias for one version, map it in the handler, and describe both names in the tool description only during the overlap window—then remove the alias in the next version.
- Removals and type changes: Breaking. New version only. Never change a string field to an object under the same name and version.
Put the compatibility policy in code review checklists. A one-line schema tweak in a PR can strand overnight batch agents if nobody notices it is breaking.
What to put in the tool result when validation fails
When the model's arguments do not match the pinned schema, do not raise an opaque 500 into the chat transcript. Return a structured tool error the model can recover from:
{
"ok": false,
"error": "schema_mismatch",
"tool": "create_ticket_v2",
"schema_version": 2,
"details": "Missing required property 'priority'. Allowed: low, medium, high.",
"hint": "Resend create_ticket_v2 with priority set. Do not call create_ticket_v1."
}
Include the expected version and a short hint. Models usually correct the next call if the error names the missing field. If you removed a tool entirely, say so explicitly and name the replacement. Silent "unknown tool" messages cause retry loops.
Rolling out a new version without dual-write hazards
A common mistake is to accept both v1 and v2 payloads in one handler and write to two downstream shapes "just in case." That creates inconsistent records. Prefer a clear rollout:
- Deploy v2 handlers and register
create_ticket_v2as active. Keep v1 deprecated but live. - For new sessions, advertise only v2 (plus unrelated tools). For existing sessions, keep advertising whatever their pinned bundle listed.
- Monitor call counts by tool name and version. When v1 traffic is near zero and no open runs pin v1, mark v1 removed.
- Delete the v1 handler only after the sunset timestamp, and make dispatch return a permanent schema error naming the replacement if anything still calls it.
If two versions must write to the same store, normalize inside a single domain service so both handlers call one TicketService.create(...) with a canonical internal model. Do not fork business logic per schema version.
Wire versioning into checkpoints and resume
Long-running agents that checkpoint after each tool turn must persist the tool bundle id alongside messages and step counters. On resume after a process restart:
- Load
bundle_idfrom the checkpoint. - Restore that exact tool list into the next model request.
- Route tool calls through the bundle's handler table.
- If the bundle's sunset has passed, fail the run with a clear "tool bundle expired; restart with a new session" error instead of improvising on the latest schemas.
Failing closed beats succeeding with the wrong contract. A restarted run that silently picks up new required fields will look healthy in metrics and wrong in the product.
Testing schema changes before they ship
Add a small CI check that treats tool schemas like public APIs:
- Diff the committed schema catalog against main. Flag removals, renames, and new required fields as breaking.
- Require an explicit
version_bumpor new tool name in the same PR when a breaking change is detected. - Replay a golden set of historical tool-call payloads against the new catalog. Old payloads must still validate against their original versioned schema, not against "latest."
- Run one integration test that starts a run on v1, deploys v2 mid-flight in the test harness, and asserts the run continues on v1 until completion.
You do not need a large eval suite for this—schema fixtures and a handful of recorded calls catch most accidents.
Operational signals to watch
Track these metrics per tool name and version:
- Call rate and error rate, especially
schema_mismatchandunknown_tool. - Count of active runs still pinned to deprecated bundles.
- Time from deprecation to zero traffic (your real drain time).
- Rate of model self-corrections after a schema error (tool error followed by a successful retry in the next turn).
A spike in schema mismatches right after a deploy usually means new sessions got a new schema while something—prompt cache, a sticky worker, or a hardcoded tool list in a supervisor—still advertises the old one. Fix the advertisement path before touching handlers.
A minimal rollout checklist
- Every tool has a versioned identity (suffix or explicit schema_version).
- Each run pins a tool bundle at start and reuses it until finish or sunset.
- Breaking changes always introduce a new version; old versions drain before removal.
- Validation failures return structured, version-aware tool errors.
- CI diffs schemas and replays old payloads against their original versions.
- Dashboards show deprecated-bundle drain and schema_mismatch rate.
Tool schema versioning is unglamorous infrastructure, but it is what lets you improve agent capabilities weekly without breaking the runs that started yesterday. Pin the contract per run, evolve behind new versions, and retire old ones only after the drain reaches zero.
Comments
Post a Comment