An agent built with ADK for Java looks like ordinary code, so teams version it like ordinary code: tag the commit, ship the jar. That is not enough. What the user experiences is produced by a bundle of parts that change at different speeds: the instruction text, the set of tools and their parameter schemas, the model ID, the generation settings, the sub-agent graph, the shape of session state and the ADK library itself. Change any one of them and behaviour changes, sometimes in ways no compiler can see.

There is a second difference. A web request lives for milliseconds, but an agent session can last hours or days, and its stored event history refers to tools, arguments and state keys from the version that wrote it. When you deploy, old conversations meet new code. Most agent versioning bugs come from that meeting.

This article treats the bundle as the unit of versioning. It shows how to fingerprint a bundle, pin each session to the bundle that created it, classify changes by whether old sessions survive them, upgrade the ADK library safely, and roll back without corrupting state. The manifest, router and replay harness shown here are patterns you write; ADK supplies the agent, runner and session APIs they sit on. For prompt files and prompt A/B tests, read ADK Java prompt management first; for versioning stored event payloads and tables, read adk-postgres migrations and schema versioning.

What a version of an agent is

Start by listing everything that can change what the agent does for the same user input. Each item is a version axis, and the bundle version is the combination.

ComponentExample changeWho notices
Instruction textNew refund policy paragraphUsers, evals
Tool setAdd, remove or rename a toolThe model, and stored history
Tool schemasNew required parameterThe model, and replayed calls
Model IDMove to a newer modelEverything: tone, tool choice, latency, cost
Generation configTemperature 0.2 to 0.7Consistency of answers
Agent graphSplit one agent into a router and two specialistsTransfers inside live sessions
State schemaRename the state key cart to basketEvery session created before the change
ADK libraryUpgrade com.google.adk:google-adkEvent handling, callbacks, serialization

The jar version covers code changes, but instruction text and model IDs are often loaded from configuration, and the same jar can run two different bundles. So the bundle needs its own identity, computed from its contents rather than assigned by hand.

A manifest and a fingerprint

Describe the bundle in a manifest and derive a fingerprint from it. A human-readable version such as support-agent 4.0.0 says what the release means. The fingerprint, a hash of the canonical manifest, says exactly what ran. Log both on every invocation.

// Your code, not an ADK API: the bundle manifest and its fingerprint.
public record ToolSpec(String name, String schemaSha256) {}

public record AgentBundle(
        String name, String version,          // "support-agent", "4.0.0"
        String instructionSha256,
        String modelId,                       // a pinned model ID, never a moving alias
        double temperature,
        List<ToolSpec> tools,                 // sorted by name
        int stateSchema,                      // bumped when state keys change meaning
        String adkVersion) {                  // from the build, e.g. read from a resource file

    public String fingerprint() {
        String canonical = String.join("\n",
            name, version, instructionSha256, modelId, Double.toString(temperature),
            tools.stream().map(t -> t.name() + "=" + t.schemaSha256()).collect(Collectors.joining(",")),
            Integer.toString(stateSchema), adkVersion);
        return DigestUtils.sha256Hex(canonical).substring(0, 12);   // Apache commons-codec
    }
}

The agent is then built from the manifest, so the running agent cannot drift from what the manifest claims. LlmAgent.builder() and FunctionTool.create are ADK APIs; everything around them is yours.

LlmAgent build(AgentBundle b, PromptStore prompts) {
    String instruction = prompts.load(b.instructionSha256());   // fails if the text is missing
    return LlmAgent.builder()
        .name("support_agent")
        .model(b.modelId())
        .instruction(instruction)
        .tools(
            FunctionTool.create(OrderTools.class, "getOrder"),
            FunctionTool.create(RefundTools.class, "requestRefund"))
        .build();
}

A CI step recomputes each tool's schema hash from the declared parameters and fails the build if it differs from the manifest. That turns a silent schema change into a reviewed version bump.

Pinning sessions to bundles

