Compact tool disclosure: stub projection, on-demand help, and the built-in
tool_help meta-tool.
A pure runtime helper (no engine, no adapter, no process state). The chat
loop uses it to send tools marked compact: true (see ALLM.Tool) to the
model as one-line stubs, and to answer the model's requests for the full
definitions.
How a compact tool reaches the model
project/2 replaces every compact tool with a stub that keeps the tool's
real name, a one-line description ending in an argument hint, and the bare
schema {"type": "object"}. It then appends one tool_help tool (see
meta_tool/0) whenever at least one compact tool is present:
iex> tool =
...> ALLM.Tool.new(
...> name: "create_issue",
...> description: "Create a new issue in a repository. Returns its URL.",
...> schema: %{
...> "type" => "object",
...> "properties" => %{"repo" => %{"type" => "string"}, "title" => %{"type" => "string"}, "body" => %{"type" => "string"}},
...> "required" => ["repo", "title"]
...> },
...> compact: true
...> )
iex> [stub, meta] = ALLM.ToolHelp.project([tool], nil)
iex> stub.description
"Create a new issue in a repository. Args: repo, title [body] [compact]"
iex> stub.schema
%{"type" => "object"}
iex> meta.name
"tool_help"Execution always uses the full tool. Only the list sent to the model is projected.
Stubs stay directly callable
A stub keeps its real name, so the model can call it straight away
whenever the argument hint is enough. tool_help is only needed when the
model wants the full description or parameter schema, so the common case
costs no extra round trip. When a compact tool is called without one of
its required top-level arguments, the call returns a usage error carrying
the tool's full help instead of running the handler (see check_args/2).
The tool list sent to the model is stable
project/2 is a pure, deterministic function of the tool list and the
tool_choice: equal inputs give equal outputs. Across the steps of one
run the list sent to the model therefore never changes, so provider
prompt caches keyed on the tool definitions keep hitting. Learning about a
tool through tool_help adds a tool result to the conversation; it never
changes the tool list.
Forcing a compact tool
When tool_choice forces one specific tool and that tool is compact, it
is sent in full, because a model forced to call a bare stub would have to
guess its arguments with no chance to ask first. See project/2 for the
recognised shapes. One case to watch: a Gemini-native
%{"mode" => "ANY", "allowedFunctionNames" => [...]} naming more than one
function leaves every stub compact, and if the list omits "tool_help"
the model cannot call tool_help at all.
Recognising the meta-tool
The meta-tool is identified by a string-keyed marker in its :metadata
(%{"allm_builtin" => "tool_help"}), not by its name, so the marker
survives a JSON round trip. When no compact tool is present, a tool of
your own named tool_help is an ordinary tool. When at least one compact
tool is present, your tool and the injected one share a name, and the
request is rejected before it is sent with
{:tools, :duplicate_name}. Rename your tool in that case.
Manual mode
Under whole-loop mode: :manual the caller runs every tool call,
including tool_help: the call surfaces like any other, and
answer/2 builds the content string to submit for it. Under per-tool
manual (manual: true on individual tools) the meta-tool itself is not
manual, so the loop runs it automatically.
Compact tools that the caller executes never pass through the tool
runner, so they get no usage-error check. Call check_args/2 yourself
if you want it.
Calling the tool runner directly
ALLM.ToolRunner.run_tool_calls/3 and ALLM.ToolRunner.stream_tool_calls/3
apply the required-argument check to compact tools as well. They answer a
tool_help call only when the tool list you pass contains meta_tool/0
(use with_meta_tool/1 to add it).
Summary
Functions
Build the content string answering a tool_help call, for callers that
run tool calls themselves (for example under mode: :manual). Equal to
render/2 on the call's arguments, with nil arguments treated as %{}.
Check that a compact tool's call supplies every required top-level argument.
Whether tool is compact. Only compact: true counts: any other value
(including a badly-typed one from a hand-built struct or decoded JSON)
means the tool is sent in full.
The built-in tool_help meta-tool.
Whether tool is the built-in meta-tool, judged by its metadata marker,
not its name. Never raises.
Project a resolved tool list into the list sent to the model.
Render the full help for the tools named in a tool_help call's
arguments.
Functions
@spec answer([ALLM.Tool.t()], ALLM.ToolCall.t()) :: String.t()
Build the content string answering a tool_help call, for callers that
run tool calls themselves (for example under mode: :manual). Equal to
render/2 on the call's arguments, with nil arguments treated as %{}.
Pass the full tool list, including meta_tool/0.
Examples
iex> tool = ALLM.Tool.new(name: "lookup", description: "Look up a record.", schema: %{}, compact: true)
iex> call = ALLM.ToolCall.new(id: "call_1", name: "tool_help", arguments: %{"names" => ["lookup"]})
iex> ALLM.ToolHelp.answer([tool, ALLM.ToolHelp.meta_tool()], call)
"## lookup\nLook up a record.\nParameters (JSON Schema): {}"
@spec check_args(ALLM.Tool.t(), map()) :: :ok | {:error, String.t()}
Check that a compact tool's call supplies every required top-level argument.
Returns :ok for any tool that is not compact, so full tools behave
exactly as they would without this check. For a compact tool, returns
{:error, usage} when a name in the schema's "required" list is absent
from args. usage names the missing arguments in "required" order and
ends with the tool's full help (see render/2), so the model can correct
its call in one round trip even if it never called tool_help. The chat
loop routes this error through your on_tool_error policy like any
handler error.
An argument counts as present under its string key or an existing atom
of the same name. Only presence is checked: no types, no nesting, no
unknown-key rejection. Never raises: non-map args count as no arguments,
and a schema without a "required" list has no required arguments.
Examples
iex> tool = ALLM.Tool.new(name: "lookup", description: "Look up a record.", schema: %{"type" => "object", "properties" => %{"id" => %{"type" => "string"}}, "required" => ["id"]}, compact: true)
iex> ALLM.ToolHelp.check_args(tool, %{"id" => "42"})
:ok
iex> {:error, usage} = ALLM.ToolHelp.check_args(tool, %{})
iex> String.split(usage, "\n") |> Enum.take(3)
["missing required argument(s): id", "", "## lookup"]
@spec compact?(ALLM.Tool.t()) :: boolean()
Whether tool is compact. Only compact: true counts: any other value
(including a badly-typed one from a hand-built struct or decoded JSON)
means the tool is sent in full.
Examples
iex> ALLM.ToolHelp.compact?(ALLM.Tool.new(name: "t", description: "d", schema: %{}, compact: true))
true
iex> ALLM.ToolHelp.compact?(%ALLM.Tool{name: "t", description: "d", schema: %{}, compact: :yes})
false
@spec meta_tool() :: ALLM.Tool.t()
The built-in tool_help meta-tool.
Plain data with no handler, so it serializes like any other tool. It
takes {"names": ["tool_name", ...]} and is answered by render/2.
Examples
iex> meta = ALLM.ToolHelp.meta_tool()
iex> {meta.name, meta.handler, meta.metadata}
{"tool_help", nil, %{"allm_builtin" => "tool_help"}}
iex> meta.schema["required"]
["names"]
@spec meta_tool?(ALLM.Tool.t()) :: boolean()
Whether tool is the built-in meta-tool, judged by its metadata marker,
not its name. Never raises.
Examples
iex> ALLM.ToolHelp.meta_tool?(ALLM.ToolHelp.meta_tool())
true
iex> ALLM.ToolHelp.meta_tool?(ALLM.Tool.new(name: "tool_help", description: "mine", schema: %{}))
false
@spec project([ALLM.Tool.t()], ALLM.Request.tool_choice() | {:tool, String.t()}) :: [ ALLM.Tool.t() ]
Project a resolved tool list into the list sent to the model.
Every compact tool is replaced by its stub, in place, and meta_tool/0 is
appended when at least one compact tool is present. Non-compact tools are
returned unchanged, so a list with no compact tool comes back == to the
input. The result is deterministic: equal inputs give equal outputs.
When tool_choice forces a single tool by name, that tool is sent in
full even if it is compact. The forced name is read from:
- a binary tool name, or
{:tool, name}; %{"type" => "tool", "name" => name}(string or atom keys);%{"type" => "function", "function" => %{"name" => name}}and the flat%{"type" => "function", "name" => name}(string or atom keys);%{"mode" => "ANY", "allowedFunctionNames" => [name]}with exactly one name (string or atom keys).
:auto, :none, :required, nil and any other shape leave every
compact tool stubbed.
Examples
iex> full = ALLM.Tool.new(name: "search", description: "Search the web.", schema: %{})
iex> compact = ALLM.Tool.new(name: "lookup", description: "Look up a record. Slow.", schema: %{}, compact: true)
iex> ALLM.ToolHelp.project([full, compact], :auto) |> Enum.map(& &1.description)
["Search the web.", "Look up a record. [compact]", ALLM.ToolHelp.meta_tool().description]
iex> ALLM.ToolHelp.project([full, compact], {:tool, "lookup"}) |> Enum.map(& &1.description) |> Enum.at(1)
"Look up a record. Slow."
iex> ALLM.ToolHelp.project([full], :auto) == [full]
true
@spec render([ALLM.Tool.t()], map()) :: String.t()
Render the full help for the tools named in a tool_help call's
arguments.
args is %{"names" => [name, ...]}, %{"names" => name}, or the
atom-keyed equivalent. Each requested name gets one section, in request
order and deduplicated, and sections are separated by a blank line. A
known tool, compact or not, renders as its name, full description and
compact JSON schema. An unknown name renders a note listing the compact
tools ((none) when there are none). Any other args shape returns a
short usage string ending in the same list. Never raises:
a schema that cannot be encoded as JSON is shown with inspect/1.
Examples
iex> tool = ALLM.Tool.new(name: "lookup", description: "Look up a record.", schema: %{"type" => "object"}, compact: true)
iex> ALLM.ToolHelp.render([tool], %{"names" => ["lookup"]})
"## lookup\nLook up a record.\nParameters (JSON Schema): {\"type\":\"object\"}"
iex> ALLM.ToolHelp.render([tool], %{"names" => ["nope"]})
"## nope\nUnknown tool. Compact tools: lookup"
iex> ALLM.ToolHelp.render([tool], %{})
"tool_help expects {\"names\": [\"tool_name\", ...]}. Compact tools: lookup"