The gap between a working demo and an incident report is usually not a bug in the model. It is an unannounced interface change. Agent code that evolves without version boundaries breaks callers silently, and the failure often surfaces hours later in a downstream service that cached a schema.
The one-paragraph version
When a team ships an agent — a system that calls tools, chains LLM outputs, and acts on external state — changing its capabilities feels like changing code. It is not. It is changing a public API that other teams, scripts, and sometimes other agents depend on. Versioning an agent means treating its tool schemas, output contracts, and side effects as a stable interface, even though the underlying model or prompt may change weekly. The simplest accurate framing: an agent is a function with side effects, and its signature is the set of tools it exposes plus the shape of what it returns. Change that signature without a version boundary and every caller can break silently. The LLM layer makes it look like there is no interface at all, which is why teams skip versioning until an integration fails at 2am.
Why this concept confuses people
Most software versioning is about code artifacts: tag a release, publish a package, bump a major version when backward compatibility breaks. Agents break that mental model in three ways.
First, agent behavior is not fully determined by its code. A prompt change, a model swap, or a temperature tweak can alter tool-call sequences without any code diff. CI passes, unit tests pass, and yet the agent now calls search_flights before get_user_profile instead of after — and a downstream service that expected the profile ID in the first call fails.
Second, tool schemas are often generated dynamically. A common pattern is to introspect Python functions with inspect.signature() and build the JSON schema at runtime. Adding an optional parameter to a function silently changes the agent's public contract. No version bump, no changelog.
Third, the consumers of an agent are not always humans. They are other agents, cron jobs, webhook handlers, or no-code integrations that a customer built. Those consumers do not read release notes. They read the tool schema once, cache it, and assume it will not change.
A typical failure mode: a team adds a priority field to a create_ticket tool, defaulting to "normal". The agent starts sending "high" for urgent issues. A downstream integration that only accepts "low", "medium", "high" rejects the new value with a 422. The agent retries three times, then gives up. The user sees "ticket creation failed" with no explanation. The fix is not in the agent code — it is in the version boundary that was never drawn.
The mental model that makes it click
Think of an agent like a REST API that happens to be implemented with an LLM. The LLM is the business logic. The tools are the endpoints. The output schema is the response body.
Once that analogy is accepted, versioning rules from API design apply directly:
- Additive changes are usually safe. Adding a new optional tool parameter, a new tool, or a new optional field in the output is backward compatible.
- Removals and renames are breaking. Removing a tool, renaming a parameter, or changing a type (string to integer) requires a new major version.
- Behavioral changes are breaking even if the schema is identical. If
searchused to return 10 results and now returns 5, that is a breaking change for any caller that paginates. - New enum values are breaking for strict consumers. Adding
"pending_review"to a set of allowed values can cause a 422 in a validator that rejects unknown values.
Agents are also stateful in ways APIs are not. An agent might remember a conversation across turns. Changing the memory format — say from a list of messages to a summary string — breaks replay and debugging. That is why agent versioning needs two axes: the interface version (tools + output schema) and the state version (memory layout, checkpoint format).
A useful analogy: an agent is like a database with a schema and a migration history. Changing a stored procedure's signature without versioning it is the same class of mistake.
A concrete worked example
Consider a customer-support agent that exposes three tools: get_order, issue_refund, and send_email. It runs on a serverless platform (for example, AWS Lambda with arm64, Python 3.11, pydantic 2.7 for schema validation). The agent's output is a JSON object with action_taken, confidence, and next_steps.
Version 1 of the agent is deployed. A partner integration calls it via HTTP and expects action_taken to be one of "refunded", "escalated", or "no_action".
The team decides to improve refund logic. They add a new tool check_refund_eligibility and change issue_refund to require an eligibility_token parameter. They also change action_taken to include "pending_review" for cases needing manual approval.
They deploy. The partner integration receives "pending_review" and crashes because its enum does not include it. The agent's retry logic kicks in, but the partner's webhook returns a 400, so the agent marks the task as failed. Within an hour, a measurable fraction of refund requests are stuck.
The fix is to version the agent. Here is a minimal versioning scheme in Python using a version header and a schema registry:
# agent_v2.py
from pydantic import BaseModel, Field
from typing import Literal, Optional
class AgentV2Output(BaseModel):
action_taken: Literal["refunded", "escalated", "no_action", "pending_review"]
confidence: float = Field(ge=0.0, le=1.0)
next_steps: list[str]
version: Literal["2.0"] = "2.0"
class AgentV1Output(BaseModel):
action_taken: Literal["refunded", "escalated", "no_action"]
confidence: float
next_steps: list[str]
version: Literal["1.0"] = "1.0"
# In the request handler, route based on the client's Accept-Version header
def handle_request(request):
version = request.headers.get("Accept-Version", "1.0")
if version == "1.0":
return run_agent_v1(request)
elif version == "2.0":
return run_agent_v2(request)
else:
raise ValueError(f"Unsupported version: {version}")
Both versions run side by side. New callers use 2.0. Existing callers stay on 1.0 until they migrate. Both can run for months if needed.
On the tool side, version the schema by selecting a validator based on the negotiated version and rejecting calls that do not match:
// tool_schema.js — Node 20 LTS, using zod for validation
import { z } from 'zod';
const issueRefundV1 = z.object({
orderId: z.string(),
amount: z.number().positive(),
reason: z.string().optional(),
});
const issueRefundV2 = z.object({
orderId: z.string(),
amount: z.number().positive(),
reason: z.string().optional(),
eligibilityToken: z.string().uuid(), // new required field
});
export function getToolSchema(version) {
if (version === '1.0') return issueRefundV1;
if (version === '2.0') return issueRefundV2;
throw new Error(`Unknown tool version: ${version}`);
}
This pattern lets a team add eligibilityToken without breaking v1 callers. The agent selects the tool version based on the interface version negotiated at request time.
How this connects to things you already know
If you have worked with gRPC or protobuf, this will feel familiar. Protobuf enforces backward compatibility rules: fields can be added, but field numbers and types cannot be changed. Agents need the same discipline, but without a compiler to enforce it. The enforcement has to be built.
If you have worked with database migrations, the state version axis will make sense. Just as a column type should not change without a migration, the agent's memory format should not change without a migration path. In practice, that means storing a state_version alongside the memory blob and writing a transformer that upgrades old states when they are loaded.
If you have worked with API gateways, the routing-by-version pattern is identical. A custom header on AWS API Gateway, or a simple reverse proxy that inspects Accept-Version, is enough. The infrastructure is not the hard part; the hard part is remembering to bump the version when the agent's behavior changes.
A common trap is assuming that because the LLM is stochastic, versioning does not matter. The opposite is true: stochastic behavior makes versioning more important, because tests cannot catch every regression. A version boundary gives a stable reference point — a team can always say "v1 did not call check_refund_eligibility" and reason about the delta.
How to measure whether versioning is working
Rather than trusting a benchmark, instrument the behavior. The relevant signals are cheap to collect and directly answer whether a version boundary is doing its job.
- Version adoption. Log the negotiated interface version on every request. A counter grouped by version (for example, a CloudWatch metric named
agent_requests_by_version) shows whether old versions are draining. If v1 traffic stays flat for weeks, consumers are not migrating. - Schema-drift detection. Snapshot the generated tool schema on each build and diff it against the previous snapshot in CI. Any removal, rename, or type change that is not accompanied by a major version bump should fail the build. A JSON diff plus
jsonschemais sufficient. - Contract-violation rate. Count responses that fail the consumer's validator, if the consumer reports them. A rise in 4xx responses from a specific partner shortly after a deploy is the signature of an unannounced breaking change.
- State migration errors. Count failures when loading persisted state. If a
state_versionmismatch appears, the migration path is missing or wrong. - Model-pinning drift. Assert at startup that the deployed model identifier matches the pinned value. A mismatch means a silent dependency upgrade.
The measurement is not the point on its own. The point is being able to answer, in one query, "which consumers are still on v1, and are they healthy?"
Common misconceptions, corrected
Misconception 1: "The model is the version." No. Changing from one model to another is a dependency upgrade, not an interface change. The interface version should remain stable unless tool schemas or output shape change. However, pin the model version in the deployment (for example, a dated model identifier) so that a silent model update does not alter behavior. If the model must be upgraded, treat it as a new minor version and run A/B tests.
Misconception 2: "Users can just update." In practice, integrations are built by other teams or customers who have their own release cycles. Enterprise integration cycles are often measured in months. Breaking them costs trust. Versioning buys time.
Misconception 3: "Versioning is too much overhead for a prototype." Start with a single version string in the output and a routing header. That is roughly ten lines of code. The overhead is in the discipline, not the code.
Misconception 4: "Tools are internal, so they don't need versions." Tools are the agent's public API. If another agent or a script calls the agent, it sees the tools. Even when tools are only used internally, other services in the same stack may depend on them.
The advanced version (once the basics are solid)
Once basic versioning is in place, more sophisticated controls become useful.
Capability flags. Instead of versioning the entire agent, version individual capabilities. For example, refund_v2 is a capability flag that enables the new refund flow. Callers opt in by passing capabilities: ["refund_v2"]. This is more granular and allows gradual rollout. It also makes deprecation easier: a team can turn off refund_v1 for most traffic, monitor, then turn it off entirely.
Schema registry with compatibility checks. Protobuf and Avro ecosystems have registries that enforce compatibility rules. A lightweight equivalent for agents is a JSON file that maps tool names to versions plus a CI check that fails if a field is removed or a type changes without a major version bump. This is a small script using jsonschema and a baseline snapshot.
State migration. If agent memory is stored in Redis or a Postgres table, include a state_version column. When loading state, check the version and run a migration function if needed. For example, if the format changed from a list of messages to a summary string, write a function that converts old states on the fly. This avoids a big-bang migration.
Deprecation policy. Define a clear timeline: when v2 is released, v1 is supported for a defined window, for example six months. After that, return a 410 Gone with a link to migration docs. Publish the policy so consumers can plan.
Observability. Tag every request with the interface version. In logs, it becomes possible to see which versions are still in use. A common metric is agent_requests_by_version. If v1 traffic drops to a negligible level after a few months, deprecation is safe.
A comparison of versioning strategies:
| Strategy | Granularity | Overhead | Best for |
|---|---|---|---|
| Full agent version (v1, v2) | Coarse | Low | Small teams, few integrations |
| Capability flags | Fine | Medium | Gradual rollouts, A/B tests |
| Schema registry + CI checks | Tool-level | High | Large orgs, many consumers |
| Model pinning only | None | Very low | Prototypes, internal tools |
Quick reference
- Interface version: tools + output schema + side effects. Bump major when breaking.
- State version: memory format, checkpoint layout. Bump when changing storage.
- Model version: pin to a dated release. Treat upgrades as minor versions.
- Routing: use an
Accept-Versionheader or a query param. Default to the latest stable version. - Deprecation: support old versions for a defined window, then return a clear error.
- Enforcement: CI check that compares tool schemas against a baseline. Fail on breaking changes without a version bump.
- Observability: log
versionwith every request. Track adoption.
Frequently Asked Questions
How do I version an agent's tools without breaking existing callers? Use a version header or capability flag. Run both tool versions side by side. The agent selects the tool version based on the negotiated interface version. Add new required parameters only in a new major version. Keep old versions for a defined support window.
Why does my agent's output schema change break integrations?
Because integrations often validate against a fixed schema. Adding a new enum value like pending_review can cause a 422 if the consumer's validator rejects unknown values. Treat output schema changes as breaking unless they only add optional fields that consumers ignore.
What is the difference between agent versioning and model versioning? Model versioning is about pinning the LLM to a specific release. Agent versioning is about the interface: tools, output shape, and state format. The model can be upgraded without changing the agent version if the interface remains compatible, but model upgrades should be tested carefully because behavior can shift.
When should I bump the major version of an agent? Bump major when a tool is removed or renamed, a parameter type changes, the output schema changes in a non-additive way, or side effects change (for example, the agent now sends an email when it did not before). Adding optional fields or new tools is a minor version.
The one thing to do in the next 30 minutes
Open your agent's main handler file (for example, handler.py or agent.js) and add a version field to the output schema. Then add a routing check at the top of the handler that reads an Accept-Version header and defaults to "1.0". Commit it. It will not fix everything, but it establishes the boundary. The next time a tool or output shape changes, there will be a place to put the new version — and a way to keep existing callers working.