Manifestbundle 4.0.0 / a91f03c2Manifestbundle 3.4.0 / 7c2e19d0buildBundle registryagent + runner per versionRunner 4.0.0new sessionsRunner 3.4.0pinned old sessionsVersion routerreads bundle from sessionClient requestuserId, sessionIdlookupSession storestate: bundle=3.4.0read pinNew sessions get the current bundle; existing sessions keep the bundle recorded at creationuntil they end or an explicit migration moves them.
Versioned agent serving: manifests build one runner per bundle, and a router sends each session to the bundle pinned in its state.

Pinning is the core mechanism. When a session is created, record the bundle version in its state. Every later turn reads the pin and goes to the runner for that bundle. The session's history then always meets the tools, instruction and state schema it was written against.

// Creating a session under the current bundle (ADK session API, your router logic).
ConcurrentMap<String, Object> state = new ConcurrentHashMap<>();
state.put("bundle_version", current.version());
state.put("bundle_fingerprint", current.fingerprint());
Session s = runners.get(current.version()).sessionService()
    .createSession(APP_NAME, userId, state, null).blockingGet();

// Every later turn: route by the pin, not by "latest".
Flowable<Event> handle(String userId, String sessionId, Content msg) {
    String pinned = pins.lookup(userId, sessionId);        // cached copy of the state key
    InMemoryRunner runner = runners.getOrDefault(pinned, runners.get(fallbackFor(pinned)));
    return runner.runAsync(userId, sessionId, msg);
}

Two practical points. First, all runners must share one session store, so a session can be read by whichever bundle owns it; InMemoryRunner is fine for the sketch, but production uses a persistent session service behind every runner (see session context in ADK Java for what state survives a turn). Second, the old runner has to stay deployed while it has live sessions. Track the count of active sessions per bundle, and retire a bundle only when that count reaches zero or the remaining sessions pass an idle cutoff you have published, for example seven days. On Kubernetes that means one Deployment per live bundle; running ADK Java agents on Kubernetes covers the drain and probe settings each one needs.

Pinning has a cost: a bug fix in bundle 3.4.0 does not reach pinned sessions unless you ship a 3.4.1 and move them to it. Treat patch releases as eligible for in-place migration, which is safe by the rules in the next section, and major releases as new-sessions-only.

Which changes old sessions survive

Classify each change by one question: can a session written by the old bundle continue on the new one? That decides the version bump and the rollout.

ChangeOld sessions survive?BumpRollout
Instruction wording, same intentYesPatchMigrate live sessions
New optional tool parameterYes, old calls still validMinorMigrate live sessions
New tool addedYesMinorMigrate live sessions
Tool renamed or removedNo: history names a tool that is goneMajorNew sessions only, keep old runner
New required tool parameterNo: replayed calls fail validationMajorNew sessions only
State key renamed or retypedNo, unless a migration runsMajorNew sessions, or migrate with code
Model ID changeUsually, but behaviour shiftsMinor or major by evalCanary on new sessions
Sub-agent split or mergeNo: transfers point at old agent namesMajorNew sessions only

The rename case is the classic trap. The model sees prior function calls in its context, so after a rename it may call the old name, which now raises an unknown-tool error. The fix is the expand-and-contract pattern: ship the new tool alongside the old one as a thin alias, wait until no pinned session uses the old bundle, then remove the alias in a later major release.

State changes need explicit code. Keep a stateSchema integer in the manifest and a migration function per step, run lazily when a session is first loaded by a newer bundle. If you cannot write that function, the change is new-sessions-only.

Versioning the model

Model IDs deserve their own discipline because the model is the largest source of behaviour. Pin a specific model version in the manifest. Provider aliases that always point at the newest model change your bundle without a deploy, and the fingerprint cannot see it.

A model change goes through an evaluation gate before any traffic: run the bundle's regression set (recorded conversations with expected tool calls and graded answers) against old and new model, and compare tool-call accuracy, refusal rate, answer quality score, latency and cost per session. Ship only if the new model is no worse on the metrics you agreed in advance. Then canary it on a small share of new sessions, because live traffic always contains cases the eval set lacks.

Upgrading the ADK library

The ADK library is part of the bundle too. ADK for Java reached 1.0 on 30 March 2026 and has shipped frequent 1.x releases since, each adding features to sessions, events, runners and plugins. Upgrades are usually smooth, but event handling and serialization sit directly under your stored sessions, so treat an upgrade as a bundle change.

