Toolnexus.Client (toolnexus v0.12.0)

Copy Markdown View Source

Unified LLM client — the host loop (SPEC §8, §10).

Give it a plain base_url + a style ("openai" | "anthropic") and it runs the whole tool-calling agent loop against a toolkit — either a plain list of %Toolnexus.Tool{} or any struct/map with :tools and :prompt fields (the skills prompt).

client = Toolnexus.Client.create(base_url: "...", style: "openai", model: "gpt-4o-mini")
result = Toolnexus.Client.run(client, "add 2 and 3", tools)

Summary

Functions

Stateful ask. With an :id (or a bare conversation-id string as the 4th argument), the client's store remembers the conversation: load transcript → run → save. Without an id, a stateless one-shot identical to run/4. With :on_text, streams and forwards text deltas while still returning the final %RunResult{}.

§8 Gap 4. The client's conversation store — the exact instance passed in :store, else the default in-memory store the client created.

Create a client. Options (SPEC §8 ClientOptions)

Prometheus text exposition of cumulative metrics (§8). Empty-but-valid before activity.

Run the agent loop: system prompt → LLM call → execute tool calls (concurrently, results fed back in call order) → repeat to :max_turns. Returns a %RunResult{}.

Streaming variant: returns an Enumerable of event maps — %{type: "text", delta}, %{type: "tool_call", id, name, args}, %{type: "tool_result", id, name, output, is_error}, %{type: "usage", usage}, %{type: "pending", request} (§10), %{type: "done", result}.

Single-turn translation (§11): exactly ONE provider call, returned in OpenAI shape. No agent loop, no tool execution, no conversation state — every call is self-contained, so this may be run statelessly and concurrently.

Types

t()

@type t() :: %Toolnexus.Client{
  api_key: term(),
  base_url: term(),
  body_transform: term(),
  deadline: term(),
  headers: term(),
  hooks: term(),
  http_options: term(),
  max_turns: term(),
  model: term(),
  on_error: term(),
  on_metric: term(),
  registry: term(),
  request_params: term(),
  retries: term(),
  retry_base_ms: term(),
  store: term(),
  style: term(),
  system_prompt: term(),
  timeout_ms: term(),
  transport: term(),
  wait_for: term()
}

Functions

ask(client, prompt, toolkit, opts \\ [])

@spec ask(t(), String.t(), term(), keyword() | String.t()) ::
  Toolnexus.Client.RunResult.t()

Stateful ask. With an :id (or a bare conversation-id string as the 4th argument), the client's store remembers the conversation: load transcript → run → save. Without an id, a stateless one-shot identical to run/4. With :on_text, streams and forwards text deltas while still returning the final %RunResult{}.

conversation_store(client)

@spec conversation_store(t()) :: struct()

§8 Gap 4. The client's conversation store — the exact instance passed in :store, else the default in-memory store the client created.

create(opts)

@spec create(keyword() | map()) :: t()

Create a client. Options (SPEC §8 ClientOptions):

  • :base_url, :style ("openai" | "anthropic"), :model, :api_key (env fallback: OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY), :headers

  • :system_prompt — prepended to the toolkit's skills prompt (joined with "\n\n")
  • :max_turns (default 10)
  • :on_error(%{error?, status?, attempt, retryable} -> :retry | :fail) classifies each failed LLM attempt (§8 Resilience). Absent ⇒ default (retryable ⇒ :retry, else :fail), byte-identical to today. A :retry is bounded by :retries. No :suspend tier.

  • :hooks — map with :before_llm, :after_llm, :before_tool, :after_tool
  • :retries (default 2), :retry_base_ms (default 500), :timeout_ms (whole-run deadline)
  • :store — a Toolnexus.Client.ConversationStore struct (default: in-memory)
  • :on_metric(event_map -> any) semantic observability sink
  • :wait_for — §10 suspension resolver, (Request -> Answer)
  • :request_params — map shallow-merged into every LLM body AFTER base keys (caller wins; messages/tools/stream forbidden) — §8 Gap 1
  • :body_transform(body -> body) run LAST after the merge — §8 Gap 1
  • :http_options — extra Req options for the LLM path (§8 Gap 2, in-process variant)
  • :transport — §8 Gap 2, the first-class injectable HTTP transport for the LLM path: a function (request -> {:ok, response} | {:error, Exception.t()}) where request is %{method: :post, url: String.t(), headers: %{String.t() => String.t()}, body: map(), receive_timeout: non_neg_integer() | nil} (body is the un-marshalled JSON body) and response is %{status: integer(), headers: map() | list(), body: term()} (body decoded for JSON endpoints, raw binary for SSE). It replaces the wire call only — retries, backoff, Retry-After, deadline, and error classification still run around it, and it composes against a real base_url (unlike :http_options :plug, which is in-process only). nil ⇒ the default Req transport, byte-identical behavior.
  • :registry — inject a shared MetricsRegistry (e.g. one runtime-wide registry across many clients); nil ⇒ a fresh private registry, byte-identical behavior

metrics(client)

@spec metrics(t()) :: String.t()

Prometheus text exposition of cumulative metrics (§8). Empty-but-valid before activity.

run(client, prompt, toolkit, opts \\ [])

@spec run(t(), String.t(), term(), keyword()) :: Toolnexus.Client.RunResult.t()

Run the agent loop: system prompt → LLM call → execute tool calls (concurrently, results fed back in call order) → repeat to :max_turns. Returns a %RunResult{}.

opts: :history — a prior transcript to continue.

stream(client, prompt, toolkit, opts \\ [])

@spec stream(t(), String.t(), term(), keyword()) :: Enumerable.t()

Streaming variant: returns an Enumerable of event maps — %{type: "text", delta}, %{type: "tool_call", id, name, args}, %{type: "tool_result", id, name, output, is_error}, %{type: "usage", usage}, %{type: "pending", request} (§10), %{type: "done", result}.

With an :id it is stateful like ask: history is loaded before streaming and the transcript is saved back to the store on the terminal done event.

translate(client, messages, opts \\ [])

@spec translate(t(), [map()], keyword()) :: Toolnexus.Translate.Result.t()

Single-turn translation (§11): exactly ONE provider call, returned in OpenAI shape. No agent loop, no tool execution, no conversation state — every call is self-contained, so this may be run statelessly and concurrently.

The inbound half of the adapters: to_anthropic/1/to_gemini/1 send declarations out, this reads the provider's tool calls back in. A :toolkit in opts is DECLARED and never executed.

messages is the OpenAI messages list, taken verbatim. Options:

  • :tools — the OpenAI tools array, verbatim (declaration-only)
  • :toolkit — an ordinary toolkit, declared but never executed; composes with :tools
  • :tool_choice — the OpenAI tool_choice, verbatim
  • :system — overrides the system prompt
  • :max_tokens — overrides the per-provider default

Retries/backoff, request-param merging and the llm metric are shared with the loop; before_llm/after_llm fire once and tool hooks never fire.

Returns a Toolnexus.Translate.Result.