forgeTools(ctx) in depth
Artifacts covers the overview. Extending artifacts covers the subclass pattern, ArtifactTool, and what artifacts deliberately do not do.
SpooledArtifact.forgeTools is the static factory that takes an DispatchContext and returns a ToolRegistry of fresh ArtifactTool instances bound to the artifacts visible in DispatchContext.turnToolCalls. It is the canonical way to hand the model a query surface over results it has already produced this turn. The behaviour is small and worth knowing in full because everything else in Extending composes out of it.
The factory walks DispatchContext.turnToolCalls, filters to calls whose ToolCall.results are an instance of the class SpooledArtifact.forgeTools is being called on (SpooledArtifact for the base, SpooledJsonArtifact for the JSON subclass, and so on), excludes any call flagged ToolCall.fromArtifactTool=== true, and collects the ToolCall.id of each remaining ToolCall into a list of compatibleIds. If the list is empty, the factory returns an empty ToolRegistry — there is no point shipping a tool whose callId enum has no values. You merge the result unconditionally; the empty case is a no-op.
When the list is non-empty, the factory mints one ArtifactTool per descriptor in the class's toolMethods. Each tool's inputSchema includes a callId field declared as validator.string().valid(...compatibleIds).required() — the model sees the explicit enum of valid choices in the tool definition the executor renders, and the validator rejects any callId outside that enum before the handler runs. Each tool is marked ephemeral: true (it belongs to one dispatch) and onCollision: 'replace' (so re-forging across subclasses on the same dispatch merges silently — overlapping base-method tools are behaviourally interchangeable). The handler resolves the artifact by finding the ToolCall whose ToolCall.id matches the callId, dispatches the ToolMethodDescriptor.method against it, and serialises the return value through descriptor.serialise or the default formatter (string as-is; string-array newline-joined; number stringified; otherwise JSON.stringify with two-space indent).
Snapshots go stale
The callId enum is frozen at forgeTools(ctx) call time. New tool calls produced after the snapshot are not in it. Carry a forged registry across executor invocations and the enum becomes a lie — iteration N+2 cannot reference calls produced in iteration N+1. Re-forge every executor invocation. The lifecycle hook below exists to make that automatic.
The lifecycle hook is ToolRegistry.bindContext, which calls DispatchContext.onAck(() => ToolRegistry.pruneEphemeral()) — pruning runs when the dispatch acks and only when it acks. Failed dispatches (nack) leave the forged tools in place so you can inspect what was forged when debugging the failure; the registry dies with the turn either way. Iteration boundaries would be the wrong scope to bind to, because an iteration is one model round-trip and a dispatch is several — pruning per iteration would drop the forged tools before the next iteration's model call could use them. Dispatch ack is the right scope and the only one the lifecycle uses.
Forgetting bindContext leaks tools to the model, not heap bytes
This is a capability leak — what grows is the set of tools the model can invoke, not RAM usage (and emphatically not the Memory primitive, which is unrelated). Ephemeral tools from previous dispatches stay registered and remain on offer to the model, the next SpooledArtifact.forgeTools sees a stale callId enum that excludes the calls it should be enumerating, and the model is offered handles that point at artifacts from a dispatch that has already finished. The bug is silent and the symptoms appear two iterations later, which is the worst possible combination of properties for a bug to have. The canonical wiring pattern lives in Forging tools; copy it, do not paraphrase it.