ArticleAgent Reliability

Version Tool Schemas Without Breaking In-Flight Agent Runs

Renaming a tool or tightening its JSON Schema is easy when every run starts after deployment. Durable agents make the old contract part of stored execution state. Preserve that contract, adapt it at one boundary, and retire it after queued, active, and replayable work no longer depends on it.

Last reviewed: Aug 27 2026

Three black architectural modules connected by glowing cyan and orange pathways on a dark platform.
The compatibility adapter is the bridge: old and new contract paths stay distinct while both reach the same domain boundary.

TL;DR

Treat a tool schema as immutable execution data, not mutable configuration. Give each contract a stable internal ID, bind that ID to the run when the run is admitted, and keep old definitions resolvable. Route both v1 and v2 calls through a compatibility adapter that produces one stable domain command. Build replay fixtures from real checkpoint shapes, deploy old-schema readers before new-schema writers, and remove v1 after a query proves that no queued, active, failed-retryable, or retained replay state can still reference it.


The Schema Is Part of the Checkpoint

A durable agent does not execute entirely inside the deployment that created it. A run may wait in a queue before its first model call, pause after the model emitted a tool call, or resume from a checkpoint after the tool produced an external effect. If the runtime looks up “the current refund tool” at resume time, one logical run can see two incompatible contracts.

Imagine that v1 exposes refund_order with an unconstrained string field named reason. Version 2 renames the tool to request_refund, replaces that field with a controlled reason_code, and adds an optional note. A queued run may still contain a prompt that names v1. A paused run may already contain serialized arguments for v1. A replay may contain the old call plus a completed side effect. Changing one global definition breaks each state differently.

The fix is to resolve the contract once and store the decision. Persist an internal identifier such as refund_tool.v1 beside the run, then load that exact immutable definition whenever the run resumes. Do not store merely “refund tool,” a code pointer, or a mutable registry key. When several tools ship as one set, pin the toolset version on the run instead; it resolves to exactly one immutable contract ID per tool, which is why the examples below carry both names.

type RunRecord = {
  id: string;
  status: "queued" | "running" | "waiting_tool" | "retryable" | "done";
  toolset_id: "support_tools.2026-08-01" | "support_tools.2026-08-27";
  created_at: string;
  checkpoint: unknown;
};

const run: RunRecord = {
  id: "run_1042",
  status: "queued",
  toolset_id: "support_tools.2026-08-01",
  created_at: "2026-08-27T06:40:00Z",
  checkpoint: null
};
Hashing Is Not Versioning

A schema hash can detect accidental mutation, but it is a poor operational name. Keep both: a readable immutable contract ID for routing and dashboards, plus a digest of the canonical payload for integrity. If the digest changes under the same ID, fail deployment rather than silently redefining history.


Snapshot the Provider-Facing Contract

Keep the tool definition supplied to the model separate from the handler that performs work. In the current OpenAI Responses API, a request can include an array of tools; a function-tool definition includes fields such as name, parameters, strict, and an optional output_schema. The Response has a separate lifecycle status. That API shape is a useful concrete example, but the version registry below is application architecture rather than an OpenAI requirement. See the current create-response reference.

Snapshot the complete tool payload that your provider request uses, not merely the JSON Schema fragment. A tool rename, description edit, required-field change, enum change, strictness toggle, or output-schema change can alter planning or validation. The handler address should not be embedded in that snapshot; map the immutable contract ID to executable code in your runtime.

const refundToolV1 = {
  type: "function",
  name: "refund_order",
  description: "Refund a paid order",
  strict: true,
  parameters: {
    type: "object",
    properties: {
      order_id: { type: "string" },
      reason: { type: "string" }
    },
    required: ["order_id", "reason"],
    additionalProperties: false
  }
} as const;

const refundToolV2 = {
  type: "function",
  name: "request_refund",
  description: "Request a refund for a paid order",
  strict: true,
  parameters: {
    type: "object",
    properties: {
      order_id: { type: "string" },
      reason_code: { type: "string", enum: ["duplicate", "faulty", "other"] },
      note: { type: "string" }
    },
    required: ["order_id", "reason_code"],
    additionalProperties: false
  }
} as const;

The paired definitions have the same wrapper so the breaking differences are visible. Versioning the tool name is useful when calls can sit outside your process, because the name itself routes to the correct decoder. If a provider or framework constrains public names, keep its accepted name and carry the immutable version in your run record. Either way, do not infer the decoder from deployment time.


Normalize Both Versions Into One Domain Command

The compatibility layer should be boring: validate the historical shape, translate it, and hand one canonical command to business logic. Do not teach the refund service about every prompt-era field name. This is also where you can make a previously implicit default explicit.

type RefundCommand = {
  orderId: string;
  reasonCode: "duplicate" | "faulty" | "other";
  note?: string;
};

function decodeRefundCall(
  contractId: "refund_tool.v1" | "refund_tool.v2",
  raw: unknown
): RefundCommand {
  if (contractId === "refund_tool.v1") {
    const value = RefundArgsV1.parse(raw);
    return {
      orderId: value.order_id,
      reasonCode: "other",
      note: value.reason
    };
  }

  const value = RefundArgsV2.parse(raw);
  return {
    orderId: value.order_id,
    reasonCode: value.reason_code,
    note: value.note
  };
}

