Skip to content
4 min read · 814 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.

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()