Imp.Tool (Imp v0.5.0)

Copy Markdown View Source

Tool definition for ReAct-style programs and supervised Elixir workflows.

A tool is a named, schema-described Elixir function. ReAct programs expose tools to the language model, while ordinary Elixir code can call the same tool values directly. The name is the action, the description is written for the model or a human reader, the schema is the input contract, and the function is ordinary Elixir.

A tool receives its arguments as a map with string keys, the shape a JSON tool call carries and MCP expects: write fn %{"country" => country} -> .... Keys are never turned into atoms, whichever runtime makes the call and whether or not the atom exists; atom keys passed from Elixir code become strings before the tool runs.

Tool calls validate JSON-schema-shaped input contracts before invoking the runner, are wrapped in Imp telemetry, and runtime traces redact sensitive values before they are stored.

Example

iex> tool =
...>   Imp.Tool.new(:lookup, "lookup a capital city", fn %{"country" => "France"} ->
...>     "Paris"
...>   end)
iex> Imp.Tool.call(tool, %{"country" => "France"})
"Paris"

Summary

Types

What a tool call came to, from the caller's side.

t()

Functions

Calls a tool with one argument.

Builds a tool from a name, description, unary function, and optional schema.

Normalizes model/provider tool arguments into the argument a tool receives.

The outcome of a tool call, read from the value the call returned.

Resolves a model/provider tool name to the canonical name in a tool catalog.

Types

outcome()

@type outcome() :: :result | :refused | :auth_refused | :not_sent | :unknown

What a tool call came to, from the caller's side.

  • :result — the tool answered. Its answer may itself be an error the tool reported, such as an MCP error result; the tool said what happened.
  • :refused — declined before anything ran: Imp's own checks (an unknown tool, a malformed call, arguments that fail the schema, a tool policy, a host's authorization, submit outputs that do not fit), recorded by the loop that made them; for an MCP tool, the server or its HTTP layer; or an MCP error result that declares it.
  • :auth_refused — the credential was refused before anything ran: an MCP server's HTTP 401, a failed OAuth flow, or an MCP error result that declares it. Renewing the credential and trying once more may succeed.
  • :not_sent — an MCP request that never left.
  • :unknown — the tool may have acted and there is no answer to say whether it did: a tool function that raised, threw or exited, an RLM budget that stopped or refused the call, an MCP call with no trustworthy answer, or an MCP error result that declares it. Check before repeating it.

t()

@type t() :: %Imp.Tool{
  description: String.t(),
  metadata: map(),
  name: atom() | String.t(),
  run: (map() -> term()),
  schema: map()
}

Functions

call(tool, arg)

Calls a tool with one argument.

Atom map keys in the argument become strings first, at every depth, as in normalize_arguments/1, so Imp.Tool.call(tool, %{query: "q"}) and a model's {"query": "q"} both reach the tool as %{"query" => "q"}. A string argument is passed as given, not decoded.

This executes the underlying function inside a [:imp, :tool] telemetry span after validating the argument against the supported JSON Schema input contract. Schema validation errors are returned without invoking the runner. Policy checks and trace redaction remain the responsibility of the calling runtime.

index_tools!(tools, context)

new(name, description, run, opts \\ [])

Builds a tool from a name, description, unary function, and optional schema.

The name keeps the type it was given: an atom stays an atom and a string stays a string. Tools imported from an MCP server are named by the string the server published. A string is never turned into an atom, so a name's type does not depend on which atoms happen to be loaded in the VM, and no atom is created from a remote server's or a model's text. Lookups by name (resolve_name/2) compare atoms and strings by their text.

The schema is a JSON-schema-shaped map used by ReAct/provider adapters and by humans reading the program boundary.

normalize_arguments(arguments)

Normalizes model/provider tool arguments into the argument a tool receives.

A JSON string is decoded. Every atom map key becomes a string, at every depth, so a tool receives the same shape whether its call came from a provider's JSON, an interpreter, or Elixir code passing atom keys. Nothing is turned into an atom, so model output cannot create atoms. Structs are left as they are.

iex> Imp.Tool.normalize_arguments(~s({"query":"capital"}))
%{"query" => "capital"}
iex> Imp.Tool.normalize_arguments(%{query: "capital", filter: %{year: 2024}})
%{"query" => "capital", "filter" => %{"year" => 2024}}

outcome(arg1)

@spec outcome(term()) :: outcome()

The outcome of a tool call, read from the value the call returned.

Pass what call/2 returned, or the {:error, reason} a ReActV2 or RLM loop recorded for the call. A tool function can return any term, including one that looks like an Imp refusal, so a value alone never reads as :refused unless something that knows declared it: an Imp.MCP.CallFailure, which Imp builds where ExMCP's error arrives, or an MCP error result whose structuredContent.outcome is "refused", "auth_refused" or "unknown". Imp's own refusals are decided by the loop that made them, which records the outcome on the call's :tool_result event as metadata.outcome; read that rather than this for a recorded call.

iex> Imp.Tool.outcome("Paris")
:result
iex> Imp.Tool.outcome({:error, {:tool_error, :lookup, {:exit, :killed}}})
:unknown
iex> Imp.Tool.outcome({:error, {:mcp_tool_error, %{
...>   "isError" => true,
...>   "content" => [%{"type" => "text", "text" => "error: write outcome unknown"}],
...>   "structuredContent" => %{"code" => "write_outcome_unknown", "outcome" => "unknown"}
...> }}})
:unknown

outcomes()

@spec outcomes() :: [outcome()]

Every outcome/0.

resolve_name(tools, name)

Resolves a model/provider tool name to the canonical name in a tool catalog.

Provider payloads commonly send names as strings, while Elixir code usually stores tool names as atoms. This helper performs string-equivalent lookup without creating atoms from model output.

iex> tools = [Imp.Tool.new(:lookup, "lookup", fn _ -> :ok end)]
iex> catalog = Imp.Tool.index_tools!(tools, "example")
iex> Imp.Tool.resolve_name(catalog, "lookup")
:lookup
iex> Imp.Tool.resolve_name(catalog, "missing")
nil