Skip to content

13 min read

Deprecated fields and conversation history

On this page

An agent conversation is stateless on the wire. Turn nine does not resume turn eight; it re-sends turns one through eight in full, including every tool_use block the model emitted and every tool_result that came back — the first being the structured block in which a model asks for a call by name and arguments, the second the block in which your service returns what that call produced — and then appends the new user message. That is how the model has any memory at all.

It also means that the request carrying your brand-new schema carries eight demonstrations of the old one, positioned much closer to the point of generation than the schema is. Chapters 1 through 3 treated a schema edit as an event with a cost measured in cache windows, which is a matter of minutes. This chapter is about the other clock, which runs for weeks: what a deployed schema change does to conversations that were already open when it landed, and how you find out when the old shape has genuinely stopped arriving.

Every turn re-sends every prior call

Sequence: App → Model (tools[search_shipments: since, until, days_back(deprecated)] + history turns 1-8 + new user turn); Model → App (tool_use search_shipments {days_back: 14}); App → Model (+ that call, + its result, + turn 10).

The reinforcement compounds, which is what makes the tail long rather than merely present. Each turn where the model copies the old shape adds another instance of the old shape to the history that the next turn will read, so a conversation that has called the tool three times the old way is more likely to make the fourth call the old way than it was to make the third. Topic 5.11, on tool descriptions functioning as few-shot examples, established that in-context examples move tool selection and argument shape harder than a description does; this is the same mechanism operating on content you did not write and cannot edit, because a stored assistant turn is a record of what the model actually emitted and rewriting it would be falsifying the transcript.

Conversation age therefore predicts old-field usage almost perfectly. Conversations that started after a deploy take the new fields immediately, at a rate consistent with the schema alone, since they have no contradictory examples to learn from. Conversations that started before it keep the old shape roughly in proportion to how many old-shaped calls they already contain. One support agent that replaced a days_back integer with an explicit since and until pair had a median session length of about four days and a p95 of eleven, meaning nineteen sessions in twenty finish sooner than that, and the decay curve of days_back traffic is that survival curve, offset by whatever fraction of those sessions touch the tool again.

Days after deployCalls carrying days_backDominant source
141%Live pre-deploy conversations, still short
318%Live pre-deploy conversations, thinning
79%Long-running sessions plus retry queue
143.1%Transcript replays, some retries
211.4%Transcript replays only
21 (Monday 09:00–11:00)4.0%Batch replay of weekend escalations

The first five rows are a survival curve wearing a percentage, and reading them that way lets a second instrument check them. Each figure is roughly half the one a few days before it, which is the shape a population of conversations produces as its members finish, and the curve should therefore run out at about the point the sessions do. It does. The p95 session length is eleven days, so by the fortnight row almost every conversation that was open at the deploy has closed, and that is exactly where the Dominant source column stops saying live and starts saying replay. Two independent things — a session-length statistic measured from the conversation store, and a source attribution measured from call counters — land on the same date without either being used to compute the other, which is the strongest evidence available that the mechanism is the one being claimed rather than a deployment fault that happens to be fading.

The last row is the one that has no decay curve at all. When an escalation comes in with a reproduction attached, an on-call engineer replays the stored transcript against the current service to see the failure again, and a transcript captured two weeks ago contains two-week-old tool calls no matter what day it is replayed on. Replay traffic has a retention window rather than a half-life: as long as thirty days of transcripts are kept and support replays them, the old field arrives from that source at a steady low rate until the last transcript containing it ages out.

A rate that goes back up is the most useful thing in that table, because a decaying population cannot produce one. Conversations only ever finish, so any mechanism made of open conversations gives a curve that falls and keeps falling. A figure that returns to nearly three times the surrounding level, at the same hours on the same day of the week, is therefore made of something else, and periodicity narrows the field fast: infrastructure faults do not repeat on a weekly cycle at nine in the morning, and human working patterns do almost nothing else. The period names the process. A Monday-morning peak points at whatever queue accumulates over a weekend and gets worked through when people come back, and in an agent deployment that is the escalation queue and the replay harness attached to it.

A history the model cannot reconcile

The second-order effect is worse than the persistence, and it shows up when the compatibility shim is thinner. If days_back had been deleted rather than deprecated, turn nine would present the model with tool definitions containing no days_back parameter and eight turns of evidence that the tool is called with a days_back parameter, and both are in the same context window with equal claim to being true.

Reading that transcript, a human resolves it instantly, because the deploy is a fact outside the document and the timestamps make the sequence obvious. The model has no outside. It has one flat context in which the schema says one thing and the demonstrated usage says another, and the resolutions it reaches vary. Some calls come out in the old shape, in defiance of the schema, which then fail validation. Some come out in the new shape and look fine. And some come out as hybrids — during the first two days of that migration, around 2% of calls arrived carrying both days_back: 30 and a since timestamp that had been derived from it incorrectly, computed against the conversation's start rather than the current date. Those are the expensive ones, because they validate. A hybrid call passes the schema check, executes, and returns shipments from the wrong window, and nothing in the error rate moves.

Hybrids are the predictable outcome of the compatibility shim rather than a surprise, and the chain that produces them is short enough to check. The model is holding two accounts of how the tool is called, both inside one context and neither marked as superseded. Its job at that moment is to emit a continuation that fits what it has read, and the continuation that fits both accounts is the one that satisfies both. Emitting both fields is that continuation. A schema that has been widened to accept either field will accept both, so nothing downstream refuses it, and the call proceeds. Every link there follows from the decision to keep the old field callable, which is the decision that made the migration survivable in the first place — the same unreconciled context, seen from the schema's side rather than the model's.

