Packages
Derive LLM tool-call schemas from Elixir @spec and @doc, with a runtime dispatcher.
Current section
Files
Jump to
Current section
Files
docs/pitfalls.md
# Pitfalls
Compile-time traps that are easy to miss when deriving tools from `@spec` and
`@doc`. Release behaviour is covered in [`design.md`](design.md); the type subset and runtime
boundaries are covered in [`design.md`](design.md) and
[`number-handling.md`](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:
```elixir
@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:
```elixir
@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`](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:
```elixir
@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`](design.md).