<!-- pom.xml: one property for every ADK artifact, so they cannot drift apart -->
<properties>
  <adk.version>1.x.y</adk.version>   <!-- placeholder: pin an exact release -->
</properties>
<dependency>
  <groupId>com.google.adk</groupId>
  <artifactId>google-adk</artifactId>
  <version>${adk.version}</version>
</dependency>

Before merging an upgrade, read the release notes for every version you skip, especially anything about events, session services and callbacks. Avoid APIs the project marks as experimental in code paths that touch stored data. Then run a replay test: load a sample of real recorded sessions (scrubbed of personal data) into the new build, resume each with a scripted next message, and assert the session loads, state reads correctly and the runner emits events without errors. A failed replay is far cheaper in CI than in production.

Rollback

Rollback for agents has two halves. Routing new sessions back to the previous bundle is easy: flip the current pointer in the registry. Sessions already created on the bad bundle are the hard part, because they may have written state the old bundle cannot read.

Write the rule down before you need it. If the release is patch or minor, the old bundle can read the new state by construction, so you can move those sessions back. If it is major, either let affected sessions finish on the bad bundle with a hotfix patch, or end them politely and start fresh with a summary. Never silently move a session onto a bundle whose state schema is older than the one that last wrote it.

Worked example: a breaking tool change

A support agent runs bundle 3.4.0 with a tool lookupOrder(orderId). The team wants getOrder(orderId, includeItems) with includeItems required, and a new state key open_ticket replacing ticket.

Classification: a tool rename, a new required parameter and a state rename, so this is 4.0.0 and new-sessions-only. The steps: build the 4.0.0 manifest and let CI verify the schema hashes; run the eval set against 4.0.0; deploy the 4.0.0 runner beside 3.4.0; point new sessions at 4.0.0 for 5 percent of users, then 50, then 100, watching tool error rate and escalation rate per bundle. Meanwhile 3.4.0 keeps serving its pinned sessions. Its active count falls from about 12,000 to under 300 in three days; the remaining idle sessions hit the seven-day cutoff, and 3.4.0 is retired. Any unknown-tool error naming lookupOrder in the 4.0.0 logs would mean the pin leaked, so it is an alert, not a log line.

Failure modes

  • Unknown tool after deploy. A session moved to a bundle that removed a tool its history uses. Cause: no pinning, or a major change shipped as minor.
  • Behaviour drift with no deploy. The manifest used a moving model alias. Pin model IDs and alert when the provider reports a different model than requested.
  • State read errors after rollback. Sessions written by the newer schema were moved back. Enforce the schema rule in the router.
  • Old runner removed too early. Pinned sessions fall back to a bundle they are incompatible with. Gate retirement on the active-session count.
  • Unattributable regressions. Logs carry the jar version but not the fingerprint, so two bundles on one jar cannot be told apart.

Trade-offs

ChoiceGainCost
Pin sessions to bundlesOld conversations never meet incompatible codeSeveral runners deployed at once; fixes need patch releases
Always run latestOne runner, simple opsEvery major change breaks live sessions
Lazy state migrationNo downtime, sessions upgrade on touchMigration code to maintain and test
Short idle cutoffOld bundles retire fastUsers lose long-running conversations

What to do next

  1. List your agent's version axes and write the first manifest, including model ID and ADK version.
  2. Compute and log the bundle fingerprint on every invocation and in traces.
  3. Store the bundle version in session state at creation and route turns by it.
  4. Add a CI check that recomputes tool schema hashes against the manifest.
  5. Adopt the compatibility table and decide patch, minor or major for each change in review.
  6. Pin the ADK version with one Maven property and build a session replay test for upgrades.
  7. Publish an idle cutoff and retire bundles only when their active-session count reaches zero.
  8. Write the rollback rule for major releases before the next one ships.
Key takeaway: Version an ADK Java agent as a bundle of instruction, tools, schemas, model, settings, state schema and library version; fingerprint it, pin every session to the bundle that created it, ship breaking changes to new sessions only, and retire old bundles when their sessions are gone.