Mapping every old unconstrained reason string to other is intentionally lossy and visible. If reporting needs the original string, retain it as migration metadata rather than pretending that historical prose fits a new enum. If no honest mapping exists, keep the v1 handler alive; a compatibility adapter is not permission to fabricate semantics.

Validation happens before normalization. A permissive “accept either shape” schema loses the information needed to choose defaults and produce useful errors. Select the validator from the stored contract ID, reject unknown fields according to that version, and attach run_id, tool_call_id, and contract_id to the resulting audit event.


Build Replay Fixtures at Three Cut Points

A single fixture for an old JSON object is not enough. Capture the states your runtime can actually restore, including the envelope around the tool arguments. The minimum useful matrix has three rows:

  1. Queued before model execution: the run stores toolset_id=v1 but no model output. On replay, assert that the provider request still contains the exact v1 tool name and schema digest.
  2. Waiting with an emitted tool call: the checkpoint stores refund_order and v1 arguments. On replay, assert that v1 validation runs, one canonical command is produced, and the v2 validator is not consulted.
  3. Checkpointed after the effect: the record contains the old call, its idempotency key, and a completed tool result. On replay, assert that the stored result is returned and the refund side effect executes zero times.

The third fixture joins schema compatibility to effect safety. A perfect decoder can still duplicate a refund if replay forgets that the call already committed. Use the effect ledger and idempotency design from Idempotent Tool Calls for AI Agents, and use the checkpoint ordering in Resume an Agent Run After a Crash.

it("replays a completed v1 call without refunding twice", async () => {
  const fixture = loadFixture("refund-v1-after-effect.json");
  const issueRefund = vi.fn();

  const result = await resumeRun(fixture, { issueRefund });

  expect(result.toolOutput).toEqual(fixture.completed_tool_output);
  expect(result.contractId).toBe("refund_tool.v1");
  expect(issueRefund).not.toHaveBeenCalled();
});
Store Fixtures Before the Migration

Generate sanitized fixtures from each persisted checkpoint version while the old code can still explain them. Add the schema digest and expected canonical command to the fixture. A hand-written “old” payload created after v2 ships often omits precisely the legacy field or envelope that breaks production replay.


Deploy Readers Before Writers

The rollout order is a protocol. Each step must be independently safe to pause or roll back.

  1. Freeze v1. Assign its immutable ID and digest, export replay fixtures, and make registry mutation a deployment failure.
  2. Ship compatibility readers. Every worker that can claim or resume a run must resolve v1 and v2, validate both, normalize both, and emit per-version metrics. Keep new-run admission on v1.
  3. Verify mixed-fleet behavior. Run v1 replay fixtures against the new workers and v2 fixtures against a canary. Do not proceed if any consumer still understands a single version.
  4. Switch new-run admission to v2. Change the admission default. Existing run records keep their stored toolset_id; retries do not upgrade themselves.
  5. Drain and observe. Watch queued, active, retryable, quarantined, and archived replay dependencies separately. A quiet live queue does not prove that retained checkpoints are safe to forget.

Rollback after step four means restoring v1 as the default for new runs while leaving both readers deployed. Rewriting v2 run records to v1 is a separate data migration and is usually unnecessary. This expands the general public-contract sequencing in The AI API Contract Change Plan to model-facing schemas and stored execution state.


Retire v1 From Evidence, Not Age

“It has been two weeks” is not a retirement proof. Your evidence must cover every state from which work can return. Query live run rows, delayed queues, dead-letter or retry stores, and the oldest checkpoint that operations promises it can replay. If cold archives are restorable, either retain the old adapter with them or migrate and verify those archives before deletion.

select status, count(*) as dependent_runs
from agent_runs
where toolset_id = 'support_tools.2026-08-01'
  and status in ('queued', 'running', 'waiting_tool', 'retryable')
group by status;

select count(*) as replayable_checkpoints
from run_checkpoints
where toolset_id = 'support_tools.2026-08-01'
  and expires_at > current_timestamp;

Retirement requires both result sets to be zero, the v1 adapter-use metric to stay at zero across the longest retry delay, and a restore drill to confirm that the oldest supported snapshot no longer references v1. Then remove v1 from new-provider requests, wait through one ordinary deployment rollback window, and delete the decoder in a later change. Separating those deletions gives rollback a known-good reader.

Before changing provider-facing fixtures, check the current OpenAI API changelog as well as the create-response reference. The changelog is a maintenance input, not a substitute for replaying your own stored envelopes.

Tool Contract Migration Checklist


Primary References — Verified Aug 27 2026

Related Guides

Resume an Agent Run After a Crash

Design checkpoints, effect boundaries, and replay classes so a durable run continues from recorded truth.

Idempotent Tool Calls for AI Agents

Prevent a replayed or redelivered tool call from repeating an external side effect.

The AI API Contract Change Plan

Sequence public API changes across producers, consumers, observability, and rollback.

Back toHome