Chapter 5 of 7 · 12 min read
Renaming a tool parameter
On this page
A parameter name is not documentation. It is the strongest single piece of evidence the model has about what belongs in that slot, which is why renaming one is sometimes the whole fix for a class of wrong calls, and why doing it in place is the version of a schema change with the widest blast radius available. Chapter 4 established why the old shape keeps arriving after a deploy: the model is conditioned on a history full of its own prior calls, and stored transcripts reissue those calls on demand for as long as they are retained.
This chapter is about the migration itself — the shape of the schema while it carries both names, the adapter that maps one onto the other, and the signal that says when the old name can go. The reason it needs a chapter rather than a paragraph is that the obvious alternative, a second tool carrying the new interface, is a real pattern with a real domain, and a rename is not in it.
What an in-place rename actually breaks
The distinct thing an in-place rename does is convert the arrival of an old shape into a hard failure in three places at once, and it is worth separating them because they have different blast radii and different recovery stories.
Live conversations that predate the deploy break at their next tool call, since the model is copying a demonstrated shape that the schema no longer permits. The user sees a stall or an apology; the conversation usually recovers within a turn or two once the error text tells the model the parameter is unknown, at the cost of a degraded stretch.
Queued retries break silently, because a dead-letter job replaying a stored call has no model in the loop to adapt — the call is a frozen JSON object, it fails validation, and it fails again on every subsequent attempt until it exhausts its retry budget and becomes a permanent loss.
Transcript replay breaks in the way that is hardest to notice, because the replay does not error out as a whole. It diverges. The point of replaying a transcript is to reproduce a specific downstream state, and a validation failure at turn six means every turn after it happens in a different world from the recorded one, so the harness runs to completion and reports that the bug does not reproduce. That is a false negative on a debugging tool, and false negatives on debugging tools cost far more than the confusion they announce themselves with.
How many callers each of those three has is a query rather than a guess, and running it before choosing a migration shape is the difference between pricing the change and hoping. Live conversations older than the deploy are a count over the conversation store. Queued retries are a count over the dead-letter queue. Stored transcripts containing a call to the affected tool are a count over the transcript store, and it is usually the largest of the three by an order of magnitude, because transcripts accumulate for as long as retention allows while conversations close. None of those counts scales with the size of the rename. A one-line change to a parameter name and a wholesale redesign of the tool have the same population of frozen callers, since what determines the population is how often the tool was called and how long the store keeps things, and neither of those is affected by what you are about to do to the schema.
The additive shape
The migration that survives all three keeps the old field callable and makes it mean the new one. The tool definition carries both parameters, neither is required on its own, and the wording of each description does the steering that the schema alone cannot.
{
"name": "update_account",
"description": "Update fields on a single billing account. Identify the account by account_id.",
"input_schema": {
"type": "object",
"properties": {
"account_id": {
"type": "string",
"description": "The account to update, e.g. acct_8Q2R. A customer may hold several accounts; this is the specific one, not the customer."
},
"customer_id": {
"type": "string",
"description": "Deprecated alias for account_id, accepted only for compatibility with earlier calls. Do not use; pass account_id instead."
},
"fields": { "type": "object" }
},
"anyOf": [
{ "required": ["account_id", "fields"] },
{ "required": ["customer_id", "fields"] }
]
}
}Two details in that schema carry most of the weight. The anyOf, the JSON
Schema keyword requiring that at least one of the listed variants be satisfied,
means a call with neither identifier still fails, which matters because making
both optional in the naive way turns a missing id into a runtime KeyError
rather than a validation error the model can read and correct. And the
deprecated field's description is written to be read by a model that is
simultaneously looking at three of its own prior calls using that field, so it
has to be blunter than a human-facing deprecation note would be; "Do not use;
pass account_id instead" is an instruction, whereas "will be removed in a future
release" is a fact about your roadmap that gives the model nothing to act on
this turn.
The adapter belongs at the boundary, before validation of the business meaning and before the handler sees anything, so that exactly one code path exists downstream.
LEGACY_ALIASES = {"update_account": {"customer_id": "account_id"}}
def normalise_arguments(tool_name: str, args: dict) -> dict:
args = dict(args)
for old, new in LEGACY_ALIASES.get(tool_name, {}).items():
if old not in args:
continue
legacy_value = args.pop(old)
if new in args and args[new] != legacy_value:
# Hybrid call: the model emitted both, with different values.
metrics.increment("tool.alias_conflict", tags={"tool": tool_name})
continue # the new field wins; the legacy value is discarded
args.setdefault(new, legacy_value)
return argsThe conflict branch exists because of the hybrid calls chapter 4 described, where a model reconciling an old demonstration against a new schema emits both fields. Resolving in favour of the new name is the right default, since the new name is the one the current description explains, and counting the conflicts separately matters because a rising conflict rate is a signal that your deprecated field's description is not steering hard enough. On one billing agent, conflicts ran at 0.9% of calls in the first three days and under 0.1% after a week.
Retiring the alias is the second half of the work, and it is a second byte change to the tool block. The additive migration therefore costs two cold windows rather than one — the add, and the removal roughly fifty days later once the live, retry and replay signals from chapter 4 have all been quiet. Both windows are priced by where the tool sits in the prefix, which is the offset map from chapter 2, and on that service it was 118,000 tokens of invalidation each time. Two windows is the correct price and it is worth stating explicitly, because teams sometimes compare the additive path against an in-place rename on cache cost alone, notice it is twice as expensive, and take that as an argument.
It is worth being clear about why the removal is a second window rather than a return to a state that was already cached. Deleting the alias produces a third byte sequence, not a re-run of the first. The schema before the migration carried one identifier under the old name, the schema during it carries both, and the schema after it carries one under the new name. Those are three different documents, and the matcher compares documents rather than intentions, so nothing is recovered from the fact that the tool is back to having a single id parameter. A prefix cache has no notion of undo. Every edit is a new prefix, including an edit whose whole purpose is to reverse an earlier one.
Three shapes, compared on the things that differ
| Rename in place | Additive alias + adapter | Parallel update_account_v2 | |
|---|---|---|---|
| Pre-deploy live conversations | Fail at next call, recover after a degraded turn or two | Keep working, wrong-entity risk persists | Keep working against v1 indefinitely |
| Queued retries of stored calls | Permanent loss after retry budget | Succeed, mapped forward | Succeed against v1 |
| Transcript replay | Diverges at first affected turn; false "does not reproduce" | Reproduces faithfully | Reproduces faithfully |
| Cold-cache windows paid | One | Two, ~50 days apart | Two, and a permanently larger tool block until v1 goes |
| Tool-block size during migration | Unchanged | +1 parameter, ~40 tokens | +1 whole tool, ~300 tokens |
| What the model must decide | Nothing | Which of two names to use | Which of two tools to call |
| Retirement signal | None needed | Alias usage across live, retry, replay | v1 call count across the same three |
| Right when | The tool has no stored callers at all | A field is renamed or reshaped | Semantics change, not just names |
The bottom row is the whole answer to whether a parallel v2 tool is a reasonable
counter-proposal. It is a real pattern with a real domain, and the domain is not
this. Exposing update_account_v2 alongside the original moves the
compatibility decision from a parameter the model fills in to a tool the model
selects, and tool selection is the harder of the two decisions to steer — the
model now weighs two entries in the tools array whose names differ by a suffix
that means nothing to it, whose descriptions must each explain why the other
exists, and it will sometimes pick the deprecated one because the history
demonstrates the deprecated one. You have also doubled the tokens this
capability occupies in a block that sits at the front of the prefix, and you
still have to run the same retirement signal, so the second cold window arrives
anyway.
The size row in that table is the part worth converting into a rate before deciding. An extra parameter adds tens of tokens to the block; an extra tool adds hundreds, which is something like seven or eight times as much for the same capability. Where those tokens land is what makes the multiple matter rather than the multiple itself. The tools array sits at the front of the serialised request, so anything added to it is paid on every request for the whole compatibility window, and under a parallel v2 it keeps being paid after the window closes for as long as v1 is still exposed. The alias is a small permanent-feeling cost with a scheduled end date. The second tool is a larger one whose end date is a separate decision somebody has to remember to make.
Where v2 earns itself is when the two versions cannot be reconciled by a
mapping. If the new tool takes a structured fields object where the old one
took a flat set of top-level keys, if the semantics change so that the old call
would now do something different rather than the same thing under another name,
if the two need different authorisation scopes, or if the parameter sets diverge
enough that a single schema carrying both becomes a document the model cannot
read cleanly — then one tool with two personalities is worse than two tools with
one each. A rename is the case where the mapping is total and one line long,
which is exactly when the adapter is cheaper than the ambiguity.
The bug the alias preserves
The uncomfortable property of this migration is that it preserves the bug for
its whole duration. customer_id existed as a name that led the model to pass a
customer's identifier, and the additive schema keeps that name in front of the
model for fifty days, so calls that pass a customer id into the alias continue
to update the wrong account at whatever rate the wording fails to prevent. The
adapter maps the field forward; it cannot map the value forward, because it has
no way to know which of a customer's accounts was meant.
"Whatever rate the wording fails to prevent" is a quantity, and the scenario above already reported it in a different unit. One call in forty is two and a half calls in a hundred, which is the same measurement written as a percentage, and writing it that way is what makes it comparable with anything measured after a change. That conversion is the whole reason to insist on a rate rather than a ratio in a postmortem: a fraction with a denominator somebody chose for readability cannot be put beside a percentage from a dashboard without one of them being rewritten first, and rewriting it afterwards is where a factor of ten usually enters an argument.
There are two mitigations and neither is complete. The first is in the
description wording above, which does not merely deprecate the field but states
the distinction the model was getting wrong, and on that service it alone took
the wrong-account rate from 2.5% to about 0.4% within a day, before any of the
old calls had drained. The second is a validation on the value rather than the
field: an id that carries a cust_ prefix arriving in either parameter is
rejected with an error explaining the difference, which converts a silent
wrong-entity update into a correctable failure. Whether you can do that depends
on whether your identifiers are typed enough to tell apart, and on that service
they were, which is not a general assumption.
What generalises from those two is that during a compatibility window the description does more of the work than the schema. The schema can only say that both names are accepted, which is both the thing you needed and the thing that keeps the bug alive; the description is where you say which one is correct and why the other misleads. A deprecated field whose description reads "deprecated" and nothing else keeps being called at close to its old rate for the whole window.
What neither mitigation settles is the accounting. A migration that costs two cold windows, runs for fifty days, and moves a wrong-entity rate from 2.5% to 0.4% has a number attached to it in both directions, and when someone asks after the first deploy what it cost, the dashboards that exist by default cannot produce an answer that means anything.
Tool versioning without invalidating the prompt cache — read the current version at https://daviddominguez.dev/learn/en/tool-versioning-without-invalidating-the-prompt-cache/renaming-a-tool-parameter