Skip to content
6 min read · 1,278 words

ToolRegistry, lifecycle, and collisions

The registry side of the tool seam: how tools are collected, how collisions are reconciled, and how a turn's registry is scoped.

Tools covers the Tool constructor and handler contract. bindContext and describe() covers the ephemeral-pruning lifecycle hook and the plain-object form executors read to render provider tool definitions.

ToolRegistry

A ToolRegistry is a name-keyed collection of Tool instances with one collision policy and one lifecycle hook. Every TurnRunner.run call constructs a fresh registry from the runner's configured baseline tools, hands it to the turn via TurnContext, and throws it away when the turn ends. Middleware can register tools, unregister tools, merge in dynamically-built tools, and prune ephemeral tools — none of those edits touch the runner's baseline. The next turn starts with the configured tool list again, in exactly the state you wired it in with.

The instance API is intentionally small: ToolRegistry.register(tool, overwrite?) adds a tool (and throws E_TOOL_ALREADY_REGISTERED on a name clash unless overwrite: true); ToolRegistry.unregister(name) removes one; ToolRegistry.get(name) and ToolRegistry.has(name) are the lookups; ToolRegistry.all() returns a fresh array in insertion order; ToolRegistry.pruneEphemeral() drops every tool whose ephemeral flag is true; ToolRegistry.bindContext(ctx) is the lifecycle hook documented below.

all() returns a fresh array (Array.from(values)) of the live Tool references — mutating the array cannot mutate the registry, and the Tool instances themselves are immutable. If you want a registry with a different tool, you build a new registry or merge one in.

What the registry does not do

It does not retry. It does not rate-limit. It does not authorise. It does not cache tool definitions. It does not deduplicate calls. It does not order or prioritise. It is a name-keyed collection of validated capabilities with one lifecycle hook and one collision policy, and every behaviour that is not exactly that is a middleware concern by design.

Collision policy

Two registry operations can collide: register and merge. They take different positions on collisions because they exist for different reasons.

ToolRegistry.register is the explicit single-tool path. It throws E_TOOL_ALREADY_REGISTERED on a name clash unless the caller passes overwrite: true. The tool's own Tool.onCollision is ignored here — if you are calling register directly, the ADK assumes you already know whether you mean to replace something. Surprise replacement on a typo is the failure mode that flag is preventing.

ToolRegistry.merge is the composition path: combine two or more registries into a fresh one without mutating any input. Baseline tools, battery tool barrels, and forged artifact-query tools meet there. Every incoming tool gets a chance to say what should happen when it collides with another. The incoming tool's Tool.onCollision is consulted first — 'replace' means it wins, 'keep' means the existing entry wins, 'throw' (the default) means defer the decision to the merge-level option. The merge-level option, again, defaults to 'throw', which means a collision nobody resolved raises E_TOOL_ALREADY_REGISTERED and the merge fails out loud. The forged artifact-query tools set onCollision: 'replace' on themselves so re-forging across SpooledArtifact subclasses on the same dispatch resolves silently — their behaviour is interchangeable, and the only thing that would change on replacement is the closure identity nobody is reading.

Why two policies?

Because a name clash in register is almost always a typo or a wiring bug — fail loud, by default, no exceptions. A name clash in merge is almost always two intentional sources colliding on a known shared name — let the tool itself say what it wants. Same data structure, different default posture, because the contexts the two operations live in have different defaults for what surprises you want.

Per-turn lifecycle

The registry the runner hands to a turn was built fresh for that turn. Middleware reaches it through TurnContext.tools and is free to mutate it: register one-off tools, unregister tools the policy currently forbids, merge in a battery's tool barrel, merge in tools forged from a SpooledArtifact. Whatever that turn's middleware does to its registry is local to that turn — no concurrent turn sees it, no subsequent turn inherits it, and the runner's baseline configuration is the one the next turn starts from. This is the property middleware composition relies on to be safe by default.

The one sharp edge is ephemeral tools — tools with Tool.ephemeral set to true that exist only for one dispatch. Leave them registered after their dispatch and the model keeps seeing stale capabilities with dead artifact ids. That is not memory pressure; it is an authority leak. ToolRegistry.bindContext is what prevents it.

Hidden tools

TL;DR

Hidden tools are registered and callable but not advertised to the model. Use them when you want the model to discover tools through a dedicated discovery tool rather than listing everything upfront. Hidden state lives on the registry, not the tool — the same tool can be visible in one registry and hidden in another.

A tool can be registered and callable without being immediately visible to the model. This is the hidden state — a property of the registry, not of the tool itself. The same tool wired into two registries can be visible in one and hidden in the other.

Hidden tools exist for one reason: discovery patterns where the model needs to know a tool exists before it can call it, but listing every tool upfront wastes context. The canonical shape is:

  1. The turn starts with a small set of visible tools and a larger set of hidden tools.
  2. One of the visible tools is a discovery tool — it enumerates the hidden tools (their names, descriptions, and schemas) so the model can choose.
  3. The model calls the discovery tool, reads the result, and decides which hidden tool to invoke.
  4. On the next iteration, the model calls the hidden tool by name. The registry resolves it, the battery executes it, and the result comes back.

The model never sees hidden tools in its tool definition list — the LLM batteries read visible() when building the provider request, and hidden tools are not in that set. But get() checks the full registry, so a hidden tool called by name resolves and executes normally. The model can call a tool it discovered without ever having seen it in the tool definition list.

Hidden is not unregistered

A hidden tool is still registered. It still resolves when called by name. It still appears in all(). It still participates in collision resolution during merge. The only thing that changes is whether the LLM battery includes it in the tool definitions sent to the model. If you want a tool to be uncallable, unregister it. If you want it to be callable but not advertised, hide it.

Lifecycle interactions

Unregistering a tool automatically removes it from the hidden set — there is nothing left to hide. Re-registering a tool with the same name does not restore its previous hidden state; hiding is a deliberate choice you make per-registry, per-turn.

Merge propagates hidden state. A tool that was hidden in any source registry remains hidden in the merged result, provided it survived collision resolution. This means merging a registry with hidden tools into a fresh registry preserves the visibility contract without extra bookkeeping — you do not need to re-hide tools after every merge.

bindContext and describe()

registry.bindContext(ctx) wires an ack handler that prunes ephemeral: true tools when the dispatch acks, and tool.describe() is the plain-object form an executor reads to produce a provider-specific tool definition.

→ Continue reading: bindContext and describe()