Conversations that hit this state also tend to degrade in ways that are not about the tool. A model that has produced one rejected call frequently spends the next turn apologising, retrying with a third variant, or narrating its confusion to the user, and the transcript picks up a stretch of low-quality turns that the model then reads on turn ten as further evidence of how this conversation goes. The cost of a hard rename is not one failed call per affected conversation; it is a degraded remainder of every affected conversation.

Deprecation dates assume a caller that can read them

The reflex when a field must change is the API deprecation cycle every backend engineer has run a dozen times: announce it, put a notice in the docs and the response headers, give clients two weeks or two quarters, then delete the field. That cycle rests on an assumption that holds for every HTTP client and fails completely here. It assumes the caller is a piece of software that someone can update, that reads the notice at some point before the deadline, and that once updated stops sending the old shape permanently.

The caller here is a model whose behaviour on this turn is conditioned on a history it emitted before the notice existed. There is no deploy that updates it, because it is not a client with a version; the closest analogue to "updating the client" is starting a new conversation, and that is not something you can do to conversations that are already open. Worse, a portion of the callers are not live at all. A stored transcript from October is a caller that will send the old shape on demand, forever, and no notice period reaches into a file on disk and rewrites what it contains. A deprecation notice in a tool description is read by the model that is generating right now, and it competes with the demonstrations in history, and it does not exist at all for the replay path where the transcript is fed back through a harness rather than through the live agent.

A date and a signal are not the same instrument. A date is a decision made on a whiteboard about when other people will have finished; a signal is a measurement of whether anything is still sending the old field. Retirement here has to be driven by the signal, and the signal has three sources that go quiet at different times and for different reasons.

python
# Emit one counter per accepted call, tagged with where the old shape came from.
def record_legacy_usage(call, ctx):
    if "days_back" not in call.arguments:
        return
    source = (
        "replay"     if ctx.is_transcript_replay
        else "retry" if ctx.is_queued_retry
        else "live"
    )
    metrics.increment(
        "tool.legacy_field",
        tags={
            "tool": call.name,
            "field": "days_back",
            "source": source,
            "conversation_age_days": min((now() - ctx.conversation_started).days, 30),
        },
    )

The live counter goes to zero when every conversation that predates the deploy has ended, which you can bound by your session expiry rather than by hope — if idle conversations are closed after fourteen days, live traffic on the old field is guaranteed dead fourteen days after the last pre-deploy turn. The retry counter goes to zero when the queue drains, which for a dead-letter queue — the holding area a job lands in once it has failed every attempt and is parked for a human — with a seven-day retention is seven days after the last enqueue. The replay counter goes to zero only when transcripts containing the old shape fall out of retention, which for a thirty-day window means thirty days after the last old-shaped call was written, and the last old-shaped call was written whenever live finally hit zero. The windows chain, so the honest retirement date for a thirty-day retention and a fourteen-day session expiry is somewhere past forty-four days, and the only way to know it has arrived is that all three counters have read zero for long enough that you would have seen a spike if one were coming.

The stumble here is taking the largest of those three windows instead of adding two of them, and it is tempting because that is the correct arithmetic almost everywhere else. Independent timers running at once finish when the slowest finishes, so a thirty-day retention alongside a fourteen-day expiry sounds like a thirty-day wait. These two are not running at once. Retention counts from the moment a transcript was written, and a transcript containing the old shape can be written on any day the old shape is still being emitted, which is any day live traffic has not yet gone quiet. The replay clock therefore cannot start until the live clock has stopped, and sequential timers add: fourteen days for the last pre-deploy conversation to close, then thirty more for the last transcript it produced to age out. The same reasoning is what makes a retention increase a schema-migration cost, since lengthening the store lengthens every deprecation that follows it.

The cost of carrying both shapes, and the fields that cannot wait

It does not make the schema smaller. You are carrying both shapes for the whole window, which costs tokens in the tool block on every request, and it costs the model a decision it would not otherwise have to make — topic 5.11's point about parameters that invite the wrong choice applies with full force to a deprecated field sitting there looking callable. The counters tell you when you can stop paying that; they do not reduce it in the meantime.

And it does not apply to a field that must stop working for reasons other than tidiness. The entire premise of watching a signal is that you are willing to keep honouring the old shape until the signal clears, which is a reasonable premise for a rename and an unreasonable one for a parameter that is a security problem. Chapter 7 takes up the case where honouring the old call is the thing you are trying to stop doing.

The general shape to carry out of this is that a tool schema has two kinds of caller and only one of them is a program. The live conversations are callers that adapt, slowly and unreliably, by being shown a new schema next to their own old examples. The stored transcripts are callers that never adapt at all, and they will keep issuing the old shape at whatever rate support replays them until the files themselves expire.

Nothing in this chapter says what the schema should look like while it carries both shapes, how the old field is mapped forward without the handler growing two code paths, or how you word a deprecation that a model reads rather than a human. Those are the mechanics of the migration itself, and they are what makes the difference between a compatibility window that works and one that quietly keeps producing the bug you were trying to fix.

Tool versioning without invalidating the prompt cache — read the current version at https://daviddominguez.dev/learn/en/tool-versioning-without-invalidating-the-prompt-cache/deprecated-fields-and-conversation-history

Back to top