Skip to content
3 min read · 583 words

Advanced details

You can write simple tools without this page. Read it when a callId does not match, artifactConstructor looks like needless indirection, or someone tries to return Media through the artifact path. These are not trivia questions; they are where the weird bugs live.

Tools covers the Tool constructor and handler contract.

How the callId is derived

The executor hashes sha256(canonicalStringify({ tool: tool.name, args })) over the raw, pre-validation arguments. The tool field is explicitly the tool's name string (see Tool.name), not the Tool instance — refactoring fields on the class will not change the hash. canonicalStringify is the ADK's canonical JSON encoder: object keys sorted via Array.prototype.sort's default (UTF-16 code-unit) comparator with recursion, arrays in declared order (order is meaningful for an array), primitives delegated to JSON.stringify. Two invocations with the same tool.name and the same arguments therefore produce the same callId regardless of argument key order, and the same value appears as ToolCall.checksum so the rest of the loop can correlate the call across the start event, the end event, the persisted record, and the model-facing nonce.

Because primitives go through JSON.stringify, arguments inherit its grammar — and its quirks. BigInt values and cyclic references throw TypeError and fail the call out loud, which is the correct outcome. NaN, Infinity, and undefined are the hash-collision gremlins: they do not throw, they degrade. NaN and Infinity serialise to "null"; undefined is dropped from objects and becomes "null" in arrays. Two different argument shapes can collapse to the same callId. Keep tool arguments inside the real JSON grammar — strings, finite numbers, booleans, null, arrays, plain objects — or accept garbage correlation.

Why artifactConstructor is a resolver, not a direct reference

Tool.artifactConstructor is typed as () => SpooledArtifactConstructor — a zero-argument closure that returns the SpooledArtifactConstructor — rather than a direct constructor reference. The reason is a module-load cycle: Tool, SpooledArtifact, and ArtifactTool all import each other transitively, and a direct reference would resolve to undefined at module-load time and crash at construction. The resolver is invoked at validation time, after the cycle has finished unwinding, by which point the constructor is fully defined. It's a small piece of plumbing that exists entirely so the three classes can know about each other without crashing at startup.

Why artifactConstructor does not accept a Media constructor

Tool.artifactConstructor is a wrap-site indirection — it tells the ADK "when you spool the string / Uint8Array my handler returned, wrap the bytes in this SpooledArtifact subclass." It exists because the handler returns raw bytes and the ADK has to decide what to wrap them in.

Media bypasses that wrap site entirely. The handler constructs the Media itself — because the handler is the only place that knows the Media.kind, Media.mimeType, Media.filename, Media.trustTier, and Media.modalityHazard the primitive requires — and the ADK lands the instance on ToolCall.results untouched. There is no raw-bytes step for artifactConstructor to plug into.

The two also point at deliberately disjoint contracts: SpooledArtifact wraps a SpoolReader (line-indexed text — SpooledArtifact.head/SpooledArtifact.tail/SpooledArtifact.grep/SpooledArtifact.asString); Media wraps a MediaReader (opaque binary streaming — MediaReader.stream/MediaReader.byteLength). An artifactConstructor that resolved to a Media constructor would be a category error: the wrap site has bytes and no modality metadata; Media requires modality metadata and accepts a reader, not bytes. If your tool produces media, return Media directly — artifactConstructor is for the artifact path.