Pitfalls
Copy MarkdownCompile-time traps that are easy to miss when deriving tools from @spec and
@doc. Release behaviour is covered in design.md; the type subset and runtime
boundaries are covered in design.md and
number-handling.md.
@spec carries no parameter names
@spec f(String.t()) is a value domain, not a record. The schema needs property
names, so the library requires annotated arguments:
@spec get_weather(city :: String.t()) :: ...The quoted AST for that argument is {:"::", _, [{:city, _, nil}, type]}, so the
name survives compilation. An unannotated argument can also take a name from a
@doc parameter bullet; otherwise it falls back to the head variable, then
argN, with a compile-time warning.
@spec is an accumulative attribute
Module.get_attribute(mod, :spec) contains specs for earlier functions too:
@spec get_weather(String.t()) :: ...
@spec two_specs(integer()) :: ...The library filters by {name, arity} and warns when a function has several
specs; overloads cannot be represented as one JSON Schema.
@doc is single-valued and consumed immediately
@doc is not an accumulating attribute. Reading it later can attach the last
function's text to every tool, or return nil. The library captures it from
@on_definition, before the attribute is reset; @doc false opts a function
out.
JSON Schema integer accepts 1.0
Draft 2020-12 defines integer as a number with a zero fractional part, so a
decoder can produce 1.0. ToolCall.Coerce truncates integral floats and
rejects fractional ones.
float() parameters can receive integers
{"type":"number"} accepts 2, while is_float(2) is false. ToolCall.Coerce
widens integers to floats before dispatch.
JSON numbers Elixir cannot represent
1e309 is valid JSON but not a finite Erlang float; the decoder raises before
the library sees it. The schema cannot prevent this. See
number-handling.md.
@on_definition fires for private functions and every clause
The hook sees private helpers and each clause of a multi-clause function. The
library ignores non-def callbacks and keeps the first {name, arity} entry.
@spec names are documentation, not syntax
The compiler allows duplicate and placeholder parameter names:
@spec f(city :: String.t(), city :: integer()) :: String.t()
@spec g(_ :: String.t(), _ :: integer()) :: String.t()The library renames duplicates (city_2) and falls back from _ to the head
variable or argN, with a warning. Two arities of one function share a tool
name; only the first is exposed.
@doc metadata never reaches the :doc attribute
Metadata-only docs such as @doc deprecated: "use bar/1" do not reach the
attribute read by @on_definition. They also live in the Docs chunk, which a
stripped release removes. A tool declared that way falls back to its function
name; write text as well if the model should see a description.
@typedoc is consumed before @before_compile
@typedoc is consumed by the following @type before @before_compile runs,
so the phase-one IR cannot see it. ToolCall recovers docs-visible @type and
@opaque text in @after_compile from the just-compiled Docs chunk and
stores the enriched literals in a companion module. It is only a fallback
parameter description: a @doc ## Parameters bullet or a @tool_param
description wins. Local alias chains are followed to the nearest documented
type, resolving current-module references inside alias bodies; nested uses such
as list(address()) do not inherit the typedoc.
@typep is different: Elixir discards its typedoc at definition time, so no
docs entry exists to recover. A private type is still resolved for the schema,
but it never contributes a description.
Stale typedoc companions are inert and cleaned by a compile or task
The companion beam is not registered in Mix's compile manifest. Source-file
companions carry a .source sidecar with their source path; every ToolCall
compile prunes companions whose source file is gone. If no ToolCall module
compiles after deleting a source file, or a module was removed from an existing
file, run mix tool_call.clean before assembling a release. The task also
scans companion beams directly, using the parent beam and a live source from
either the .source sidecar or the beam's compile_info.source; a stripped
compile_info leaves only the parent-beam check.
The compile-generation token makes any remaining stale companion unusable by
the new module, so it falls back to phase-one data instead of serving stale
descriptions. A companion generated by a ToolCall build that predates the
__tool_call_companion__/0 marker is treated as foreign, so upgrade with one
mix clean or ToolCall warns and skips typedoc enrichment rather than
overwriting it.
A missing Docs chunk is not always a problem
docs: false intentionally omits the chunk. ToolCall skips typedoc silently in
that compile and only warns about a missing chunk when docs are enabled. An
undecodable or structurally invalid Docs chunk is reported as corrupt and
falls back to phase-one descriptions.
@on_definition accumulates
Unlike @doc, @on_definition is an accumulating attribute. A use ToolCall
does not replace another library's definition hook; both callbacks fire,
including for generated functions such as __tools__/0.
Remaining design boundaries
Some limits are intentional: nested/remote/provider typedoc inheritance,
@typep typedoc recovery, overloaded specs, two arities of one function,
tuple defaults, JSON numbers outside Erlang's finite range, @doc metadata-only
entries and type-provider typedoc. See the Design boundaries section in
design.md.