Skip to content
4 min read · 722 words

Extending artifacts

The subclass extension pattern, the ArtifactTool recursion-break, and the explicit non-goals of the artifact layer.

Artifacts covers the base SpooledArtifact surface and the ephemeral lifecycle overview. forgeTools(ctx) in depth is the detailed factory walkthrough.

Subclass extension pattern

Every SpooledArtifact subclass follows one pattern. Your subclass follows the same pattern or it becomes the weird one future maintainers have to debug. Each subclass owns its own SpooledArtifact.toolMethods array, listing only that class's descriptors. Each subclass overrides SpooledArtifact.forgeTools to first call the base — const base = SpooledArtifact.forgeTools(ctx) — and then mint its own descriptors' tools against DispatchContext.turnToolCalls narrowed via plain instanceof ThisClass, and merges them onto the base registry. The merge resolves the overlap on the base-method tool names through onCollision: 'replace', which is silent because the dispatched method is the same in either case.

There is no requiresSubclass field on the descriptor, no helper indirection, no this-based class narrowing. The narrowing lives at each subclass's filter site, expressed as the most direct thing it could be: isInstanceOf(tc.results, ThisClass.name, ThisClass). The bundled SpooledJsonArtifact.forgeTools and SpooledMarkdownArtifact.forgeTools are the worked examples; a new subclass copies the shape and reads as cleanly as the bundled ones do.

One subclass, one set of descriptors

A descriptor should be reachable on exactly one class — the most-derived class that handles the method correctly. Subclasses inherit the base seven through forgeTools calling its parent's factory, not through descriptor concatenation. Listing the same descriptor twice forges duplicate tools with the same name and the same handler. The onCollision: 'replace' merge keeps one, silently. That is dead code disguised as design intent, and future you will waste an afternoon looking for the distinction that never existed.

ArtifactTool

ArtifactTool is the Tool subclass that backs every forged artifact-query tool. It exists because the artifact-query path needs to violate one rule the base Tool enforces: a normal tool's handler returns content that gets wrapped in a SpooledArtifact (via the artifactConstructor resolver), but an ArtifactTool's handler returns the model-visible answer to a query against an existing artifact — and wrapping that answer in another artifact would let the model artifact_grep on the grep result, spool yet another artifact, and so on. The fix at the type level is a different handler return type: string or Tokenizable. The fix at the schema level is artifactConstructor: validator.any().forbidden() — an ArtifactTool is the one tool shape that may not declare one. The ADK wraps a bare-string return into a Tokenizable at the result-wrapping site, so ToolCall.results for an ArtifactTool invocation is always a Tokenizable, never a SpooledArtifact.

The recursion-break flag is ToolCall.fromArtifactTool. The ADK sets it to true on every call produced by an ArtifactTool handler, and SpooledArtifact.forgeTools excludes those calls from the compatibleIds list before building the callId enum. The records still exist for persistence and observability — they just are not eligible forge targets, which is the entire reason the flag is on the call rather than buried inside the registry.

What artifacts do not do

The artifact surface is what it is and nothing else. It does not authorise — any tool whose query needs gating, scope-checking, or human approval handles that in its handler with Gates, not at the artifact layer. It does not deduplicate — two calls that produced equal byte sequences are two artifacts, and middleware that wants to canonicalise duplicates does so above the artifact. It does not impose a size policy — an artifact will hold whatever its SpoolReader can read, and a battery that wants to refuse oversized outputs declares the policy in middleware. It does not auto-stream content into messages — asString() is the explicit "inline the body" operation, and the executor decides whether to use it.

The artifact layer is data shape, not policy

Everything that looks like "should the model be allowed to see this artifact at all" or "should the model be allowed to call this query right now" is a middleware question — gated tools, RBAC, quota windows, redaction. The artifact is what the answer is given against, not the place the answer is enforced.

For the canonical executor wiring — including the bindContext line that makes the lifecycle automatic — see Forging tools.