Toolnexus.Agents.agent
Elixir · package toolnexus · SPEC §7D · elixir/lib/toolnexus/agents.ex
@spec agent(String.t(), keyword() | map()) :: Toolnexus.Agents.AgentDef.t()def agent(name, spec)
# spec: :does (required — routing description), :uses (%{tools: [...]}),# :soul | :soul_file (system prompt inline or from a file),# :team ([agent/2] this agent may delegate to — recursion is opt-in),# :budget (%{max_turns, max_tokens, max_tool_calls, max_wall_ms,# max_children, max_concurrent, max_depth}),# :model (default "inherit" — the runtime's llm.model),# :wait_for (§10 interpreter — (Request.t() -> Answer.t())),# :on_spawn / :on_close, :hooks / :on_metric (§8 seams, per-agent)
@spec run(AgentDef.t(), keyword() | map(), String.t()) :: map()def run(a, rt_opts, prompt)# one-shot: build a runtime, run to completion, tear down.# rt_opts are Toolnexus.Agents.Runtime options (:transport, :llm, ...).
@spec as_tool(AgentDef.t(), keyword() | map()) :: Toolnexus.Tool.t()def as_tool(a, rt_opts)# the bridge: an Agent IS a Tool — drop it into the classic client/toolkit API.The Level-1 surface over the agent runtime: one axiom, an Agent is a Tool — (system prompt ×
a filtered toolkit view × the §8 client loop), invocable uniformly. agent/2 is the one new noun;
run/3 (one-shot, tear itself down) and as_tool/2 (the bridge into the classic API) are how you
actually use a definition. Everything compiles down to the six Runtime
verbs — agent/2 never spawns a process by itself.
When to use it
Section titled “When to use it”- You want a sub-agent with its own system prompt, tools, and budget, callable by a parent
agent’s
tasktool, or run directly to completion. - Delegation without hand-rolling the runtime — declare a
:teamand the parent gets atasktool scoped to exactly that team; children never inherit delegation unless their own definition also declares a team (recursion is opt-in). - Dropping an agent into the classic toolkit/client API —
as_tool/2turns anyAgentDefinto a plainToolnexus.Tool, soToolnexus.Client.run/4can call it like any other tool.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — agent/2 builds a definition, no I/O
Section titled “1. The smallest useful call — agent/2 builds a definition, no I/O”alias Toolnexus.Agents
explore = Agents.agent("explore", does: "read-only research")coder = Agents.agent("coder", does: "implements changes", team: [explore], budget: %{max_tokens: 10_000})
true = explore.name == "explore"true = explore.spec[:does] == "read-only research"true = coder.spec[:team] == [explore]
# registry/2 is the transitive closure of the team graph — pure data, no process spawnedreg = Agents.registry(coder)true = reg |> Map.keys() |> Enum.sort() == ["coder", "explore"]true = reg["coder"][:task_targets] == ["explore"]
IO.puts("ok: #{map_size(reg)} agent(s) in the registry — #{Enum.join(Map.keys(reg), ", ")}")2. The realistic case — run/3 one-shot to completion, an in-memory transport
Section titled “2. The realistic case — run/3 one-shot to completion, an in-memory transport”The :transport option (the same §8 seam Toolnexus.Client.create accepts) replaces the LLM HTTP
call with an in-process function — zero network, zero cost, fully deterministic.
alias Toolnexus.Agents
mock_transport = fn %{body: body} -> msgs = body["messages"] || [] tool_msgs = Enum.filter(msgs, &(&1["role"] == "tool"))
resp = if tool_msgs == [] do %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "lookup", "arguments" => ~s({"q":"bug"})}} ]}}], "usage" => %{"prompt_tokens" => 5, "completion_tokens" => 3, "total_tokens" => 8} } else %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "bug at line 42"}}], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6} } end
{:ok, %{status: 200, headers: %{}, body: resp}}end
lookup = Toolnexus.define_tool(%{ name: "lookup", description: "look something up", input_schema: %{"type" => "object", "properties" => %{"q" => %{"type" => "string"}}}, execute: fn args -> "data(#{args["q"]})" end})
explore = Agents.agent("explore", does: "read-only research", uses: %{tools: [lookup]}, model: "m-explore")
r = Agents.run(explore, %{transport: mock_transport}, "find the bug")
true = r.status == "done"true = r.text == "bug at line 42"false = r.is_errortrue = r.turns == 2
IO.puts("ok: #{r.text} in #{r.turns} turns")3. The full surface — as_tool/2 bridges into the classic client/toolkit API
Section titled “3. The full surface — as_tool/2 bridges into the classic client/toolkit API”alias Toolnexus.{Agents, Client}
mock_transport = fn %{body: body} -> msgs = body["messages"] || [] tool_msgs = Enum.filter(msgs, &(&1["role"] == "tool"))
resp = case body["model"] do "m-explore" -> %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "bug at line 42"}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5}}
"m-old-api" -> if tool_msgs == [] do %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "e1", "type" => "function", "function" => %{"name" => "explore", "arguments" => ~s({"prompt":"scan the repo"})}} ]}}], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6} } else %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "summary: #{hd(tool_msgs)["content"]}"}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5} } end end
{:ok, %{status: 200, headers: %{}, body: resp}}end
explore = Agents.agent("explore", does: "read-only research", model: "m-explore")toolkit = [Agents.as_tool(explore, %{transport: mock_transport})]
client = Client.create(base_url: "http://mock.local", style: "openai", model: "m-old-api", api_key: "test", transport: mock_transport)
r = Client.run(client, "scan the repo for bugs", toolkit)
true = r.text =~ "summary:" and r.text =~ "bug at line 42"true = hd(r.tool_calls).metadata.agent == "explore"
IO.puts("ok: an Agent, called through the classic client API like any other tool")AgentDef.spec fields (Level-1 surface)
Section titled “AgentDef.spec fields (Level-1 surface)”| Field | Type | What it is |
|---|---|---|
:does |
String.t() |
Required routing description — advertised to a delegating parent’s task tool. |
:uses |
%{tools: [Tool.t()]} |
The toolkit view this agent’s turns see. |
:soul / :soul_file |
String.t() |
Inline system prompt, or a path to read it from. |
:team |
[AgentDef.t()] |
Delegation targets — never inherited by children. |
:budget |
map() |
See Budgets. |
:model |
String.t() |
Default "inherit" — resolves to the runtime’s llm.model. |
:wait_for |
(Request.t() -> Answer.t()) |
§10 interpreter authority for this agent. |
:on_spawn / :on_close |
functions | Lifecycle callbacks. |
:hooks / :on_metric |
§8 seams | Per-agent — replaces the runtime-wide value, never merged. |
See also
Section titled “See also”Toolnexus.Agents.Runtime— the six host verbs that drive sub-agents, plus the read-only list and inspect views.Toolnexus.Agents.Handle— the state machine for one spawned agent: pending, running, suspended, done.Toolnexus.Agents budget— cap tool calls and wall-clock per agent and per team, enforced while the run is in flight.